From 44e6438bd0b1e1d4d01f43a1d44668895200996d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:19:30 +0200 Subject: [PATCH 1/7] feat(contract): add the FTW Cloud names Copied byte for byte from srcfl/ftw-webapp, which holds the source: scope ftw.events.read, the cloud_scopes and cloud_purposes lists, capability cloud.link, carrier state cloud, and push kinds cloud.granted and cloud.revoked. The generator now writes CloudScopes and CloudPurposes into go/protocol, and the registry header names the file it actually generates. Co-Authored-By: Claude Opus 5.5 --- contract/push-catalogue.yaml | 9 ++++++ contract/registry.yaml | 27 ++++++++++++++-- go/internal/apiauth/contract_gen.go | 1 + .../appproto/gencontract/gencontract.go | 31 +++++++++++++++++++ go/protocol/contract_gen.go | 25 +++++++++++++++ go/protocol/push_gen.go | 4 +++ 6 files changed, 94 insertions(+), 3 deletions(-) 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/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/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/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."}, } From 4896e6160a21e68f7457680d5e009528baab55a1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:19:30 +0200 Subject: [PATCH 2/7] feat(protocol): add the FTW Cloud wire format go/protocol/cloudwire holds what docs/protocol-cloud.md specifies: the unpadded lane 1 frame, the {t, b, sig} envelope and its ES256 signing rule, home and opaque ids, the message bodies, the scope of each class and the field allowlist. A test fails when a body grows a field the allowlist lacks, and compares the allowlist with the protocol document's tables when the document is present. Golden vectors in testdata/cloud-vectors.json freeze the bytes for srcfl/ftw-cloud and the phone. View is the phone's freshness rules as a reference, with the document's cases as tests. Co-Authored-By: Claude Opus 5.5 --- go/protocol/cloudwire/allowlist.go | 52 +++ go/protocol/cloudwire/allowlist_test.go | 166 ++++++++ go/protocol/cloudwire/messages.go | 295 +++++++++++++ .../cloudwire/testdata/cloud-vectors.json | 166 ++++++++ go/protocol/cloudwire/vectors_test.go | 279 +++++++++++++ go/protocol/cloudwire/view.go | 347 +++++++++++++++ go/protocol/cloudwire/view_test.go | 314 ++++++++++++++ go/protocol/cloudwire/wire.go | 394 ++++++++++++++++++ go/protocol/cloudwire/wire_test.go | 303 ++++++++++++++ 9 files changed, 2316 insertions(+) create mode 100644 go/protocol/cloudwire/allowlist.go create mode 100644 go/protocol/cloudwire/allowlist_test.go create mode 100644 go/protocol/cloudwire/messages.go create mode 100644 go/protocol/cloudwire/testdata/cloud-vectors.json create mode 100644 go/protocol/cloudwire/vectors_test.go create mode 100644 go/protocol/cloudwire/view.go create mode 100644 go/protocol/cloudwire/view_test.go create mode 100644 go/protocol/cloudwire/wire.go create mode 100644 go/protocol/cloudwire/wire_test.go 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) + } + }) +} From b0e497067c1d1fe5137164be27e768e15e7b5371 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:30:17 +0200 Subject: [PATCH 3/7] feat(cloudlink): stream a signed copy of the box to FTW Cloud go/internal/cloudlink is the box's uplink to FTW Cloud. It is off until an owner grants it, and a grant whose key fingerprint does not match cloud.key reads as off. - One WebSocket over TLS 1.3 with no proxy. The box sends nothing but hello until the service has signed a hello_ok bound to this TLS session's exporter value and the box's nonce. - Every message is signed with cloud.key and carries seq, boot, uptimeMs and wallMs while NTP has set the clock. - live, lp and prices go on change, at most every 5 s (1 s while watched, lp at least 2 s); a table change goes at once with a new srcRev; hb goes after H and never while a changed live or lp waits. - One writer per socket: a slow socket gets the newest values, nothing queues, and a write blocked for 10 s closes it. Past 20 MB in 24 h of uptime only roster and bye go. - Driver and loadpoint names become opaque ids; capability fields are not in the shapes. The downlink is a closed set of five types, under a fuzz test. - A revoke sends bye with purge and keeps the key until the service's signed receipt arrives. CI runs go list -deps to keep control, API and loadpoint code out of the package's build graph. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-cloudlink-imports.sh | 24 + .github/workflows/test.yml | 4 + Makefile | 8 +- go/internal/cloudlink/clock_linux.go | 16 + go/internal/cloudlink/clock_other.go | 14 + go/internal/cloudlink/conn.go | 311 ++++++++++ go/internal/cloudlink/downlink_test.go | 74 +++ go/internal/cloudlink/grant.go | 187 ++++++ go/internal/cloudlink/link.go | 567 +++++++++++++++++ go/internal/cloudlink/link_test.go | 690 +++++++++++++++++++++ go/internal/cloudlink/sender.go | 577 +++++++++++++++++ 11 files changed, 2470 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/check-cloudlink-imports.sh create mode 100644 go/internal/cloudlink/clock_linux.go create mode 100644 go/internal/cloudlink/clock_other.go create mode 100644 go/internal/cloudlink/conn.go create mode 100644 go/internal/cloudlink/downlink_test.go create mode 100644 go/internal/cloudlink/grant.go create mode 100644 go/internal/cloudlink/link.go create mode 100644 go/internal/cloudlink/link_test.go create mode 100644 go/internal/cloudlink/sender.go 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/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..44a6105a0 --- /dev/null +++ b/go/internal/cloudlink/conn.go @@ -0,0 +1,311 @@ +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, 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. Each is handed to the sender, which owns what it changes. +func (l *Link) read(conn *websocket.Conn, 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.MsgWatch, cloudwire.MsgResync, cloudwire.MsgPurged: + 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..354645ed6 --- /dev/null +++ b/go/internal/cloudlink/link.go @@ -0,0 +1,567 @@ +// 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 || !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("cloudlink: reading dev root CA %s: %v", DevRootCA, err) + } + 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..4f9efe740 --- /dev/null +++ b/go/internal/cloudlink/link_test.go @@ -0,0 +1,690 @@ +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 +} + +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() + 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) { + 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() + env := svc.waitFor(cloudwire.MsgLive, 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 TestChangeTestsIgnoreAges(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 lpKey(a) != lpKey(b) { + t.Fatal("lp differs only in ages and still counts as changed") + } + b.Loadpoints[0].PowerW = 1 + if lpKey(a) == lpKey(b) { + t.Fatal("a power change 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") + } +} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go new file mode 100644 index 000000000..b970108a8 --- /dev/null +++ b/go/internal/cloudlink/sender.go @@ -0,0 +1,577 @@ +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 + lpKey string + lpAt time.Time + pricesKey string + pricesAt time.Time + anyAt time.Time // last live or hb + rosterSeq uint64 + rosterAt time.Time + watchUntil time.Time +} + +// 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.rosterSeq = 0 + } + case cloudwire.MsgPurged: + var b cloudwire.Purged + if !snap.g.Revoked || !l.verifiedByService(snap.home, env) || cloudwire.Unmarshal(env.B, &b) != nil || + b.What != cloudwire.PurgedHome || !bytes.Equal(b.GrantID, snap.g.ID) { + return nil + } + l.finishPurge(b.AtMs) + return errPurged + } + return nil +} + +// tick sends whatever is due. +func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { + l := w.link + + seq, roster := l.rosterNumber(snap) + l.mu.Lock() + due := l.rosterDue + l.rosterDue = false + l.mu.Unlock() + if due || seq != st.rosterSeq || now.Sub(st.rosterAt) >= rosterRenew { + if _, err := w.sendBody(snap, cloudwire.MsgRoster, &roster, true); err != nil { + return err + } + st.rosterSeq, st.rosterAt = seq, now + } + + period := periodIdle + if now.Before(st.watchUntil) { + period = periodWatched + } + full := st.full || now.Sub(st.lastFull) >= fullEvery + waiting := false + + var ages map[string]int64 + if snap.allowed(cloudwire.MsgLive) { + live, tableChanged := l.projectLive(snap, l.opts.Site.Live()) + ages = live.Ages + key := liveKey(live) + changed := tableChanged || key != st.liveKey + 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: + waiting = true + } + } + + if snap.allowed(cloudwire.MsgLP) { + if lp, ok := l.opts.Site.Loadpoints(); ok { + lp = l.projectLP(snap, lp) + key := lpKey(lp) + changed := key != st.lpKey + switch { + case full || (changed && now.Sub(st.lpAt) >= max(period, lpPeriodMin)): + sent, err := w.sendBody(snap, cloudwire.MsgLP, &lp, false) + if err != nil { + return err + } + if sent { + st.lpKey, st.lpAt = key, now + } + case changed: + waiting = 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. + l.mu.Lock() + h := time.Duration(cloudwire.Heartbeat(l.table)) * time.Millisecond + l.mu.Unlock() + if !waiting && now.Sub(st.anyAt) >= h { + 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}) +} + +func lpKey(lp cloudwire.LP) string { + c := cloudwire.LP{ControlRev: lp.ControlRev} + for _, p := range lp.Loadpoints { + p.SocAgeS = nil + 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 = nil + c.Feedback = append(c.Feedback, f) + } + return digest(c) +} + +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 +} From 8a030e2e480578270b30112968bbfa5e62a43720 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:37:40 +0200 Subject: [PATCH 4/7] feat(cloud): grant routes, roster and adapters for the FTW Cloud uplink - GET /api/cloud and /api/cloud/preview for an owner: the grant, the bytes sent in 24 hours, and the exact frames that left the house. - POST /api/cloud/grant from an owner's session, or on the LAN only from the box itself or with the house password. DELETE from any door. - Enrolment lists whole keys for the signed roster and says when who is paired, or as what, has changed. - The adapters read the live snapshot the app session sends, the loadpoint view GET /api/loadpoints composes and the price store. - Owners hear cloud.granted and cloud.revoked; these two rules are on by default, unlike the rest. - Backups skip cloud.key, so a restored box has FTW Cloud off. Co-Authored-By: Claude Opus 5.5 --- .changeset/ftw-cloud-uplink.md | 5 + go/cmd/ftw/cloud_link.go | 347 ++++++++++++++++++ go/cmd/ftw/cloud_link_test.go | 79 ++++ go/cmd/ftw/main.go | 8 + go/internal/api/api.go | 5 + go/internal/api/api_cloud.go | 179 +++++++++ go/internal/api/api_cloud_test.go | 176 +++++++++ .../api/api_notifications_push_test.go | 9 +- go/internal/appenroll/enroll.go | 58 ++- go/internal/appenroll/roles_test.go | 57 +++ go/internal/appproto/telemetry.go | 8 + go/internal/backup/archive.go | 7 + go/internal/backup/derived_files_test.go | 18 + go/internal/notifications/catalogue.go | 2 + go/internal/notifications/cloud.go | 36 ++ go/internal/notifications/cloud_test.go | 29 ++ go/internal/notifications/service.go | 7 + 17 files changed, 1024 insertions(+), 6 deletions(-) create mode 100644 .changeset/ftw-cloud-uplink.md create mode 100644 go/cmd/ftw/cloud_link.go create mode 100644 go/cmd/ftw/cloud_link_test.go create mode 100644 go/internal/api/api_cloud.go create mode 100644 go/internal/api/api_cloud_test.go create mode 100644 go/internal/notifications/cloud.go create mode 100644 go/internal/notifications/cloud_test.go 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/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 cffc17663..a3b748412 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(), @@ -2620,6 +2627,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 9466dbb73..ae023b7b8 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -106,6 +106,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 @@ -422,6 +426,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/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/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/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, } } From 9bf7d78d2ad2861aafd2da80299255f6c41cb888 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:58:50 +0200 Subject: [PATCH 5/7] fix(cloudlink): send lp on change of state, not on every reading A charging car moves its measured power every reading, so lp went at every period: about 12 MB a day on a simulated site, against an estimate of 5 MB. lp now counts power and actual watts as changed only past max(100 W, 5 %), session energy past 100 Wh and charge level past 1 %. A change of state still goes at once, the full lp every 15 minutes carries exact values, and live field 10 carries the charging power at every period. Also say which way a dev root CA file failed to load. Co-Authored-By: Claude Opus 5.5 --- go/internal/cloudlink/link.go | 7 ++-- go/internal/cloudlink/link_test.go | 19 +++++++--- go/internal/cloudlink/sender.go | 58 ++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 354645ed6..7f4f15823 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -187,8 +187,11 @@ func New(opts Options) (*Link, error) { pool = x509.NewCertPool() } pemBytes, err := os.ReadFile(DevRootCA) - if err != nil || !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("cloudlink: reading dev root CA %s: %v", DevRootCA, err) + 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 } diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 4f9efe740..d39ef1973 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -668,19 +668,28 @@ func TestBudgetRollsOver24HoursOfUptime(t *testing.T) { } } -func TestChangeTestsIgnoreAges(t *testing.T) { +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 lpKey(a) != lpKey(b) { + if lpChanged(a, b) { t.Fatal("lp differs only in ages and still counts as changed") } - b.Loadpoints[0].PowerW = 1 - if lpKey(a) == lpKey(b) { - t.Fatal("a power change does not count") + 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}}} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index b970108a8..15ba20d2e 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -74,7 +74,7 @@ type cadence struct { lastFull time.Time liveKey string liveAt time.Time - lpKey string + lpLast *cloudwire.LP lpAt time.Time pricesKey string pricesAt time.Time @@ -217,8 +217,7 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { if snap.allowed(cloudwire.MsgLP) { if lp, ok := l.opts.Site.Loadpoints(); ok { lp = l.projectLP(snap, lp) - key := lpKey(lp) - changed := key != st.lpKey + changed := st.lpLast == nil || lpChanged(*st.lpLast, lp) switch { case full || (changed && now.Sub(st.lpAt) >= max(period, lpPeriodMin)): sent, err := w.sendBody(snap, cloudwire.MsgLP, &lp, false) @@ -226,7 +225,7 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { return err } if sent { - st.lpKey, st.lpAt = key, now + st.lpLast, st.lpAt = &lp, now } case changed: waiting = true @@ -401,10 +400,12 @@ func tableKey(l cloudwire.Live) string { 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 = nil + p.SocAgeS, p.SocPermille, p.PowerW, p.SessionWh = nil, nil, 0, 0 if p.Manual != nil { m := *p.Manual m.SinceAgeMs = 0 @@ -413,12 +414,57 @@ func lpKey(lp cloudwire.LP) string { c.Loadpoints = append(c.Loadpoints, p) } for _, f := range lp.Feedback { - f.AgeMs = nil + 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}) } From a87386a8c384b29c861f5f45bbafe1b74c28156b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:10:11 +0200 Subject: [PATCH 6/7] perf(cloudlink): read the box no faster than a message can go The sender looked at live, loadpoints and the roster number on every 250 ms tick. On a Pi the loadpoint view composes control feedback, and the roster number is a SQLite read. Now live is read once a second (so a table change still goes within a second), loadpoints once a period, and the roster only when it changed, at connect and every six hours. An hb still reads fresh ages on the tick it goes. Co-Authored-By: Claude Opus 5.5 --- go/internal/cloudlink/link_test.go | 36 +++++++++++++++++++---- go/internal/cloudlink/sender.go | 46 +++++++++++++++++++----------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index d39ef1973..8a66da293 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -59,10 +59,12 @@ const ( ) type fakeSite struct { - mu sync.Mutex - grid int64 - state protocol.SourceState - lastOk int64 + mu sync.Mutex + grid int64 + state protocol.SourceState + lastOk int64 + liveCalls int + lpCalls int } func (s *fakeSite) setGrid(w int64) { @@ -74,6 +76,7 @@ func (s *fakeSite) setGrid(w int64) { 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}}, @@ -86,6 +89,9 @@ func (s *fakeSite) Live() Live { } 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, @@ -505,7 +511,8 @@ func TestATableChangeGoesAtOnceWithANewRevision(t *testing.T) { h.site.mu.Lock() h.site.state = protocol.SourceLagging h.site.mu.Unlock() - env := svc.waitFor(cloudwire.MsgLive, time.Second) + // 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 { @@ -697,3 +704,22 @@ func TestChangeTestsIgnoreAgesAndNoise(t *testing.T) { 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 index 15ba20d2e..d42df2a30 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -79,11 +79,21 @@ type cadence struct { pricesKey string pricesAt time.Time anyAt time.Time // last live or hb - rosterSeq uint64 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 @@ -154,7 +164,7 @@ func (w *writer) downlink(snap grantSnapshot, st *cadence, env cloudwire.Envelop st.full = true } if slices.Contains(b.What, cloudwire.ResyncRoster) { - st.rosterSeq = 0 + st.rosterAt = time.Time{} } case cloudwire.MsgPurged: var b cloudwire.Purged @@ -172,31 +182,35 @@ func (w *writer) downlink(snap grantSnapshot, st *cadence, env cloudwire.Envelop func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { l := w.link - seq, roster := l.rosterNumber(snap) l.mu.Lock() due := l.rosterDue l.rosterDue = false + h := time.Duration(cloudwire.Heartbeat(l.table)) * time.Millisecond l.mu.Unlock() - if due || seq != st.rosterSeq || now.Sub(st.rosterAt) >= rosterRenew { + 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.rosterSeq, st.rosterAt = seq, now + st.rosterAt = now } period := periodIdle if now.Before(st.watchUntil) { period = periodWatched } + lpPeriod := max(period, lpPeriodMin) full := st.full || now.Sub(st.lastFull) >= fullEvery - waiting := false + hbDue := now.Sub(st.anyAt) >= h var ages map[string]int64 - if snap.allowed(cloudwire.MsgLive) { + 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 { @@ -210,16 +224,18 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { st.liveKey, st.liveAt, st.anyAt = key, now, now } case changed: - waiting = true + st.liveWaiting = true } } - if snap.allowed(cloudwire.MsgLP) { + 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) >= max(period, lpPeriodMin)): + case full || (changed && now.Sub(st.lpAt) >= lpPeriod): sent, err := w.sendBody(snap, cloudwire.MsgLP, &lp, false) if err != nil { return err @@ -228,7 +244,7 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { st.lpLast, st.lpAt = &lp, now } case changed: - waiting = true + st.lpWaiting = true } } } @@ -251,11 +267,9 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { } } - // hb never vouches for a live or lp that waits for its period. - l.mu.Lock() - h := time.Duration(cloudwire.Heartbeat(l.table)) * time.Millisecond - l.mu.Unlock() - if !waiting && now.Sub(st.anyAt) >= h { + // 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 { From 069890838def6b6ea03dd4f816ae71ecc919928a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:44:01 +0200 Subject: [PATCH 7/7] fix(cloudlink): take the purge receipt before the service hangs up The service closes the uplink right after it sends purged. The receipt went to the sender through a channel, and the reader could see the close first and end the connection with the receipt unread; the box then had to ask again. The reader now checks the receipt itself and ends the connection only after the key and the grant are gone. Co-Authored-By: Claude Opus 5.5 --- go/internal/cloudlink/conn.go | 14 ++++++++++---- go/internal/cloudlink/sender.go | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 44a6105a0..0d5bf20fe 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -163,7 +163,7 @@ func (l *Link) serve(ctx context.Context, conn *websocket.Conn, snap grantSnapsh events := make(chan cloudwire.Envelope, 4) readErr := make(chan error, 1) go func() { - readErr <- l.read(conn, events) + readErr <- l.read(conn, snap, events) }() sendErr := make(chan error, 1) go func() { @@ -244,8 +244,10 @@ func parseDownlink(kind int, msg []byte) (cloudwire.Envelope, error) { // 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. Each is handed to the sender, which owns what it changes. -func (l *Link) read(conn *websocket.Conn, events chan<- cloudwire.Envelope) error { +// 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)) @@ -258,7 +260,11 @@ func (l *Link) read(conn *websocket.Conn, events chan<- cloudwire.Envelope) erro } _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) switch env.T { - case cloudwire.MsgWatch, cloudwire.MsgResync, cloudwire.MsgPurged: + 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) diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index d42df2a30..04d528a70 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -166,18 +166,23 @@ func (w *writer) downlink(snap grantSnapshot, st *cadence, env cloudwire.Envelop if slices.Contains(b.What, cloudwire.ResyncRoster) { st.rosterAt = time.Time{} } - case cloudwire.MsgPurged: - var b cloudwire.Purged - if !snap.g.Revoked || !l.verifiedByService(snap.home, env) || cloudwire.Unmarshal(env.B, &b) != nil || - b.What != cloudwire.PurgedHome || !bytes.Equal(b.GrantID, snap.g.ID) { - return nil - } - l.finishPurge(b.AtMs) - return errPurged } 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