From 44e6438bd0b1e1d4d01f43a1d44668895200996d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:19:30 +0200 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 From cc6b36cb445fd50c814751176219d1f7092f3847 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:18:48 +0200 Subject: [PATCH 08/15] feat(protocol): FTW Cloud app session wire and a Noise initiator The phone reads its home from FTW Cloud over Noise IK with the service as responder. cloudwire gains the prologue, the now, watch, suspend and snapshot bodies, the close codes, and golden vectors for one whole session. appwire gains the initiator half of IK, checked against the same Cacophony vector as the responder, for the service's tests and any Go client. Co-Authored-By: Claude Opus 5.5 --- go/protocol/appwire/initiator.go | 167 ++++++++++++ go/protocol/appwire/initiator_test.go | 114 ++++++++ go/protocol/cloudwire/app.go | 153 +++++++++++ go/protocol/cloudwire/app_vectors_test.go | 254 ++++++++++++++++++ .../cloudwire/testdata/cloud-app-vectors.json | 53 ++++ 5 files changed, 741 insertions(+) create mode 100644 go/protocol/appwire/initiator.go create mode 100644 go/protocol/appwire/initiator_test.go create mode 100644 go/protocol/cloudwire/app.go create mode 100644 go/protocol/cloudwire/app_vectors_test.go create mode 100644 go/protocol/cloudwire/testdata/cloud-app-vectors.json diff --git a/go/protocol/appwire/initiator.go b/go/protocol/appwire/initiator.go new file mode 100644 index 000000000..5da59e49b --- /dev/null +++ b/go/protocol/appwire/initiator.go @@ -0,0 +1,167 @@ +package appwire + +import ( + "fmt" + "sync" +) + +// Noise_IK_25519_ChaChaPoly_SHA256, initiator half. +// +// The box is always a responder, so Core never runs this. It exists for the +// other side of FTW Cloud's app session, where the service is the responder +// and a phone the initiator: the service's tests and any Go client speak to +// it with this. Verified against the same Cacophony vector as the responder. + +// InitiatorOptions configures the initiator. +type InitiatorOptions struct { + // StaticKey is the initiator's long-term key. + StaticKey KeyPair + // RemoteStatic is the responder's static key, known before the first + // byte: IK's pre-message. + RemoteStatic []byte + // Prologue is mixed in before anything else. Both ends must agree on it + // byte for byte. + Prologue []byte + // Ephemeral fixes the ephemeral key. Test vectors only. + Ephemeral *KeyPair +} + +type initStep int + +const ( + initWrite1 initStep = iota + initRead2 + initDone + initSplit +) + +// InitiatorState runs the initiator side of IK. Drive it with WriteMessage, +// then ReadMessage, then Split. +type InitiatorState struct { + mu sync.Mutex + sym *symmetricState + s KeyPair + e KeyPair + rs []byte + step initStep +} + +// NewInitiator starts a handshake toward a responder whose static key is +// already known. +func NewInitiator(opts InitiatorOptions) (*InitiatorState, error) { + if opts.StaticKey.priv == nil { + return nil, fmt.Errorf("%w: initiator needs a static key", ErrNoiseKey) + } + if len(opts.RemoteStatic) != DHBytes { + return nil, fmt.Errorf("%w: responder key is %d bytes, need %d", ErrNoiseKey, len(opts.RemoteStatic), DHBytes) + } + h := &InitiatorState{sym: newSymmetricState(), s: opts.StaticKey, rs: append([]byte{}, opts.RemoteStatic...)} + if opts.Ephemeral != nil { + h.e = *opts.Ephemeral + } + h.sym.mixHash(opts.Prologue) + h.sym.mixHash(h.rs) + return h, nil +} + +// WriteMessage produces `-> e, es, s, ss` carrying payload. +func (h *InitiatorState) WriteMessage(payload []byte) ([]byte, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step != initWrite1 { + return nil, fmt.Errorf("%w: cannot write now", ErrNoiseState) + } + if h.e.priv == nil { + e, err := GenerateKeyPair() + if err != nil { + return nil, err + } + h.e = e + } + out := h.e.Public() + h.sym.mixHash(out) + + es, err := h.e.dh(h.rs) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(es); err != nil { + return nil, err + } + encStatic, err := h.sym.encryptAndHash(h.s.Public()) + if err != nil { + return nil, err + } + ss, err := h.s.dh(h.rs) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(ss); err != nil { + return nil, err + } + encPayload, err := h.sym.encryptAndHash(payload) + if err != nil { + return nil, err + } + h.step = initRead2 + out = append(out, encStatic...) + return append(out, encPayload...), nil +} + +// ReadMessage consumes `<- e, ee, se` and returns its payload. +func (h *InitiatorState) ReadMessage(message []byte) ([]byte, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step != initRead2 { + return nil, fmt.Errorf("%w: cannot read now", ErrNoiseState) + } + if len(message) < DHBytes+TagBytes { + return nil, fmt.Errorf("%w: handshake message 2 is %d bytes", ErrNoiseMessage, len(message)) + } + re := append([]byte{}, message[:DHBytes]...) + h.sym.mixHash(re) + ee, err := h.e.dh(re) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(ee); err != nil { + return nil, err + } + se, err := h.s.dh(re) + if err != nil { + return nil, err + } + if err := h.sym.mixKey(se); err != nil { + return nil, err + } + payload, err := h.sym.decryptAndHash(message[DHBytes:]) + if err != nil { + return nil, err + } + h.step = initDone + return payload, nil +} + +// Split ends the handshake and hands over the transport keys. Like the +// responder's, it works once. +func (h *InitiatorState) Split() (*Session, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.step == initSplit { + return nil, fmt.Errorf("%w: handshake already split", ErrNoiseState) + } + if h.step != initDone { + return nil, fmt.Errorf("%w: handshake is not finished", ErrNoiseState) + } + keys, err := h.sym.split() + if err != nil { + return nil, err + } + h.step = initSplit + return &Session{ + Send: keys[0], + Recv: keys[1], + HandshakeHash: append([]byte{}, h.sym.h...), + RemoteStatic: append([]byte{}, h.rs...), + }, nil +} diff --git a/go/protocol/appwire/initiator_test.go b/go/protocol/appwire/initiator_test.go new file mode 100644 index 000000000..7ce9de4b1 --- /dev/null +++ b/go/protocol/appwire/initiator_test.go @@ -0,0 +1,114 @@ +package appwire + +import ( + "bytes" + "encoding/hex" + "errors" + "testing" +) + +// The vector's initiator ephemeral, from the same Cacophony entry. The +// responder test does not need it; the initiator cannot run without it. +const cacophonyInitEphemeral = "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a" + +func TestCacophonyVectorAsInitiator(t *testing.T) { + eph := keyFromHex(t, cacophonyInitEphemeral) + initiator, err := NewInitiator(InitiatorOptions{ + StaticKey: keyFromHex(t, cacophony.initStatic), + RemoteStatic: unhex(t, cacophony.initRemoteStatc), + Prologue: unhex(t, cacophony.prologue), + Ephemeral: &eph, + }) + if err != nil { + t.Fatalf("NewInitiator: %v", err) + } + message1, err := initiator.WriteMessage(unhex(t, cacophony.messages[0].payload)) + if err != nil { + t.Fatalf("WriteMessage: %v", err) + } + if got := hex.EncodeToString(message1); got != cacophony.messages[0].ciphertext { + t.Fatalf("message 1 = %s, want %s", got, cacophony.messages[0].ciphertext) + } + payload, err := initiator.ReadMessage(unhex(t, cacophony.messages[1].ciphertext)) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + if got := hex.EncodeToString(payload); got != cacophony.messages[1].payload { + t.Fatalf("message 2 payload = %s, want %s", got, cacophony.messages[1].payload) + } + session, err := initiator.Split() + if err != nil { + t.Fatalf("Split: %v", err) + } + if got := hex.EncodeToString(session.HandshakeHash); got != cacophony.handshakeHash { + t.Fatalf("handshake hash = %s, want %s", got, cacophony.handshakeHash) + } + for i, msg := range cacophony.messages[2:] { + if i%2 == 0 { + ciphertext, err := session.Send.EncryptWithAD(nil, unhex(t, msg.payload)) + if err != nil || hex.EncodeToString(ciphertext) != msg.ciphertext { + t.Fatalf("transport %d: %x %v", i, ciphertext, err) + } + continue + } + plaintext, err := session.Recv.DecryptWithAD(nil, unhex(t, msg.ciphertext)) + if err != nil || hex.EncodeToString(plaintext) != msg.payload { + t.Fatalf("transport %d: %x %v", i, plaintext, err) + } + } + if _, err := initiator.Split(); !errors.Is(err, ErrNoiseState) { + t.Fatalf("second Split: %v", err) + } +} + +// An initiator and the responder agree with each other, and a transport +// built on each side carries frames both ways. +func TestInitiatorAndResponderMeet(t *testing.T) { + phone, _ := GenerateKeyPair() + service, _ := GenerateKeyPair() + prologue := []byte("ftw.cloud.v1:test") + init, err := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: service.Public(), Prologue: prologue}) + if err != nil { + t.Fatal(err) + } + resp, err := NewResponder(HandshakeOptions{StaticKey: service, Prologue: prologue}) + if err != nil { + t.Fatal(err) + } + m1, _ := init.WriteMessage([]byte("now")) + p1, err := resp.ReadMessage(m1) + if err != nil || string(p1) != "now" { + t.Fatalf("message 1: %q %v", p1, err) + } + m2, _ := resp.WriteMessage([]byte("snapshot")) + p2, err := init.ReadMessage(m2) + if err != nil || string(p2) != "snapshot" { + t.Fatalf("message 2: %q %v", p2, err) + } + is, _ := init.Split() + rs, _ := resp.Split() + if !bytes.Equal(rs.RemoteStatic, phone.Public()) || !bytes.Equal(is.HandshakeHash, rs.HandshakeHash) { + t.Fatal("the two ends disagree about the session") + } + a, b := NewTransport(is), NewTransport(rs) + msg, _ := b.Encrypt([]byte("frame")) + got, err := a.Decrypt(msg) + if err != nil || string(got) != "frame" { + t.Fatalf("transport: %q %v", got, err) + } +} + +func TestInitiatorRefusesAnotherResponder(t *testing.T) { + phone, _ := GenerateKeyPair() + service, _ := GenerateKeyPair() + impostor, _ := GenerateKeyPair() + init, _ := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: service.Public()}) + resp, _ := NewResponder(HandshakeOptions{StaticKey: impostor}) + m1, _ := init.WriteMessage(nil) + if _, err := resp.ReadMessage(m1); !errors.Is(err, ErrNoiseAuth) { + t.Fatalf("an impostor read message 1: %v", err) + } + if _, err := NewInitiator(InitiatorOptions{StaticKey: phone, RemoteStatic: []byte{1, 2}}); !errors.Is(err, ErrNoiseKey) { + t.Fatalf("short responder key: %v", err) + } +} diff --git a/go/protocol/cloudwire/app.go b/go/protocol/cloudwire/app.go new file mode 100644 index 000000000..d7005687c --- /dev/null +++ b/go/protocol/cloudwire/app.go @@ -0,0 +1,153 @@ +package cloudwire + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// The app session: a phone reads its home from FTW Cloud. +// +// The phone opens wss://cloud.ftw.energy/v1/app?home= and runs +// Noise_IK_25519_ChaChaPoly_SHA256 as initiator, with the service's Noise key +// as the responder's static key and AppPrologue(home) as the prologue. The +// query only says which home to look in: a wrong one fails the handshake, +// because the prologue binds it. Message 1 carries a now envelope, message 2 +// the snapshot, and after that each box envelope the device may read rides, +// unchanged, in an unpadded lane 1 frame inside a Noise transport message, +// exactly as appwire.Transport writes one. +// +// Every envelope between phone and service has an empty sig. Only box +// envelopes carry signatures, and the phone checks them with the box key it +// learned from the box over its own Noise session, never from the service. + +// AppPrologueTag starts the prologue; the home id follows it. +const AppPrologueTag = "ftw.cloud.v1:" + +// AppHomeParam is the query parameter that names the home. +const AppHomeParam = "home" + +// Phone to service. The service acts on these and ignores every other type. +const ( + // MsgNow is message 1's payload. Watch semantics follow. + MsgNow = "now" + // MsgSuspend and MsgUnsuspend are an owner's; they only narrow access. + MsgSuspend = "suspend" + MsgUnsuspend = "unsuspend" + // MsgWatch (shared with the downlink) changes whether this phone is + // looking: hz 1 while it can see the home, 0 when it cannot. +) + +// Service to phone. +const ( + // MsgSnapshot is message 2's payload. + MsgSnapshot = "snapshot" +) + +// Close codes on /v1/app. A phone reads them to decide whether to retry. +const ( + // CloseProtocol: the phone sent something this protocol does not allow. + CloseProtocol = 4400 + // CloseDenied is every refusal: no such home, a device not in the roster, + // a suspended device, an expired roster or a grant that is off. It does + // not say which. + CloseDenied = 4403 + // CloseReplaced: a newer session from the same device took over. + CloseReplaced = 4408 + // CloseChanged: the roster changed what this device may read. A phone + // reconnects at once to learn its new access. + CloseChanged = 4409 + // CloseBusy: the home has as many sessions open as the service allows. + // The relay uses the same number for the same thing. + CloseBusy = 4429 +) + +// Hz values for now and watch. +const ( + HzLooking = 1 + HzAway = 0 +) + +// Now is message 1: whether the phone is looking. +type Now struct { + Hz int `cbor:"hz"` +} + +// Suspend names the roster device an owner suspends or lifts a suspension +// from. +type Suspend struct { + DeviceID string `cbor:"deviceId"` +} + +// Snapshot is message 2: for each class the device may read, the newest +// envelope the service holds, the newest live that carried sources and the +// newest hb, in rising seq, as the box sent them. LastSeenMs and Online are +// the service's own word, unsigned, and never shown as a reading. +// +// Envelopes that do not fit in one Noise message follow message 2 as +// ordinary frames, so More counts them. +type Snapshot struct { + Envelopes []cbor.RawMessage `cbor:"envelopes"` + LastSeenMs int64 `cbor:"lastSeenMs"` + Online bool `cbor:"online"` + More int `cbor:"more"` +} + +// AppPrologue is the Noise prologue for one home's app session. +func AppPrologue(home string) []byte { + return append([]byte(AppPrologueTag), home...) +} + +// ValidHome reports whether s has a home id's shape: 26 characters of +// lowercase base32. +func ValidHome(s string) bool { + if len(s) != 26 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { + return false + } + } + return true +} + +// Unsigned wraps a body the phone or service says itself: an envelope with an +// empty sig. +func Unsigned(t string, body any) ([]byte, error) { + b, err := Marshal(body) + if err != nil { + return nil, err + } + return Envelope{T: t, B: b}.Encode() +} + +// SnapshotPayload is message 2's payload around envelopes already encoded. +// They are carried as they are: the service never re-encodes a box message. +func SnapshotPayload(envelopes [][]byte, lastSeenMs int64, online bool, more int) ([]byte, error) { + raw := make([]cbor.RawMessage, len(envelopes)) + for i, e := range envelopes { + if _, err := DecodeEnvelope(e); err != nil { + return nil, fmt.Errorf("cloudwire: snapshot entry %d: %w", i, err) + } + raw[i] = e + } + return Unsigned(MsgSnapshot, Snapshot{Envelopes: raw, LastSeenMs: lastSeenMs, Online: online, More: more}) +} + +// ParseSnapshot reads message 2. Each entry comes back as its exact bytes. +func ParseSnapshot(payload []byte) (Snapshot, error) { + env, err := DecodeEnvelope(payload) + if err != nil { + return Snapshot{}, err + } + if env.T != MsgSnapshot { + return Snapshot{}, fmt.Errorf("%w: message 2 is %q, not a snapshot", ErrEnvelope, env.T) + } + var s Snapshot + if err := Unmarshal(env.B, &s); err != nil { + return Snapshot{}, err + } + return s, nil +} diff --git a/go/protocol/cloudwire/app_vectors_test.go b/go/protocol/cloudwire/app_vectors_test.go new file mode 100644 index 000000000..d5749cfc8 --- /dev/null +++ b/go/protocol/cloudwire/app_vectors_test.go @@ -0,0 +1,254 @@ +package cloudwire + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// testdata/cloud-app-vectors.json freezes one app session byte for byte: +// fixed static and ephemeral keys on both ends, message 1 with now, message 2 +// with a snapshot of the signed vectors in cloud-vectors.json, and transport +// messages both ways. srcfl/ftw-cloud answers it as the service; the phone +// replays it as the initiator. + +const appVectorsPath = "testdata/cloud-app-vectors.json" + +type appVectorFile struct { + ServiceStatic string `json:"serviceStatic"` + ServicePublic string `json:"servicePublic"` + ServiceEphemeral string `json:"serviceEphemeral"` + PhoneStatic string `json:"phoneStatic"` + PhonePublic string `json:"phonePublic"` + PhoneEphemeral string `json:"phoneEphemeral"` + Home string `json:"home"` + Prologue string `json:"prologue"` + Msg1Payload string `json:"msg1Payload"` + Msg1 string `json:"msg1"` + Msg2Payload string `json:"msg2Payload"` + Msg2 string `json:"msg2"` + HandshakeHash string `json:"handshakeHash"` + Snapshot []string `json:"snapshot"` + Transport []appVectorFrame `json:"transport"` + Notes map[string]string `json:"notes"` +} + +type appVectorFrame struct { + From string `json:"from"` + Name string `json:"name"` + Frame string `json:"frame"` + Message string `json:"message"` +} + +func fixedX25519(t *testing.T, label string) appwire.KeyPair { + t.Helper() + sum := sha256.Sum256([]byte(label)) + k, err := appwire.KeyPairFromSecret(sum[:]) + if err != nil { + t.Fatal(err) + } + return k +} + +func secretHex(label string) string { + sum := sha256.Sum256([]byte(label)) + return hex.EncodeToString(sum[:]) +} + +func TestAppSessionVectors(t *testing.T) { + raw, err := os.ReadFile(vectorsPath) + if err != nil { + t.Fatal(err) + } + var signed vectorFile + if err := json.Unmarshal(raw, &signed); err != nil { + t.Fatal(err) + } + envelope := func(name string) []byte { + for _, m := range signed.Messages { + if m.Name == name { + b, _ := hex.DecodeString(m.Envelope) + return b + } + } + t.Fatalf("no signed vector %q", name) + return nil + } + frameOf := func(env []byte) []byte { + f, err := EncodeFrame(env) + if err != nil { + t.Fatal(err) + } + return f + } + + labels := map[string]string{ + "serviceStatic": "ftw.cloud app vector service static", + "serviceEphemeral": "ftw.cloud app vector service ephemeral", + "phoneStatic": "ftw.cloud app vector phone static", + "phoneEphemeral": "ftw.cloud app vector phone ephemeral", + } + serviceStatic := fixedX25519(t, labels["serviceStatic"]) + serviceEph := fixedX25519(t, labels["serviceEphemeral"]) + phoneStatic := fixedX25519(t, labels["phoneStatic"]) + phoneEph := fixedX25519(t, labels["phoneEphemeral"]) + home := signed.BoxKey.Home + prologue := AppPrologue(home) + + msg1Payload, err := Unsigned(MsgNow, Now{Hz: HzLooking}) + if err != nil { + t.Fatal(err) + } + // The snapshot: newest live with sources, newest live, lp, prices and hb, + // in rising seq. + names := []string{"live full", "live delta", "lp", "prices", "hb"} + var envs [][]byte + for _, n := range names { + envs = append(envs, envelope(n)) + } + msg2Payload, err := SnapshotPayload(envs, 1_791_820_869_000, true, 0) + if err != nil { + t.Fatal(err) + } + + init, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: phoneStatic, RemoteStatic: serviceStatic.Public(), Prologue: prologue, Ephemeral: &phoneEph, + }) + if err != nil { + t.Fatal(err) + } + resp, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: serviceStatic, Prologue: prologue, Ephemeral: &serviceEph}) + if err != nil { + t.Fatal(err) + } + msg1, err := init.WriteMessage(msg1Payload) + if err != nil { + t.Fatal(err) + } + got1, err := resp.ReadMessage(msg1) + if err != nil || !bytes.Equal(got1, msg1Payload) { + t.Fatalf("message 1: %v", err) + } + msg2, err := resp.WriteMessage(msg2Payload) + if err != nil { + t.Fatal(err) + } + got2, err := init.ReadMessage(msg2) + if err != nil || !bytes.Equal(got2, msg2Payload) { + t.Fatalf("message 2: %v", err) + } + snap, err := ParseSnapshot(got2) + if err != nil || len(snap.Envelopes) != len(envs) || !snap.Online { + t.Fatalf("snapshot: %+v %v", snap, err) + } + for i := range envs { + if !bytes.Equal(snap.Envelopes[i], envs[i]) { + t.Fatalf("snapshot entry %d is not the box's bytes", i) + } + } + is, _ := init.Split() + rs, _ := resp.Split() + phone, service := appwire.NewTransport(is), appwire.NewTransport(rs) + + out := appVectorFile{ + ServiceStatic: secretHex(labels["serviceStatic"]), ServicePublic: hex.EncodeToString(serviceStatic.Public()), + ServiceEphemeral: secretHex(labels["serviceEphemeral"]), + PhoneStatic: secretHex(labels["phoneStatic"]), PhonePublic: hex.EncodeToString(phoneStatic.Public()), + PhoneEphemeral: secretHex(labels["phoneEphemeral"]), + Home: home, Prologue: hex.EncodeToString(prologue), + Msg1Payload: hex.EncodeToString(msg1Payload), Msg1: hex.EncodeToString(msg1), + Msg2Payload: hex.EncodeToString(msg2Payload), Msg2: hex.EncodeToString(msg2), + HandshakeHash: hex.EncodeToString(rs.HandshakeHash), + Notes: map[string]string{ + "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", + "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", + "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0.", + }, + } + out.Snapshot = names + + addFrame := func(from, name string, frame []byte) { + var msg []byte + var err error + if from == "service" { + msg, err = service.Encrypt(frame) + if err == nil { + var back []byte + if back, err = phone.Decrypt(msg); err == nil && !bytes.Equal(back, frame) { + t.Fatalf("%s did not round-trip", name) + } + } + } else { + msg, err = phone.Encrypt(frame) + if err == nil { + var back []byte + if back, err = service.Decrypt(msg); err == nil && !bytes.Equal(back, frame) { + t.Fatalf("%s did not round-trip", name) + } + } + } + if err != nil { + t.Fatalf("%s: %v", name, err) + } + out.Transport = append(out.Transport, appVectorFrame{From: from, Name: name, Frame: hex.EncodeToString(frame), Message: hex.EncodeToString(msg)}) + } + addFrame("service", "hb", frameOf(envelope("hb"))) + addFrame("service", "lp", frameOf(envelope("lp"))) + away, _ := Unsigned(MsgWatch, Watch{Hz: HzAway}) + addFrame("phone", "watch away", frameOf(away)) + suspend, _ := Unsigned(MsgSuspend, Suspend{DeviceID: "Zf09xQ2a"}) + addFrame("phone", "suspend", frameOf(suspend)) + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(appVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(appVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the wire change is intended, "+ + "regenerate with -update and pair the change with srcfl/ftw-cloud and the phone", appVectorsPath) + } +} + +func TestAppPrologueAndHome(t *testing.T) { + if got := string(AppPrologue("fjzyr67buzpyncqmjliwgezvde")); got != "ftw.cloud.v1:fjzyr67buzpyncqmjliwgezvde" { + t.Fatalf("prologue %q", got) + } + for s, ok := range map[string]bool{ + "fjzyr67buzpyncqmjliwgezvde": true, + "fjzyr67buzpyncqmjliwgezvd": false, + "fjzyr67buzpyncqmjliwgezvdee": false, + "Fjzyr67buzpyncqmjliwgezvde": false, + "fjzyr67buzpyncqmjliwgezvd1": false, + "fjzyr67buzpyncqmjliwgezvd/": false, + } { + if ValidHome(s) != ok { + t.Fatalf("ValidHome(%q) = %v", s, !ok) + } + } +} + +func TestSnapshotRefusesWhatIsNotAnEnvelope(t *testing.T) { + if _, err := SnapshotPayload([][]byte{{0xa0}}, 0, false, 0); err == nil { + t.Fatal("a snapshot took an empty map as an envelope") + } + now, _ := Unsigned(MsgNow, Now{Hz: 1}) + if _, err := ParseSnapshot(now); err == nil { + t.Fatal("a now envelope read as a snapshot") + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-app-vectors.json b/go/protocol/cloudwire/testdata/cloud-app-vectors.json new file mode 100644 index 000000000..b49c1b364 --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-app-vectors.json @@ -0,0 +1,53 @@ +{ + "serviceStatic": "02d86370739892ce589fb0dac0416128cb716a667f6b3baf2979ed03a39be145", + "servicePublic": "4bcf8ca8663797d826934ad341de6e223114105a1f513b78da30ef3f32cd8557", + "serviceEphemeral": "f4e90c05a93a8b3e2e3741a3c3df44681e9829b95332b4a406e3325d3cce6bd3", + "phoneStatic": "ea39659c38ef7cf936ca64fc18edccb9889785bf85e202593bd192680b425f37", + "phonePublic": "f7d081a5e8884ade9cf55aeb6cf767afabf70d3c0b195a013651055908128f08", + "phoneEphemeral": "df36c6700bc874c62773997c6e95704050bac93a3b649e0883a7f5791a358ebc", + "home": "fjzyr67buzpyncqmjliwgezvde", + "prologue": "6674772e636c6f75642e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465", + "msg1Payload": "a3616245a162687a016174636e6f776373696740", + "msg1": "38869cb4f47eb17ca6a251b657a79943bca4b3a22813caac1578296e837a0273dd35e67fe141738f375ec80128d8e3c18dae31dcc5fe42a0273b9cbe300164e9e903d823a7c1bab23d562d1bf2879379e0a974150000f0b93b6e0c0fe2d0f58b1d814d5427606b9ab58d548696d231eab9369a86", + "msg2Payload": "a36162590771a4646d6f726500666f6e6c696e65f569656e76656c6f70657385a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd86a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740", + "msg2": "a0719d669e99f8ba82522bf6f2e6614d99bccb5616ae3e2af7e4635267531c148d96093e3a658c9843d6ba1321e2fc10ea6f9dc9a4ff98256ab2edf4dd8f9d8d29fc50ed8ce63ab3d409e5740f425a84b8331523b6aebb7bc37080cf41080144beee9311a8089e341d3fb4886247e9114f2990333e5e29e7f3d91f338d0fdb89bb0ff5424c533b8a6cb800dd263eb08c5995540efdad9b729ff8d63e1c15df4cfe7079a8354fee4141d7ac43a0d44cc6b820444920c760983413caefbd1a31037003228381c291226f4cead6db97fe6023e8c0c1a503e8467c6726d1a045b456971a2ba19df457439481c218b95a5a94628ca3a813ee741501e475b6039f5614ab8a5e71ffe8377d3e06fc253f106a4133709fbfdccb6c6ca754ba375e12c924cb5c52351cf78ee6645f007a439ef55774f69b8e458d93cec2a1b369759ba67772b8c4f45737a82aef39f434c956b34a8a1978e261999904b80d39cd619cda27cbc983b45385b8c2569677f76d888439e7dae4df77a4e1d05ee11526f1a6276de5a53266d13fcc620e77594115f90d1b9e802ee942c5705aecb8bcd7ca2d410474dcd89d368f5411645a69bf364e67abc04e7638028117f577c016d008ba9eea84215a921b67dab3e16ec3c11b716574c332a5047e824b0232b97a1bdd210056b4a936cf70221aef0e40fd7ae1341abf7ab9b09a4424203c2f821ca2e9360b2f2130b28fa7d28cf7ac89d7013dd086f4f7aa88f3667cd25d279b0697224504f05bb5dd15098759a86cf046521fddf4010323fa2bfd88bd58b3e6babaf32d77a607b220aa613b4a0dacc1141556d8aae189d218f9f402e57b3ededb3f8b46d5d18267566fa0c309ef988333c416218579247abd47634d6631a4c6e81ecb0c5946370b1686b08509ff35aee5a6b4aed4ff56bb1c4fd7742377f8da760867a8c8e482abd062326085f62b2343957c6826e15d8a5025a584ca3b1eb31d2f48c5c0234ec9f63b68d589d4b7ad4727bb5141e2e2d3a3a7cfbe15eae129a6cdaaab7df28eb05d8ce4cf5d65761c4f5132dc717cae2c7045860224743780aca762c3e77fec18db5c7bb73bfd66f90df934afaf0b6f4f7d6102bae17b083e47a4aa63db614aa0441c0fd45193d7bc02194d9d93241f6ce83feda0f6f16d79cd13a3aadfb1fad0e8db8a40a1434f85634e923759363a71b37579f28c3795fba377a59c719937b56d44aed8bad1fd3a7be959b80ae0e12fcf1d8f2a016a64663a9f197eb35c1d1948dea05b88ff7cf25aa794a98e01383ab4dd5e4de1aceaa0dae29b90c776b4b8c97ef95201f1b466b5a19841a427a4b74123341f763770661c4fb8b01837a785c86f065d258801fac607d886d0775b32c8856e47f05be7c0f672c886c218371104c237c95c9e149bf2a5ab68882737919351429a0982da7304bb3d2643a11f12105ac393a5167fdf30a163b175c3bfe2aea7cb89cf13a60fac27fc447e98c530726d5ed4ec0d2355488a5c0ee8b265c95a3ec9381a2e1f156740bfe1346143e227d4ed6a78adbd1447f9e2690fb878f3c7bdbaefa366edbdb4c2b99c4735847beaaeada651140497d4dc82133797dd1f3333579b93aeef3a485dc3a658a20d22584b54bd5efac1d1542e37212465b51c0ccebb55d81f68729e77db8ea5eb001abd46fd4ea13d2e67114be82976a748368948bd9f3bb44784830eb8723d37e1bb12785fdcd2b02ce60d7ad655000d5abbdbbae9db3f215308e56f378b736cd101236221dbfb5c00a15c97161b53c144de4fdfac0f1e349536a37065259b86630b2f7351568878832c2a8c8c5669f70d8430b6a22c80c898da82ccd6e0b0cb902eda4b5294981a3d037cdf4da8ea31e511fdd142546e4c01e1c0154d1c098f5b36dee0d4732ad19032e9416eed93addea024bc422e5dad3783af218534b255d5dbde9202d0931230b7c6678c66165e05f0e98099b9ebe45ae7d83a1e0f4bc15845cbe4012a807632819d8d5762286c2868056b0670c26b7faa765047b42f89d7fe6e012a262c354a485419b0fb236a4332130707728e25b323f76dfcd6d89fcff4ffa274f988bcd09207f0e24f12f69a83f448b814c5eb0d7d87f993593f27ea3189a61f111d89c9d9e6668abd495253340741b31b34234e3d89106cda2a4dd06e3bc96ad3ed9dede19f1d387b1c7e094733b9e85571d9393fa23efdef0e64af8a53d4ab314b0c0f2a0f15d16bb2db9f8e57816b10d0e8a3d3406a08a68d24d0fdb1589ef951c7e98f77f9ad558da0a9a927ad9d85779bec3376f51188d12accf7fe20a019861a3c8bf0cf5135a093a7ab2cb7b2db0ca548b5e677a3bf8e0add303e3567d71826c4fd38a65a95aeeb8c166846090ee9a173fe0a753041f9df779f693ae686b75fe9854f76d0405bd2e4e5d98f3d0ee74747229c73f2964e72861a4b8df60fe638e8c450b80de929b1d612afc9c8fe537ffad8eed8f176c4d53cd1149e9566da2ff321905ea640938df4f972cb887c66c1a7828da36a014b09f111c1464ffe85f74d6e50a8428c4384579d0180ecd00209012545755e9b1c584817446d5a930e775460bb6edb1f19f6185e63685debd622b293657d3a560ef47c53aff422488f8cb09013290f08433e0f8284fa0e5105f84a2aa2882e75b7b846f19bf6dc89894a3fd773f1804f8cfa5e8a134ba15003472bacb2e5ccae391e8f08ae52a05ee1d4c2f850f94f096087bb4d26c5a6dacde4189ce9b76aeb738021092857b36c84037356d7f3431ac041c41072c57377ea", + "handshakeHash": "572f4062a19964d6417d27e070137f6e012ac705c073841669685158a36a0de7", + "snapshot": [ + "live full", + "live delta", + "lp", + "prices", + "hb" + ], + "transport": [ + { + "from": "service", + "name": "hb", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "message": "00000000000000009bc932e703b7e50790aabe40832bc82ce30862451e6dbe237412298bdaec184f5bce9309f5e4823f6dc191ab5ae312c7308284bda5b4c01cebc376c65fde035767f581515047a7d463eda5e9849ee2278db80301aa88813981e80f9f3f4988de7cf8869719a5160e41e2cca4c4b0deedcff9c5706ca531452abcee947966643389f57790b7c899cf6fa4945d70b455d3270a6484d978440b91d3e23ce3055f1ef260a148ba4570bb51cb58ef3190066f2b1443d6ecd11a015b96966e0caf32236a4df15140e6ae6e131ede59ff51403400bcd325f075f7d2a5000f847a6a7996480bbbc63d08" + }, + { + "from": "service", + "name": "lp", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "message": "000000000000000148cf8f560097201c885cbfb4654f3007e1146c3b6c39ce8a4b98af3c76347a1def28cbf0bf20db92705fcc9bff3ee7bd78220f315ebbfebc2b4371edd2b9823910351899a8b3ef8e2a26c61eaa56fd4d71fff10af0aef235390740ad8357756cbe3ccbbf667c02d3c36d056d8f2ea29dcf4dd3be678e9d19abc308d6f940c45053b6633a336e0e86ba1accd5ac2ac60a7b7471115b9c539ee973acc104a71b71c42724ed43335f9eb3eff4d447b52d989605ab2a0e3250fbf19ec53367f11f1fbaa633b1bbd7422bc0740f7d44ffc6e7fc1b1e6594fae2a3027eb58b5a63089a328878c377ef69d5b1e194606836b5db97e9da4cd5e6bac5214f17b7ef161d0a04605251de29bfab442788332d5dcfc716bc04f604544368a87cfb77d7d57b89dae49c8378b2cf2372b6fe1108076edd6adbec3fa25df23d210bb22281f91564367ec7bfb17d5b6c218eda569d65bf5a99a9a242ff72190419e2ebaa34919457c9163458d6eec3e42b67e395b6d96b771a82ca8e4cf5faf3db7493c02d1695e2b2a6e40d3156cb570f5d4880ca294fd8ac70d8deddd605cdc9fbf5cb4c0b3de38a66ff3e1ef0d35c01dfb576eda170a545682498c035afac02f29ce0e3b7c0c3d0ad1536d04481cbc90e18376c57348ff1f38b1b83000e343966b52cf82f0d243c0d225360c6c1034f0dfbc2ed08f09ff2844c578ac10f27e5fa85e77fe84b98e6fff0609e82409f9b32b5809f50355007f7284bc8d60d612d60b46627668de3d4e7966b6c1f40435bd4d0fbba103a13c29db8fb74aa1c6ca06ea20979e3093d3a88f8fbef1bba2e15cfd81822a573820f3ca87e9f9519fbb8c536f23831f69e7be75b869835" + }, + { + "from": "phone", + "name": "watch away", + "frame": "010100000016a3616245a162687a0061746577617463686373696740", + "message": "000000000000000018d9d2f74604f3ec3b405b61c46ab22f3f16d31530953cc94eec8cade0d64f32ba349e2f7835559709c008f3" + }, + { + "from": "phone", + "name": "suspend", + "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740", + "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b" + } + ], + "notes": { + "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", + "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", + "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0." + } +} From 7490c64c35e34298678cca3ecd35ad68d1cbaa02 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:33:18 +0200 Subject: [PATCH 09/15] feat(appwire): name the initiator's key after message 1 A responder that admits initiators by key decides before message 2 carries anything. FTW Cloud checks the phone's key against the box's roster there. Co-Authored-By: Claude Opus 5.5 --- go/protocol/appwire/initiator_test.go | 6 ++++++ go/protocol/appwire/noise.go | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/go/protocol/appwire/initiator_test.go b/go/protocol/appwire/initiator_test.go index 7ce9de4b1..158f72779 100644 --- a/go/protocol/appwire/initiator_test.go +++ b/go/protocol/appwire/initiator_test.go @@ -75,11 +75,17 @@ func TestInitiatorAndResponderMeet(t *testing.T) { if err != nil { t.Fatal(err) } + if resp.RemoteStatic() != nil { + t.Fatal("the responder named a key before message 1") + } m1, _ := init.WriteMessage([]byte("now")) p1, err := resp.ReadMessage(m1) if err != nil || string(p1) != "now" { t.Fatalf("message 1: %q %v", p1, err) } + if !bytes.Equal(resp.RemoteStatic(), phone.Public()) { + t.Fatal("after message 1 the responder does not know the initiator's key") + } m2, _ := resp.WriteMessage([]byte("snapshot")) p2, err := init.ReadMessage(m2) if err != nil || string(p2) != "snapshot" { diff --git a/go/protocol/appwire/noise.go b/go/protocol/appwire/noise.go index 8df400bdf..f9303a381 100644 --- a/go/protocol/appwire/noise.go +++ b/go/protocol/appwire/noise.go @@ -496,6 +496,19 @@ func (h *HandshakeState) WriteMessage(payload []byte) ([]byte, error) { return append(epub, encrypted...), nil } +// RemoteStatic is the initiator's static key once ReadMessage has +// authenticated it, and nil before. A responder that admits initiators by key, +// as FTW Cloud admits phones by the box's roster, decides here, before +// message 2 carries anything. +func (h *HandshakeState) RemoteStatic() []byte { + h.mu.Lock() + defer h.mu.Unlock() + if h.step == stepRead1 { + return nil + } + return append([]byte{}, h.rs...) +} + // Complete reports whether the pattern has run to its end. func (h *HandshakeState) Complete() bool { h.mu.Lock() From 8cca4203666580ff84d7a406df035fbab520502d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:49:21 +0200 Subject: [PATCH 10/15] feat(protocol): signed events, push registration and the alert payload FTW Cloud C4 carries notifications. cloudwire gains the event class body (eventId, ts, kind, severity, args, src, lp), the phone's push.register and push.unregister, and the APNs payload the service builds: the same aps for every alert, and the box's envelope unchanged under "ftw". ReadAlert is the reference for what a phone's notification extension makes of a payload: a fresh, anchored, box-signed event of a known kind, the service's own box.unreachable, or the generic line. The catalogue gains charging.goal_at_risk. cloud-vectors.json gains three signed events; push-vectors.json holds fourteen payloads, the phone's clock and the verdict each must get, for the service and the app to replay. Co-Authored-By: Claude Opus 5.5 --- contract/push-catalogue.yaml | 6 + go/protocol/cloudwire/allowlist.go | 7 +- go/protocol/cloudwire/push.go | 220 +++++++++++++ go/protocol/cloudwire/push_vectors_test.go | 289 ++++++++++++++++++ .../cloudwire/testdata/cloud-vectors.json | 30 ++ .../cloudwire/testdata/push-vectors.json | 181 +++++++++++ go/protocol/cloudwire/vectors_test.go | 15 + go/protocol/push_gen.go | 2 + 8 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 go/protocol/cloudwire/push.go create mode 100644 go/protocol/cloudwire/push_vectors_test.go create mode 100644 go/protocol/cloudwire/testdata/push-vectors.json diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8560885d2..8473df44a 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -19,6 +19,12 @@ events: - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." - kind: charging.interrupted # Only after the hysteresis window the box applies: a charge that had # been running steadily and then stopped without finishing. A flapping diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 42acd3829..22fb4f80f 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -31,6 +31,8 @@ var Fields = map[string][]string{ MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"}, + MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant"}, "Device": {"id", "key", "role", "scopes", "epoch"}, "Grant": {"id", "scopes", "purposes", "controller"}, @@ -39,14 +41,15 @@ var Fields = map[string][]string{ } // 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. +// not send yet (plan, health, tiles) have no entry; the service treats their +// bytes as opaque either way. var Bodies = map[string]any{ MsgHello: Hello{}, MsgLive: Live{}, MsgLP: LP{}, MsgPrices: Prices{}, MsgHB: HB{}, + MsgEvent: Event{}, MsgRoster: Roster{}, MsgBye: Bye{}, } diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go new file mode 100644 index 000000000..57650dfc3 --- /dev/null +++ b/go/protocol/cloudwire/push.go @@ -0,0 +1,220 @@ +package cloudwire + +import ( + "encoding/json" + + "github.com/srcfl/ftw/go/protocol" +) + +// Events and push: what the box tells phones that are not looking, how a +// phone tells the service where Apple can reach it, and what the service +// hands Apple. docs/protocol-cloud.md, "Push", is the normative text. +// +// The box signs every event. The service wraps the envelope, unchanged, in an +// APNs alert; the phone's notification extension checks the signature with +// the box key it learned over Noise, writes its own text from the kind, and +// picks the buttons from the verified kind alone. The service picks nothing: +// not the words, not the buttons, not the priority. box.unreachable is the +// one alert the service writes itself, because a box that cannot reach the +// service cannot sign one; it carries no arguments and no buttons. + +// Message types a phone sends the service over its app session. +const ( + MsgPushRegister = "push.register" + MsgPushUnregister = "push.unregister" +) + +// APNs environments. A development build of the app gets sandbox tokens. +const ( + PushSandbox = "sandbox" + PushProduction = "production" +) + +// PushKindAlert is the only kind of registration until widgets and Live +// Activities get their own pushes. +const PushKindAlert = "alert" + +// APNsTokenMaxBytes bounds a device token. Apple's are 32 bytes today and +// documented as variable length. +const APNsTokenMaxBytes = 100 + +// Event severities, from the box's notification priority. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityAlarm = "alarm" +) + +// Event is one notification the box raised, for devices with +// ftw.events.read. Args are the catalogue's placeholders except names: an +// event about a device names it by Src, and the phone looks the name up in +// the table it holds from the box. +type Event struct { + Header + // EventID is unique per event. + EventID string `cbor:"eventId"` + // Ts is the box's wall time when the event happened, present only + // while NTP has synced the box clock. It can be earlier than the + // header's wallMs when the event waited for the uplink. + Ts *int64 `cbor:"ts,omitempty"` + Kind string `cbor:"kind"` + Severity string `cbor:"severity"` + Args map[string]string `cbor:"args"` + // Src is the opaque source id of the device the event concerns. + Src string `cbor:"src,omitempty"` + // LP is the opaque loadpoint id of the loadpoint it concerns. + LP string `cbor:"lp,omitempty"` +} + +// PushRegister tells the service a device's APNs token for one kind of push. +// The service binds it to the device's roster entry and forgets it when the +// roster drops the device, its epoch changes, or Apple says the token is +// gone. +type PushRegister struct { + APNsToken []byte `cbor:"apnsToken"` + Environment string `cbor:"environment"` + Kind string `cbor:"kind"` + // ActivityID names a Live Activity. Unused until its push exists. + ActivityID string `cbor:"activityId,omitempty"` +} + +// PushUnregister forgets one token. +type PushUnregister struct { + APNsToken []byte `cbor:"apnsToken"` +} + +// AlertMaxAgeMs is how old an event may be and still get its text and +// buttons on the phone. An older one, or one the phone cannot anchor in +// time, shows the generic line with no buttons. +const AlertMaxAgeMs = 10 * 60 * 1000 + +// UnreachableAfterMs is how long the service waits without the box's socket +// before it sends box.unreachable. A bye for an update holds it back until +// the box returns or UpdateGraceMs has passed. +const ( + UnreachableAfterMs = 600 * 1000 + UpdateGraceMs = 60 * 60 * 1000 +) + +// Alert is the "ftw" member of an APNs alert payload. Event is the box's +// envelope exactly as it arrived; Kind is set only on the alert the service +// writes itself, box.unreachable, and a phone accepts no other kind there. +type Alert struct { + V int `json:"v"` + Home string `json:"home"` + Event []byte `json:"event,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// AlertKey is the payload key Alert sits under, beside Apple's "aps". +const AlertKey = "ftw" + +// Localisation keys of the line Apple shows if the extension does not run. +// The app's own strings hold the words, so the service writes none. +const ( + AlertTitleLocKey = "push.title" + AlertBodyLocKey = "push.generic" +) + +// AlertPayload is the whole APNs payload for one alert: Apple's "aps" with +// the generic line and mutable-content, so the extension runs, and Alert +// under AlertKey. Every alert gets the same aps, whatever its kind. +func AlertPayload(a Alert) ([]byte, error) { + type apsAlert struct { + TitleLocKey string `json:"title-loc-key"` + LocKey string `json:"loc-key"` + } + type aps struct { + Alert apsAlert `json:"alert"` + MutableContent int `json:"mutable-content"` + Sound string `json:"sound"` + } + a.V = Version + return json.Marshal(struct { + APS aps `json:"aps"` + FTW Alert `json:"ftw"` + }{aps{apsAlert{AlertTitleLocKey, AlertBodyLocKey}, 1, "default"}, a}) +} + +// What a phone makes of an alert. +const ( + // AlertEvent: a box-signed event, anchored and fresh, of a kind the + // catalogue has. The phone writes its text and offers its buttons. + AlertEvent = "event" + // AlertUnreachable: the service's box.unreachable. Its own text, no + // buttons. + AlertUnreachable = "unreachable" + // AlertGeneric: anything else. The generic line, no buttons. + AlertGeneric = "generic" +) + +// AlertView is what an alert says once checked. +type AlertView struct { + Verdict string + Home string + // The rest is set only for AlertEvent. + Kind string + Args map[string]string + Src string + LP string + Boot []byte + Seq uint64 + // AtMs is when the event happened by the phone's reading of the box + // clock: never later than it arrived. + AtMs int64 +} + +// ClockLeadMs is how far ahead of the phone a box's wall clock may run +// before the phone stops trusting it (rule N). +const ClockLeadMs = 2000 + +// ReadAlert is the reference for what a phone's notification extension does +// with an alert payload. boxKey returns the key the phone learned over Noise +// for a home, nil when it has none; nowMs is the phone's clock at arrival. +// Whether a (boot, seq) was seen before is the caller's state, not this +// function's. +func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) AlertView { + var p struct { + FTW *Alert `json:"ftw"` + } + if json.Unmarshal(payload, &p) != nil || p.FTW == nil || p.FTW.V != Version { + return AlertView{Verdict: AlertGeneric} + } + a := *p.FTW + view := AlertView{Verdict: AlertGeneric, Home: a.Home} + if len(a.Event) == 0 { + if a.Kind == protocol.PushBoxUnreachable { + view.Verdict = AlertUnreachable + } + return view + } + if a.Kind != "" { + return view + } + env, err := DecodeEnvelope(a.Event) + if err != nil || env.T != MsgEvent { + return view + } + pk := boxKey(a.Home) + if pk == nil { + return view + } + pub, err := ParsePublicKey(pk) + if err != nil || Verify(pub, a.Home, env) != nil { + return view + } + var ev Event + if Unmarshal(env.B, &ev) != nil || ev.WallMs == nil || *ev.WallMs > nowMs+ClockLeadMs { + return view + } + at := min(*ev.WallMs, nowMs) + if ev.Ts != nil { + at = min(at, *ev.Ts) + } + _, known := protocol.PushSentences[ev.Kind] + if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > AlertMaxAgeMs { + return view + } + return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, + Boot: ev.Boot, Seq: ev.Seq, AtMs: at} +} diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go new file mode 100644 index 000000000..66002ba5d --- /dev/null +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -0,0 +1,289 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "slices" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// Push vectors: APNs payloads as the service makes them, the phone's clock +// when each arrives, and what the phone must make of it. ReadAlert is the +// reference; the phone's notification extension replays the same file. +// +// go test ./cloudwire -run TestPushVectors -update +// +// Signatures are stored, as in cloud-vectors.json, so an update that only +// adds a case leaves the others' bytes alone. + +const pushVectorsPath = "testdata/push-vectors.json" + +type pushVectorFile struct { + // Keys are the box keys the phone holds, by home. + Keys []pushVectorKey `json:"keys"` + Cases []pushVectorCase `json:"cases"` +} + +type pushVectorKey struct { + Home string `json:"home"` + PK string `json:"pk"` +} + +type pushVectorCase struct { + Name string `json:"name"` + // Payload is the APNs payload, exactly as the service sends it. + Payload string `json:"payload"` + // Sig is the event's stored signature, hex, empty when there is no + // event or it is unsigned. + Sig string `json:"sig"` + NowMs int64 `json:"nowMs"` + Want pushVectorOut `json:"want"` +} + +type pushVectorOut struct { + Verdict string `json:"verdict"` + Home string `json:"home"` + Kind string `json:"kind,omitempty"` + Args map[string]string `json:"args,omitempty"` + Src string `json:"src,omitempty"` + LP string `json:"lp,omitempty"` + Boot string `json:"boot,omitempty"` + Seq uint64 `json:"seq,omitempty"` + AtMs int64 `json:"atMs,omitempty"` +} + +func TestPushVectors(t *testing.T) { + box := testKey(t, "ftw.cloud vector box") + other := testKey(t, "ftw.cloud vector box two") + home, boxPK := homeOf(t, box) + otherHome, otherPK := homeOf(t, other) + grantKey := bytes.Repeat([]byte{0x77}, GrantKeyBytes) + charger := SourceID(grantKey, "easee") + garage := LoadpointID(grantKey, "garage") + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + wall := int64(1_791_820_800_000) + at := func(uptime int64) *int64 { return i64(wall + uptime - 60_000) } + + var stored pushVectorFile + if !*update { + raw, err := os.ReadFile(pushVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if err := json.Unmarshal(raw, &stored); err != nil { + t.Fatal(err) + } + } + storedSig := func(i int, name string) []byte { + if i >= len(stored.Cases) || stored.Cases[i].Name != name { + t.Fatalf("push vector %d is not %q in %s; regenerate with -update", i, name, pushVectorsPath) + } + sig, _ := hex.DecodeString(stored.Cases[i].Sig) + return sig + } + + connected := Event{ + Header: Header{Seq: 9, Boot: boot, UptimeMs: 71_000, WallMs: at(71_000)}, + EventID: "Q2ZrMjR0dzVh", Ts: i64(*at(71_000) - 400), Kind: protocol.PushChargingConnected, + Severity: SeverityInfo, Args: map[string]string{}, Src: charger, LP: garage, + } + interrupted := Event{ + Header: Header{Seq: 10, Boot: boot, UptimeMs: 72_000, WallMs: at(72_000)}, + EventID: "aW50cnB0ZDAx", Ts: i64(*at(72_000) - 300), Kind: protocol.PushChargingInterrupted, + Severity: SeverityWarning, Args: map[string]string{}, Src: charger, LP: garage, + } + noClock := connected + noClock.WallMs, noClock.Ts, noClock.Seq = nil, nil, 12 + ahead := connected + ahead.WallMs, ahead.Ts, ahead.Seq = at(80_000), nil, 13 + unknown := connected + unknown.Kind, unknown.Seq = "charging.someday", 14 + goal := Event{ + Header: Header{Seq: 15, Boot: boot, UptimeMs: 74_000, WallMs: at(74_000)}, + EventID: "Z29hbHJpc2sx", Ts: i64(*at(74_000) - 100), Kind: protocol.PushChargingGoalAtRisk, + Severity: SeverityWarning, Args: map[string]string{"target": "80", "by": "07:00"}, LP: garage, + } + signedAsOther := connected + signedAsOther.Seq = 16 + + type spec struct { + name string + key *ecdsa.PrivateKey // signer; nil for no event + signFor string // home the box signs for + event *Event + alert Alert // Home and Kind; Event is filled in + mutate func(env []byte) []byte + nowMs int64 + want pushVectorOut + } + fresh := func(e Event) int64 { return *e.WallMs + 1_200 } + event := func(e Event) pushVectorOut { + at := min(*e.WallMs, fresh(e)) + if e.Ts != nil { + at = min(at, *e.Ts) + } + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, AtMs: at} + } + generic := pushVectorOut{Verdict: AlertGeneric, Home: home} + specs := []spec{ + {name: "connected, fresh", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: fresh(connected), want: event(connected)}, + {name: "interrupted, fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: fresh(interrupted), want: event(interrupted)}, + {name: "goal at risk, fresh", key: box, signFor: home, event: &goal, alert: Alert{Home: home}, + nowMs: fresh(goal), want: event(goal)}, + {name: "one byte of the body changed", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + mutate: func(env []byte) []byte { + out := bytes.Clone(env) + i := bytes.Index(out, []byte("charging.connected")) + out[i+len("charging.")] ^= 0x01 + return out + }, + nowMs: fresh(connected), want: generic}, + {name: "named for another home the phone holds", key: box, signFor: home, event: &connected, + alert: Alert{Home: otherHome}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: otherHome}}, + {name: "signed by another box for this home", key: other, signFor: home, event: &signedAsOther, + alert: Alert{Home: home}, nowMs: fresh(signedAsOther), want: generic}, + {name: "two hours old", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: *connected.WallMs + 2*60*60*1000, want: generic}, + {name: "ten minutes old is still fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: *interrupted.Ts + AlertMaxAgeMs, + want: func() pushVectorOut { + o := event(interrupted) + o.AtMs = *interrupted.Ts + return o + }()}, + {name: "box clock not synced", key: box, signFor: home, event: &noClock, alert: Alert{Home: home}, + nowMs: fresh(connected), want: generic}, + {name: "box clock ahead of the phone", key: box, signFor: home, event: &ahead, alert: Alert{Home: home}, + nowMs: *ahead.WallMs - ClockLeadMs - 1, want: generic}, + {name: "a kind the phone does not know", key: box, signFor: home, event: &unknown, alert: Alert{Home: home}, + nowMs: fresh(unknown), want: generic}, + {name: "unreachable, from the service", alert: Alert{Home: home, Kind: protocol.PushBoxUnreachable}, + nowMs: wall, want: pushVectorOut{Verdict: AlertUnreachable, Home: home}}, + {name: "another kind from the service", alert: Alert{Home: home, Kind: protocol.PushChargingConnected}, + nowMs: wall, want: generic}, + {name: "a home the phone does not hold", key: box, signFor: home, event: &connected, + alert: Alert{Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + } + + keys := map[string][]byte{home: boxPK, otherHome: otherPK} + out := pushVectorFile{Keys: []pushVectorKey{ + {Home: home, PK: hex.EncodeToString(boxPK)}, + {Home: otherHome, PK: hex.EncodeToString(otherPK)}, + }} + for i, s := range specs { + a := s.alert + var sig []byte + if s.event != nil { + body, err := Marshal(*s.event) + if err != nil { + t.Fatal(err) + } + if *update { + if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { + t.Fatal(err) + } + } else { + sig = storedSig(i, s.name) + } + if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { + t.Fatalf("%s: stored signature does not verify: %v", s.name, err) + } + env, err := Envelope{T: MsgEvent, B: body, Sig: sig}.Encode() + if err != nil { + t.Fatal(err) + } + if s.mutate != nil { + env = s.mutate(env) + } + a.Event = env + } + payload, err := AlertPayload(a) + if err != nil { + t.Fatal(err) + } + if len(payload) > 4096 { + t.Fatalf("%s: %d bytes is over APNs' 4 KB", s.name, len(payload)) + } + + view := ReadAlert(payload, func(h string) []byte { return keys[h] }, s.nowMs) + got := pushVectorOut{Verdict: view.Verdict, Home: view.Home, Kind: view.Kind, Args: view.Args, + Src: view.Src, LP: view.LP, Seq: view.Seq, AtMs: view.AtMs} + if view.Boot != nil { + got.Boot = hex.EncodeToString(view.Boot) + } + if !equalOut(got, s.want) { + t.Errorf("%s: got %+v, want %+v", s.name, got, s.want) + } + out.Cases = append(out.Cases, pushVectorCase{Name: s.name, Payload: string(payload), + Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want}) + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(pushVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, _ := os.ReadFile(pushVectorsPath) + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the change is intended, regenerate with -update "+ + "and pair it with srcfl/ftw-cloud and the app", pushVectorsPath) + } +} + +func equalOut(a, b pushVectorOut) bool { + if len(a.Args) == 0 && len(b.Args) == 0 { + a.Args, b.Args = nil, nil + } + ka, kb := make([]string, 0, len(a.Args)), make([]string, 0, len(b.Args)) + for k, v := range a.Args { + ka = append(ka, k+"="+v) + } + for k, v := range b.Args { + kb = append(kb, k+"="+v) + } + slices.Sort(ka) + slices.Sort(kb) + a.Args, b.Args = nil, nil + return a.Verdict == b.Verdict && a.Home == b.Home && a.Kind == b.Kind && a.Src == b.Src && a.LP == b.LP && + a.Boot == b.Boot && a.Seq == b.Seq && a.AtMs == b.AtMs && slices.Equal(ka, kb) +} + +// The service's alert has the same aps whatever it carries, so Apple learns +// a kind only from the signed event, never from priority or wording. +func TestEveryAlertHasTheSameAps(t *testing.T) { + a, err := AlertPayload(Alert{Home: "h", Event: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + b, err := AlertPayload(Alert{Home: "h", Kind: protocol.PushBoxUnreachable}) + if err != nil { + t.Fatal(err) + } + var pa, pb map[string]json.RawMessage + if json.Unmarshal(a, &pa) != nil || json.Unmarshal(b, &pb) != nil { + t.Fatal("not JSON") + } + if !bytes.Equal(pa["aps"], pb["aps"]) { + t.Fatalf("aps differs: %s vs %s", pa["aps"], pb["aps"]) + } + if !bytes.Contains(pa["aps"], []byte(`"mutable-content":1`)) { + t.Fatalf("no mutable-content: %s", pa["aps"]) + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index d578b866a..9ad224461 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -161,6 +161,36 @@ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" } ] } diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json new file mode 100644 index 000000000..218d502fc --- /dev/null +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -0,0 +1,181 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index a263773a4..172253841 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -159,6 +159,21 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"bye", "box", MsgBye, Bye{Header: h(8, 70_000), Why: ByeRevoked, Purge: true}}, {"purged", "service", MsgPurged, Purged{AtMs: 1_791_821_000_000, What: PurgedHome, GrantID: bytes.Repeat([]byte{0x44}, GrantIDBytes)}}, + {"event connected", "box", MsgEvent, Event{ + Header: h(9, 71_000), EventID: "Q2ZrMjR0dzVh", Ts: i64(wall + 71_000 - 60_000 - 400), + Kind: protocol.PushChargingConnected, Severity: SeverityInfo, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event interrupted", "box", MsgEvent, Event{ + Header: h(10, 72_000), EventID: "aW50cnB0ZDAx", Ts: i64(wall + 72_000 - 60_000 - 300), + Kind: protocol.PushChargingInterrupted, Severity: SeverityWarning, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event session complete", "box", MsgEvent, Event{ + Header: h(11, 73_000), EventID: "ZG9uZTAwMDAx", Ts: i64(wall + 73_000 - 60_000 - 200), + Kind: protocol.PushChargingSessionComplete, Severity: SeverityInfo, + Args: map[string]string{"kwh": "18.4"}, Src: charger, LP: garage, + }}, } } diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index c0454eea2..bb6a93f20 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -10,6 +10,7 @@ const PushCatalogueVersion = 1 const ( PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" + PushChargingGoalAtRisk = "charging.goal_at_risk" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" PushDriverOffline = "driver.offline" @@ -30,6 +31,7 @@ type PushSentence struct { var PushSentences = map[string]PushSentence{ PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, + PushChargingGoalAtRisk: {Title: "The car may not be ready", Body: "It will not reach {target} % by {by} at this pace."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, PushDriverOffline: {Title: "A device went quiet", Body: "{name} stopped answering."}, From c85761d687276ccdaf85a8e4af85640967f2acb0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:53:35 +0200 Subject: [PATCH 11/15] feat(cloud): session information in hello_ok, cloud.link and statistics consent hello_ok gains an optional `cloud` field for every role while an owner has FTW Cloud on: the box's FTW Cloud key, the home id, the uplink's boot id and the tables from opaque source and loadpoint ids to names. A phone checks what the service forwards with that key, never one the service gives it. Floor mode leaves it out. hello_ok says cloud.link when this build names a service. Release builds name none and stay silent. POST /api/cloud/stats {policy} adds the stats purpose under a grant already on (409 when off or revoking), draws a 16-byte statsId once and keeps it, saves the grant, marks the roster due and writes the audit line. DELETE /api/cloud/stats removes the purpose and forgets the id. No statistics are sent yet. Co-Authored-By: Claude Opus 5.5 --- go/cmd/ftw/app_link.go | 10 +- go/cmd/ftw/cloud_link.go | 37 ++++ go/cmd/ftw/cloud_link_test.go | 81 +++++++++ go/cmd/ftw/main.go | 5 +- go/internal/api/api_cloud.go | 54 +++++- go/internal/api/api_cloud_test.go | 207 +++++++++++++++++++++++ go/internal/appproto/handler.go | 10 ++ go/internal/appproto/handshake_test.go | 74 ++++++++ go/internal/appproto/protocol.go | 2 + go/internal/cloudlink/grant.go | 6 + go/internal/cloudlink/link.go | 139 ++++++++++++++- go/internal/cloudlink/link_test.go | 225 +++++++++++++++++++++++++ go/protocol/messages.go | 24 +++ 13 files changed, 868 insertions(+), 6 deletions(-) diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 747354aa2..80b4ebed8 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -519,6 +519,7 @@ func startAppLink( siteMeterStale time.Duration, gateway *lateAPI, webPush *notifications.WebPush, + cloud *cloudSession, ) (*appenroll.Identity, *appuplink.Uplink, bool, error) { enabled := cfg != nil && cfg.AppLink.On() @@ -610,9 +611,12 @@ func startAppLink( API: gateway, Caller: caller, Grants: grants, - Caps: caps, - Codec: appuplink.Codec(), - Sender: sender, + // FTW Cloud is asked per session: its link is bound after + // this returns, and its grant moves while the box runs. + Caps: cloud.caps(caps), + Cloud: cloud.info, + Codec: appuplink.Codec(), + Sender: sender, // The three frozen power fields point at the source whose // freshness governs them. The site meter is the only one the // box can name without knowing the site's hardware. diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 92120aab9..ef6ac90f0 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -6,6 +6,7 @@ import ( "log/slog" "math" "path/filepath" + "slices" "sort" "strconv" "sync" @@ -337,6 +338,42 @@ func startCloudLink( return link } +// cloudSession is FTW Cloud as an app session sees it. The app link starts +// first, because the cloud link's roster needs its enrolment, so the link is +// bound here once it exists. A session that opens before then hears no +// cloud.link and no session information, which is what a box without FTW +// Cloud says. +type cloudSession struct { + link atomic.Pointer[cloudlink.Link] +} + +func (c *cloudSession) bind(link *cloudlink.Link) { c.link.Store(link) } + +func (c *cloudSession) load() *cloudlink.Link { + if c == nil { + return nil + } + return c.link.Load() +} + +// caps adds cloud.link to an app session's capabilities when this build can +// run the uplink. A release build names no service and says nothing. +func (c *cloudSession) caps(caps []string) []string { + if link := c.load(); link != nil && link.Available() { + return append(slices.Clip(caps), appproto.CapCloudLink) + } + return caps +} + +// info is hello_ok's FTW Cloud session information, nil while FTW Cloud is +// off. +func (c *cloudSession) info() *appproto.CloudSession { + if link := c.load(); link != nil { + return link.SessionInfo() + } + return nil +} + // 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 { diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index 58cf37b9b..c268185be 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -2,11 +2,20 @@ package main import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "log/slog" + "path/filepath" + "slices" "testing" "time" "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -77,3 +86,75 @@ func TestCloudLoadpointLeavesCapabilitiesAndNamesBehind(t *testing.T) { t.Fatalf("plugged loadpoint %+v", lp) } } + +type quietCloudSite struct{} + +func (quietCloudSite) Live() cloudlink.Live { + return cloudlink.Live{Sources: map[string]cloudlink.Source{"p1": {Kind: "driver"}}} +} +func (quietCloudSite) Loadpoints() (cloudwire.LP, bool) { return cloudwire.LP{}, false } +func (quietCloudSite) Prices() (cloudwire.Prices, bool) { return cloudwire.Prices{}, false } + +type noPhones struct{} + +func (noPhones) Devices() []cloudlink.RosterDevice { return nil } + +func newTestCloudLink(t *testing.T, keys []*ecdsa.PublicKey) *cloudlink.Link { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + link, err := cloudlink.New(cloudlink.Options{ + Site: quietCloudSite{}, Roster: noPhones{}, KV: st, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: keys, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + return link +} + +// An app session hears cloud.link only from a build that can run the uplink, +// and the session information only while an owner has FTW Cloud on. +func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) { + base := appproto.DefaultCaps() + var none *cloudSession + unbound := &cloudSession{} + release := &cloudSession{} + release.bind(newTestCloudLink(t, []*ecdsa.PublicKey{})) + for name, c := range map[string]*cloudSession{"no link": none, "not bound yet": unbound, "release build": release} { + if slices.Contains(c.caps(base), appproto.CapCloudLink) || c.info() != nil { + t.Fatalf("%s: caps %v, info %+v", name, c.caps(base), c.info()) + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + link := newTestCloudLink(t, []*ecdsa.PublicKey{&key.PublicKey}) + withService := &cloudSession{} + withService.bind(link) + caps := withService.caps(base) + if !slices.Contains(caps, appproto.CapCloudLink) || slices.Contains(base, appproto.CapCloudLink) { + t.Fatalf("caps %v from %v", caps, base) + } + if withService.info() != nil { + t.Fatal("session information before a grant") + } + if err := link.Grant(cloudlink.GrantRequest{Scopes: []string{appproto.ScopeLiveRead}, Policy: "2026-10"}); err != nil { + t.Fatal(err) + } + info := withService.info() + if info == nil || info.Home != link.Status().Home || len(info.Sources) != 1 { + t.Fatalf("session information %+v", info) + } + for id, name := range info.Sources { + if name != "p1" || len(id) != 11 { + t.Fatalf("source %q named %q", id, name) + } + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 95a9686cd..5b4561f1f 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2423,10 +2423,12 @@ func main() { // The app's window onto this box's own HTTP API. Handed to the uplink // now and filled in below, once the API server exists. appAPI := &lateAPI{} + // FTW Cloud as app sessions see it, bound below once the link exists. + appCloud := &cloudSession{} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs, - controlRev, appLinkWatchdog, appAPI, webPush, + controlRev, appLinkWatchdog, appAPI, webPush, appCloud, ) switch { case appLinkErr != nil: @@ -2443,6 +2445,7 @@ func main() { cloudNotify := &cloudNotices{} cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify) + appCloud.bind(cloudLink) // The anonymous fleet ping: how many boxes, which versions, which drivers. // Its separate relay HTTP door keeps daily totals; the encrypted app path diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 92ba4e6af..e73e2e016 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -18,7 +18,8 @@ import ( // 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. +// app's consent screen claims no more than an owner's session. Statistics are +// a second consent under the grant, behind the same doors. // CloudGrant is the box's FTW Cloud link as the routes see it. type CloudGrant interface { @@ -26,6 +27,8 @@ type CloudGrant interface { Preview() cloudlink.Preview Grant(cloudlink.GrantRequest) error Revoke(by string) error + Stats(cloudlink.StatsRequest) error + StopStats(by string) error } func (s *Server) cloudRoutes() { @@ -33,6 +36,8 @@ func (s *Server) cloudRoutes() { s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview) s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp) s.handle("DELETE /api/cloud/grant", Configure, s.handleCloudRevoke, NoStepUp) + s.handle("POST /api/cloud/stats", Configure, s.handleCloudStats, NoStepUp) + s.handle("DELETE /api/cloud/stats", Configure, s.handleCloudStopStats, NoStepUp) } // cloudGate admits an owner's session, and on the LAN a local client. With @@ -157,6 +162,53 @@ func (s *Server) handleCloudRevoke(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, link.Status()) } +// POST /api/cloud/stats {policy} — contribute to statistics, a separate +// consent under a grant already on. Same gate as the grant. +func (s *Server) handleCloudStats(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, true) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + var req cloudlink.StatsRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeAppLinkError(w, http.StatusBadRequest, "send {policy}") + return + } + req.By = cloudCaller(r) + switch err := link.Stats(req); { + case err == nil: + writeJSON(w, http.StatusOK, link.Status()) + case errors.Is(err, cloudlink.ErrGrantRequest): + writeAppLinkError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, cloudlink.ErrNotGranted), errors.Is(err, cloudlink.ErrPurgePending): + writeAppLinkError(w, http.StatusConflict, err.Error()) + case errors.Is(err, cloudlink.ErrNoService): + writeAppLinkError(w, http.StatusServiceUnavailable, err.Error()) + default: + writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics on") + } +} + +// DELETE /api/cloud/stats — stop contributing to statistics. Any door, as for +// the grant. +func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) { + if !s.cloudGate(w, r, false) { + return + } + link, ok := s.cloudLink(w) + if !ok { + return + } + if err := link.StopStats(cloudCaller(r)); err != nil { + writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off") + return + } + writeJSON(w, http.StatusOK, link.Status()) +} + // CloudLoadpoints is the loadpoint state GET /api/loadpoints composes, and // the control feedback beside it, for the FTW Cloud uplink. ok is false on a // box without loadpoints. diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index fb064be2f..58be0f419 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -1,17 +1,29 @@ package api import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "io" + "log/slog" "net/http" "net/http/httptest" + "path/filepath" + "strings" + "sync" "testing" "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) type stubCloud struct { granted []cloudlink.GrantRequest revoked []string + stats []cloudlink.StatsRequest + stopped []string err error } @@ -25,6 +37,14 @@ func (c *stubCloud) Revoke(by string) error { c.revoked = append(c.revoked, by) return nil } +func (c *stubCloud) Stats(req cloudlink.StatsRequest) error { + c.stats = append(c.stats, req) + return c.err +} +func (c *stubCloud) StopStats(by string) error { + c.stopped = append(c.stopped, by) + return nil +} func viewerRequest(method, target, body string) *http.Request { r := sessionRequest(method, target, body) @@ -167,6 +187,8 @@ func TestCloudRoutesAreRegisteredWithTheirTiers(t *testing.T) { {http.MethodGet, "/api/cloud/preview", apiauth.TierRead, false}, {http.MethodPost, "/api/cloud/grant", apiauth.TierConfigure, true}, {http.MethodDelete, "/api/cloud/grant", apiauth.TierConfigure, true}, + {http.MethodPost, "/api/cloud/stats", apiauth.TierConfigure, true}, + {http.MethodDelete, "/api/cloud/stats", apiauth.TierConfigure, true}, } { facts := s.Route(httptest.NewRequest(c.method, c.path, nil)) if facts.Tier != c.tier || facts.NoStepUp != c.noStepUp { @@ -174,3 +196,188 @@ func TestCloudRoutesAreRegisteredWithTheirTiers(t *testing.T) { } } } + +const statsBody = `{"policy":"stats-2026-10"}` + +// Statistics are a separate consent behind the grant's own door. +func TestWhoMayTurnStatisticsOn(t *testing.T) { + cases := []struct { + name string + req *http.Request + want int + }{ + {"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, + {"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, + {"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, + {"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, + {"a LAN client with the house password", func() *http.Request { + r := localRequest(http.MethodPost, "/api/cloud/stats", statsBody) + return r.WithContext(withLANSecret(r.Context(), true)) + }(), http.StatusOK}, + {"through a proxy", func() *http.Request { + r := loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody) + r.Header.Set("X-Forwarded-For", "203.0.113.9") + return r + }(), http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudStats(w, c.req) + if w.Code != c.want { + t.Fatalf("got %d, want %d: %s", w.Code, c.want, w.Body.String()) + } + if asked := len(cloud.stats) == 1; asked != (c.want == http.StatusOK) { + t.Fatalf("stats asked = %v with status %d", asked, w.Code) + } + }) + } +} + +func TestStatisticsRecordWhichDoorAskedAndThePolicy(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + s.handleCloudStats(httptest.NewRecorder(), sessionRequest(http.MethodPost, "/api/cloud/stats", + `{"policy":"stats-2026-10","By":"someone else"}`)) + if len(cloud.stats) != 1 || cloud.stats[0].By != "app:aaaa1111" || cloud.stats[0].Policy != "stats-2026-10" { + t.Fatalf("stats %+v", cloud.stats) + } + w := httptest.NewRecorder() + s.handleCloudStats(w, sessionRequest(http.MethodPost, "/api/cloud/stats", "not json")) + if w.Code != http.StatusBadRequest { + t.Fatalf("a body that is not JSON: %d", w.Code) + } +} + +func TestAnyDoorMayTurnStatisticsOff(t *testing.T) { + for name, req := range map[string]*http.Request{ + "owner over a session": sessionRequest(http.MethodDelete, "/api/cloud/stats", ""), + "a LAN client": localRequest(http.MethodDelete, "/api/cloud/stats", ""), + } { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + w := httptest.NewRecorder() + s.handleCloudStopStats(w, req) + if w.Code != http.StatusOK || len(cloud.stopped) != 1 { + t.Fatalf("%s: %d, stopped %v", name, w.Code, cloud.stopped) + } + } +} + +// A viewer's session sees no FTW Cloud screen and changes nothing in it, even +// where turning something off would narrow access. +func TestAViewerReachesNoCloudRoute(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + for _, c := range []struct { + method, target, body string + handler http.HandlerFunc + }{ + {http.MethodGet, "/api/cloud", "", s.handleCloudStatus}, + {http.MethodGet, "/api/cloud/preview", "", s.handleCloudPreview}, + {http.MethodPost, "/api/cloud/grant", grantBody, s.handleCloudGrant}, + {http.MethodDelete, "/api/cloud/grant", "", s.handleCloudRevoke}, + {http.MethodPost, "/api/cloud/stats", statsBody, s.handleCloudStats}, + {http.MethodDelete, "/api/cloud/stats", "", s.handleCloudStopStats}, + } { + w := httptest.NewRecorder() + c.handler(w, viewerRequest(c.method, c.target, c.body)) + if w.Code != http.StatusForbidden { + t.Errorf("viewer %s %s: %d", c.method, c.target, w.Code) + } + } + if len(cloud.granted)+len(cloud.revoked)+len(cloud.stats)+len(cloud.stopped) != 0 { + t.Fatalf("a viewer changed FTW Cloud: %+v", cloud) + } +} + +func TestStatisticsRefusalsKeepTheirMeaning(t *testing.T) { + for err, want := range map[error]int{ + cloudlink.ErrGrantRequest: http.StatusBadRequest, + cloudlink.ErrNotGranted: http.StatusConflict, + cloudlink.ErrPurgePending: http.StatusConflict, + cloudlink.ErrNoService: http.StatusServiceUnavailable, + } { + s := New(&Deps{Cloud: &stubCloud{err: err}}) + w := httptest.NewRecorder() + s.handleCloudStats(w, sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody)) + if w.Code != want { + t.Errorf("%v: got %d, want %d", err, w.Code, want) + } + } +} + +// The consent screen's whole round trip on a real link: on, statistics on, +// GET shows both purposes, statistics off, FTW Cloud off. +func TestTheConsentRoundTripShowsThePurposes(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + link, err := cloudlink.New(cloudlink.Options{ + Site: cloudSiteStub{}, Roster: cloudRosterStub{}, KV: &cloudKV{m: map[string]string{}}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{&key.PublicKey}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + s := New(&Deps{Cloud: link}) + call := func(h http.HandlerFunc, method, target, body string, want int) cloudlink.Status { + t.Helper() + w := httptest.NewRecorder() + h(w, sessionRequest(method, target, body)) + if w.Code != want { + t.Fatalf("%s %s: %d, want %d: %s", method, target, w.Code, want, w.Body.String()) + } + var st cloudlink.Status + _ = json.Unmarshal(w.Body.Bytes(), &st) + return st + } + + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) + call(s.handleCloudGrant, http.MethodPost, "/api/cloud/grant", grantBody, http.StatusOK) + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusOK) + if st := call(s.handleCloudStatus, http.MethodGet, "/api/cloud", "", http.StatusOK); st.State != "on" || + strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("status with statistics on: %+v", st) + } + if st := call(s.handleCloudStopStats, http.MethodDelete, "/api/cloud/stats", "", http.StatusOK); strings.Join(st.Purposes, ",") != "serve" { + t.Fatalf("status with statistics off: %+v", st) + } + if st := call(s.handleCloudRevoke, http.MethodDelete, "/api/cloud/grant", "", http.StatusOK); st.State != "revoking" { + t.Fatalf("status after FTW Cloud off: %+v", st) + } + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) +} + +type cloudSiteStub struct{} + +func (cloudSiteStub) Live() cloudlink.Live { return cloudlink.Live{} } +func (cloudSiteStub) Loadpoints() (cloudwire.LP, bool) { return cloudwire.LP{}, false } +func (cloudSiteStub) Prices() (cloudwire.Prices, bool) { return cloudwire.Prices{}, false } + +type cloudRosterStub struct{} + +func (cloudRosterStub) Devices() []cloudlink.RosterDevice { return nil } + +type cloudKV struct { + mu sync.Mutex + m map[string]string +} + +func (k *cloudKV) LoadConfig(key string) (string, bool) { + k.mu.Lock() + defer k.mu.Unlock() + v, ok := k.m[key] + return v, ok +} + +func (k *cloudKV) SaveConfig(key, value string) error { + k.mu.Lock() + defer k.mu.Unlock() + k.m[key] = value + return nil +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 992e09d6d..8627b3ac2 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -68,6 +68,12 @@ type Config struct { // silently hiding a feature in the app. Caps []string + // Cloud answers hello_ok's FTW Cloud session information, or nil while + // FTW Cloud is off. Asked once per hello, for every role: a viewer reads + // FTW Cloud too and checks the box's signatures the same way. Nil on a + // box without the uplink. + Cloud func() *CloudSession + // Source ids for the three frozen power fields. The dictionary points // each reading at one of these, which is how the app turns a number // into a freshness claim. @@ -373,11 +379,14 @@ func (h *Handler) onHello(env Envelope) error { caps := h.cfg.Caps hint := "" + var cloud *CloudSession if proto == ProtoFloor { // Floor mode speaks the frozen subset and nothing else. Advertising // more would invite an app that cannot use it to try. caps = []string{CapStatusCore} hint = HintAppUpdate + } else if h.cfg.Cloud != nil { + cloud = h.cfg.Cloud() } source, syncedAtMs := h.cfg.Clock.Sync() @@ -401,6 +410,7 @@ func (h *Handler) onHello(env Envelope) error { Role: h.cfg.Caller.Role, Scopes: h.cfg.Caller.Scopes.Names(), Subscribed: fastSub, + Cloud: cloud, } h.mu.Lock() diff --git a/go/internal/appproto/handshake_test.go b/go/internal/appproto/handshake_test.go index e9bfd4db7..5d843f84e 100644 --- a/go/internal/appproto/handshake_test.go +++ b/go/internal/appproto/handshake_test.go @@ -1,8 +1,10 @@ package appproto import ( + "reflect" "testing" + "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/control" ) @@ -254,3 +256,75 @@ func TestSubWhileBootingIsRefusedWithARetryableCode(t *testing.T) { t.Fatal("a booting box accepted a subscription") } } + +// Every role reads FTW Cloud, so every role learns the box's key, home id, +// boot and names. The hook answers nil while FTW Cloud is off, and hello_ok +// then says nothing about it. +func TestHelloOKCarriesTheCloudSessionForEveryRole(t *testing.T) { + session := &CloudSession{ + PK: append([]byte{4}, make([]byte, 64)...), Home: "abcdefghijklmnopqrstuvwxyz", + Boot: make([]byte, 16), + Sources: map[string]string{"AAAAAAAAAAA": "meter.p1"}, + Loadpoints: map[string]string{"BBBBBBBBBBB": "garage"}, + } + for _, c := range []struct { + name string + caller apiauth.Caller + hook func() *CloudSession + want *CloudSession + }{ + {"owner", ownerCaller(), func() *CloudSession { return session }, session}, + {"viewer", viewerCaller(), func() *CloudSession { return session }, session}, + {"FTW Cloud off", ownerCaller(), func() *CloudSession { return nil }, nil}, + {"no uplink", viewerCaller(), nil, nil}, + } { + t.Run(c.name, func(t *testing.T) { + h, _, rec, _ := newRig(t) + h.cfg.Caller, h.cfg.Cloud = c.caller, c.hook + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoMax}}) + ok := body[HelloOK](t, rec.only(t, MsgHelloOK)) + if !reflect.DeepEqual(ok.Cloud, c.want) { + t.Fatalf("cloud = %+v, want %+v", ok.Cloud, c.want) + } + }) + } +} + +// An app too old for full mode gets the frozen subset, and FTW Cloud is not in +// it. +func TestFloorModeLeavesTheCloudSessionOut(t *testing.T) { + h, _, rec, _ := newRig(t) + h.cfg.Cloud = func() *CloudSession { + t.Fatal("floor mode asked for FTW Cloud's session information") + return nil + } + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoFloor}}) + if ok := body[HelloOK](t, rec.only(t, MsgHelloOK)); ok.Cloud != nil { + t.Fatalf("floor hello_ok carries %+v", ok.Cloud) + } +} + +// The field rides under one key, so an older app skips it whole. +func TestTheCloudSessionWireNames(t *testing.T) { + raw, err := Marshal(HelloOK{Cloud: &CloudSession{PK: []byte{4}, Home: "h", Boot: []byte{1}, + Sources: map[string]string{"s": "d"}, Loadpoints: map[string]string{"l": "p"}}}) + if err != nil { + t.Fatal(err) + } + var generic map[string]any + if err := Unmarshal(raw, &generic); err != nil { + t.Fatal(err) + } + cloud, ok := generic["cloud"].(map[any]any) + if !ok { + t.Fatalf("hello_ok keys %v", generic) + } + for _, k := range []string{"pk", "home", "boot", "sources", "loadpoints"} { + if _, ok := cloud[k]; !ok { + t.Fatalf("cloud has no %q: %v", k, cloud) + } + } + if len(cloud) != 5 { + t.Fatalf("cloud has %d keys: %v", len(cloud), cloud) + } +} diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 348a75500..317ea3e14 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -106,6 +106,7 @@ type ( ErrorBody = protocol.ErrorBody Event = protocol.Event SessionTerminate = protocol.SessionTerminate + CloudSession = protocol.CloudSession ) const ( @@ -213,6 +214,7 @@ const ( CapNetWebrtc = protocol.CapNetWebrtc CapPriceSpot = protocol.CapPriceSpot CapApiPassthrough = protocol.CapApiPassthrough + CapCloudLink = protocol.CapCloudLink ScopeLiveRead = protocol.ScopeLiveRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopePlanRead = protocol.ScopePlanRead diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go index 22a83e33c..fcff0073e 100644 --- a/go/internal/cloudlink/grant.go +++ b/go/internal/cloudlink/grant.go @@ -48,6 +48,12 @@ type grantRecord struct { // AtMs is the wall time of the grant, or 0 when the clock was not set. AtMs int64 `json:"atMs"` Controller string `json:"controller"` + // StatsID names this box's statistics rows at the service. Drawn when an + // owner first turns statistics on, kept while they stay on, and gone + // when they are turned off. + StatsID []byte `json:"statsId,omitempty"` + // StatsPolicy names the consent text the owner read for statistics. + StatsPolicy string `json:"statsPolicy,omitempty"` // Revoked means the owner turned FTW Cloud off and the service has not // yet confirmed the deletion. The key stays until it has. Revoked bool `json:"revoked,omitempty"` diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 7f4f15823..94802e13f 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -14,6 +14,7 @@ package cloudlink import ( + "bytes" "context" "crypto/ecdsa" "crypto/tls" @@ -24,6 +25,7 @@ import ( "fmt" "log/slog" "os" + "slices" "strings" "sync" "time" @@ -55,6 +57,12 @@ var ErrNoService = errors.New("cloudlink: this build has no FTW Cloud service") // deleted at the service. var ErrPurgePending = errors.New("cloudlink: FTW Cloud is still deleting the previous copy") +// ErrNotGranted is statistics asked for while FTW Cloud is off. +var ErrNotGranted = errors.New("cloudlink: turn FTW Cloud on first") + +// statsIDBytes is the length of a statsId, which the protocol fixes at 16. +const statsIDBytes = 16 + // Site is the box's state, read. Every method answers from memory and changes // nothing. Names in the answers are the box's own driver and loadpoint names; // the link replaces each with an opaque id before anything leaves the house. @@ -397,6 +405,85 @@ func (l *Link) Revoke(by string) error { return nil } +// StatsRequest is an owner's request to contribute to statistics. +type StatsRequest struct { + // Policy names the consent text the owner read. + Policy string `json:"policy"` + // By is the device or door that asked, for the audit line. + By string `json:"-"` +} + +// Stats adds the stats purpose to a grant already on. The first time, it +// draws the statsId that names this box's rows at the service; later calls +// keep it. It sends no statistics itself. +func (l *Link) Stats(req StatsRequest) error { + if len(l.opts.ServiceKeys) == 0 { + return ErrNoService + } + if strings.TrimSpace(req.Policy) == "" { + return fmt.Errorf("%w: name the policy the owner accepted", ErrGrantRequest) + } + l.mu.Lock() + defer l.mu.Unlock() + switch { + case l.grant == nil: + return ErrNotGranted + case l.grant.Revoked: + return ErrPurgePending + } + g := *l.grant + if len(g.StatsID) == 0 { + id, err := randomBytes(statsIDBytes) + if err != nil { + return err + } + g.StatsID = id + } + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, true) + g.StatsPolicy, g.By = req.Policy, req.By + return l.storeLocked(g, "statistics on") +} + +// StopStats removes the stats purpose and forgets the statsId. Turning it on +// again draws a new one. +func (l *Link) StopStats(by string) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.grant == nil || l.grant.Revoked || + (len(l.grant.StatsID) == 0 && !slices.Contains(l.grant.Purposes, protocol.CloudPurposeStats)) { + return nil + } + g := *l.grant + g.Purposes = withPurpose(g.Purposes, protocol.CloudPurposeStats, false) + g.StatsID, g.StatsPolicy, g.By = nil, "", by + return l.storeLocked(g, "statistics off") +} + +// storeLocked saves a changed grant, marks the roster due and writes the +// audit line. The caller holds l.mu. +func (l *Link) storeLocked(g grantRecord, event string) error { + if err := saveJSON(l.opts.KV, grantKey, g); err != nil { + return err + } + l.grant = &g + l.rosterDue = true + l.audit(event, &g) + l.signal() + return nil +} + +// withPurpose returns purposes with p present or absent, in registry order. +// It never changes the slice it was given. +func withPurpose(purposes []string, p string, on bool) []string { + out := []string{} + for _, q := range protocol.CloudPurposes { + if (q == p && on) || (q != p && slices.Contains(purposes, q)) { + out = append(out, q) + } + } + return out +} + // finishPurge deletes the key and the grant once the service has confirmed // the deletion. func (l *Link) finishPurge(atMs int64) { @@ -425,7 +512,7 @@ func (l *Link) finishPurge(atMs int64) { 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) + "purposes", g.Purposes, "policy", g.Policy, "stats_policy", g.StatsPolicy) } func (l *Link) notify(kind string) { @@ -487,6 +574,56 @@ func (l *Link) Status() Status { return s } +// Available reports whether this build names a service, and so can run the +// uplink at all. Release builds without service keys cannot. +func (l *Link) Available() bool { return len(l.opts.ServiceKeys) > 0 } + +// SessionInfo is what a phone needs, over its Noise session with the box, to +// read this home from FTW Cloud: the key that signs it, the home id, the boot +// that anchors its times and names for the opaque ids. It is nil unless the +// grant is on. +// +// The tables name every driver and loadpoint the box knows now. One that +// appears later has no name on a phone until its next session. +func (l *Link) SessionInfo() *protocol.CloudSession { + snap := l.current() + if snap.g == nil || snap.g.Revoked || len(snap.pk) != cloudwire.PublicKeyBytes { + return nil + } + gk := snap.g.GrantKey + s := &protocol.CloudSession{ + PK: bytes.Clone(snap.pk), Home: snap.home, Boot: bytes.Clone(l.boot), + Sources: map[string]string{}, Loadpoints: map[string]string{}, + } + source := func(name string) { + if name != "" { + s.Sources[cloudwire.SourceID(gk, name)] = name + } + } + live := l.opts.Site.Live() + for name := range live.Sources { + source(name) + } + for _, names := range live.FieldSources { + for _, name := range names { + source(name) + } + } + for _, name := range live.DispatchBlockedBy { + source(name) + } + if lp, ok := l.opts.Site.Loadpoints(); ok { + for _, p := range lp.Loadpoints { + s.Loadpoints[cloudwire.LoadpointID(gk, p.ID)] = p.ID + source(p.Src) + } + for _, f := range lp.Feedback { + source(f.Src) + } + } + return s +} + func (l *Link) uptimeMs() int64 { return time.Since(l.opts.Started).Milliseconds() } // header stamps the next box body. Seq rises per message, per boot. diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 8a66da293..51bba7d78 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -11,6 +11,7 @@ import ( "errors" "io" "log/slog" + "maps" "net/http" "net/http/httptest" "os" @@ -723,3 +724,227 @@ func TestTheLinkReadsTheBoxNoFasterThanItCanSend(t *testing.T) { t.Fatalf("in 3 s the link read live %d times and loadpoints %d times", live, lp) } } + +// -------------------------------------------------------------------------- +// Session information for phones +// -------------------------------------------------------------------------- + +// A phone learns the box's key, home id, boot and names over Noise. With them +// it verifies what the service forwards, anchors its times and names its +// sources. +func TestSessionInfoLetsAPhoneCheckAndNameWhatTheServiceForwards(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if s := h.link.SessionInfo(); s != nil { + t.Fatalf("session information with FTW Cloud off: %+v", s) + } + h.grant(t) + env := svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + s := h.link.SessionInfo() + if s == nil { + t.Fatal("no session information with FTW Cloud on") + } + if len(s.PK) != cloudwire.PublicKeyBytes || s.Home != cloudwire.HomeID(s.PK) || s.Home != svc.home { + t.Fatalf("key %x and home %q; the service saw home %q", s.PK, s.Home, svc.home) + } + pub, err := cloudwire.ParsePublicKey(s.PK) + if err != nil || cloudwire.Verify(pub, s.Home, env) != nil { + t.Fatalf("a forwarded live does not verify with the session key: %v", err) + } + var live cloudwire.Live + if err := cloudwire.Unmarshal(env.B, &live); err != nil { + t.Fatal(err) + } + if len(s.Boot) != cloudwire.BootBytes || !bytes.Equal(s.Boot, live.Boot) { + t.Fatalf("session boot %x, live boot %x", s.Boot, live.Boot) + } + + gk := h.link.current().g.GrantKey + wantSources := map[string]string{ + cloudwire.SourceID(gk, meterName): meterName, + cloudwire.SourceID(gk, chargerName): chargerName, + } + if !maps.Equal(s.Sources, wantSources) { + t.Fatalf("sources %v, want %v", s.Sources, wantSources) + } + for id := range live.Sources { + if s.Sources[id] == "" { + t.Fatalf("live names source %q, which the session cannot name", id) + } + } + if want := map[string]string{cloudwire.LoadpointID(gk, lpName): lpName}; !maps.Equal(s.Loadpoints, want) { + t.Fatalf("loadpoints %v, want %v", s.Loadpoints, want) + } +} + +// Turning FTW Cloud off withdraws the session information at once, before the +// service confirms the deletion. +func TestSessionInfoGoesWithTheGrant(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + if h.link.SessionInfo() == nil { + t.Fatal("no session information with FTW Cloud on") + } + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + if s := h.link.SessionInfo(); s != nil || h.link.Status().State != "revoking" { + t.Fatalf("while revoking: %+v", s) + } +} + +func TestAvailableFollowsTheServiceKeys(t *testing.T) { + svc := newFakeService(t) + if !newHarness(t, svc).link.Available() { + t.Fatal("a build with a service key says FTW Cloud is unavailable") + } + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) + if err != nil { + t.Fatal(err) + } + if link.Available() { + t.Fatal("a build without a service key says FTW Cloud is available") + } +} + +// -------------------------------------------------------------------------- +// Statistics +// -------------------------------------------------------------------------- + +func (h *harness) record(t *testing.T) grantRecord { + t.Helper() + g, err := loadJSON[grantRecord](h.kv, grantKey) + if err != nil || g == nil { + t.Fatalf("grant record: %v", err) + } + return *g +} + +func (h *harness) nextRoster(t *testing.T) cloudwire.Roster { + t.Helper() + var r cloudwire.Roster + if err := cloudwire.Unmarshal(h.svc.waitFor(cloudwire.MsgRoster, 3*time.Second).B, &r); err != nil { + t.Fatal(err) + } + return r +} + +// Statistics are a second consent under a grant already on. The box draws a +// statsId once, keeps it while statistics stay on, tells the service through +// the roster and sends no statistics yet. +func TestStatisticsAreASecondConsentUnderTheGrant(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10"}); !errors.Is(err, ErrNotGranted) { + t.Fatalf("statistics with FTW Cloud off: %v", err) + } + h.grant(t) + first := h.nextRoster(t) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + if err := h.link.Stats(StatsRequest{}); !errors.Is(err, ErrGrantRequest) { + t.Fatalf("statistics without a policy: %v", err) + } + + if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10", By: "app:aaaa1111"}); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if strings.Join(r.Grant.Purposes, ",") != "serve,stats" || r.RosterSeq <= first.RosterSeq { + t.Fatalf("roster after statistics on: purposes %v, seq %d after %d", r.Grant.Purposes, r.RosterSeq, first.RosterSeq) + } + if st := h.link.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("status purposes %v", st.Purposes) + } + g := h.record(t) + if len(g.StatsID) != statsIDBytes || g.StatsPolicy != "stats-2026-10" || g.Policy != "2026-10" { + t.Fatalf("grant record %+v", g) + } + + // Asked again, the same id. + if err := h.link.Stats(StatsRequest{Policy: "stats-2026-11"}); err != nil { + t.Fatal(err) + } + if again := h.record(t); !bytes.Equal(again.StatsID, g.StatsID) || again.StatsPolicy != "stats-2026-11" { + t.Fatalf("statsId %x became %x", g.StatsID, again.StatsID) + } + + // A restart reads it back. + reopened, err := New(Options{Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, + ServiceKeys: h.link.opts.ServiceKeys}) + if err != nil { + t.Fatal(err) + } + if st := reopened.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { + t.Fatalf("after a restart: %+v", st) + } + + for _, env := range svc.received() { + if env.T == "stats" { + t.Fatal("the box sent statistics") + } + } +} + +// Turning statistics off forgets the statsId; turning them on again draws a +// new one, because the service refuses an id it has deleted. +func TestStoppingStatisticsForgetsTheID(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if err := h.link.StopStats("lan"); err != nil { + t.Fatalf("statistics off with FTW Cloud off: %v", err) + } + h.grant(t) + h.nextRoster(t) + if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { + t.Fatal(err) + } + h.nextRoster(t) + old := h.record(t).StatsID + + if err := h.link.StopStats("lan"); err != nil { + t.Fatal(err) + } + r := h.nextRoster(t) + if strings.Join(r.Grant.Purposes, ",") != "serve" { + t.Fatalf("roster after statistics off: %v", r.Grant.Purposes) + } + if g := h.record(t); len(g.StatsID) != 0 || g.StatsPolicy != "" || strings.Join(g.Purposes, ",") != "serve" { + t.Fatalf("grant record after statistics off: %+v", g) + } + if err := h.link.StopStats("lan"); err != nil { + t.Fatalf("statistics off twice: %v", err) + } + + if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { + t.Fatal(err) + } + if renewed := h.record(t).StatsID; len(renewed) != statsIDBytes || bytes.Equal(renewed, old) { + t.Fatalf("statsId %x after %x", renewed, old) + } +} + +func TestNoStatisticsWhileTheCopyIsBeingDeleted(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + if err := h.link.Revoke("test"); err != nil { + t.Fatal(err) + } + if err := h.link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrPurgePending) { + t.Fatalf("statistics while revoking: %v", err) + } +} + +func TestABuildWithoutAServiceRefusesStatistics(t *testing.T) { + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) + if err != nil { + t.Fatal(err) + } + if err := link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrNoService) { + t.Fatalf("statistics: %v", err) + } +} diff --git a/go/protocol/messages.go b/go/protocol/messages.go index f296c6e0e..7876a89aa 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -136,6 +136,30 @@ type HelloOK struct { // Subscribed says the optional subscription carried by Hello was accepted. // False also covers an older box, where this additive field is absent. Subscribed bool `cbor:"subscribed,omitempty"` + // Cloud is what the phone needs to read this home from FTW Cloud. Every + // role gets it, and only while an owner has FTW Cloud on. Absent means + // off, or an older box. + Cloud *CloudSession `cbor:"cloud,omitempty"` +} + +// CloudSession lets a phone check and read what FTW Cloud serves it. It comes +// from the box over Noise and never from the service, so the service cannot +// sign for the box, swap its key or rename a source. +type CloudSession struct { + // PK is the box's FTW Cloud public key, the 65-byte uncompressed P-256 + // point. Every box envelope from the service verifies with it. + PK []byte `cbor:"pk"` + // Home is the home id at the service, cut from PK. + Home string `cbor:"home"` + // Boot is the uplink's current 16-byte boot id. With hello_ok's + // clock.uptimeMs it anchors the times in box envelopes: both count from + // the same process start. + Boot []byte `cbor:"boot"` + // Sources maps each opaque source id to its source id in this protocol, + // the driver name. + Sources map[string]string `cbor:"sources"` + // Loadpoints maps each opaque loadpoint id to the loadpoint id. + Loadpoints map[string]string `cbor:"loadpoints"` } // HintAppUpdate tells the app it is behind. It is a hint, not an error: the From c6301a0bb1f79dbee2480817565ef5a817e863e8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:55:40 +0200 Subject: [PATCH 12/15] fix(cloudwire): a dropped message leaves the phone's view as it was View.Accept reset the view for a new boot, and turned rule N off, before it had decoded the body and checked seq, so a signed message it then dropped could still wipe what the phone held. It now checks everything first. An unknown source state ranked as live when it was the second argument of worse; it ranks as never on either side. Found by the Swift port. Adds the doc's case 11 and a changeset for C3. Co-Authored-By: Claude Opus 5.5 --- .changeset/ftw-cloud-app-session.md | 5 ++ go/protocol/cloudwire/view.go | 83 +++++++++++++++++------------ go/protocol/cloudwire/view_test.go | 59 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 33 deletions(-) create mode 100644 .changeset/ftw-cloud-app-session.md diff --git a/.changeset/ftw-cloud-app-session.md b/.changeset/ftw-cloud-app-session.md new file mode 100644 index 000000000..aec2119bc --- /dev/null +++ b/.changeset/ftw-cloud-app-session.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Let phones read their home from FTW Cloud. While FTW Cloud is on, the box tells every paired phone, over its own encrypted session, the key it signs with and the names behind its anonymous ids, so a phone can check what the service forwards. Boxes that can run the uplink say so to the app. `POST` and `DELETE /api/cloud/stats` record and withdraw an owner's consent to statistics; nothing is sent for it yet. diff --git a/go/protocol/cloudwire/view.go b/go/protocol/cloudwire/view.go index 9df86a017..218a4c101 100644 --- a/go/protocol/cloudwire/view.go +++ b/go/protocol/cloudwire/view.go @@ -105,54 +105,67 @@ func (v *View) Accept(e Envelope, arrivalMs int64) error { return err } - if !bytes.Equal(h.Boot, v.boot) { + // Decode the class body before anything changes, so a message that is + // dropped leaves the view as it was. + var ( + live *Live + lp *LP + hb *HB + ) + switch e.T { + case MsgLive: + live = new(Live) + if err := Unmarshal(e.B, live); err != nil { + return err + } + case MsgLP: + lp = new(LP) + if err := Unmarshal(e.B, lp); err != nil { + return err + } + case MsgHB: + hb = new(HB) + if err := Unmarshal(e.B, hb); err != nil { + return err + } + } + + lead := h.WallMs != nil && *h.WallMs > arrivalMs+RuleNLeadMs + newBoot := !bytes.Equal(h.Boot, v.boot) + if newBoot { // Another boot replaces this one only with an anchored message sent // after the newest one held, so a replay of an older boot cannot // wipe a newer one. - 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) { + if (v.boot != nil && (!ok || send <= v.newest)) || h.Seq == 0 { return ErrNotNewer } v.reset(h.Boot) - } - if h.Seq <= v.seq[e.T] { + } else if h.Seq <= v.seq[e.T] { return ErrNotNewer } - if h.WallMs != nil && *h.WallMs > arrivalMs+RuleNLeadMs { + if lead { 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 + switch { + case live != nil: + v.live = live + if live.Sources != nil { + v.table, v.tableRev, v.fieldSrc = live.Sources, live.SrcRev, live.FieldSources } if anchored { v.setM(&anchor{seq: h.Seq, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, - srcRev: 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 + srcRev: live.SrcRev, liveSeq: h.Seq, ages: live.Ages}) } + case lp != nil: + v.lp = lp + case hb != nil: if anchored { v.setM(&anchor{seq: h.Seq, hb: true, boot: h.Boot, wallSend: ws, uptimeMs: h.UptimeMs, - srcRev: b.SrcRev, liveSeq: b.LiveSeq, lpSeq: b.LpSeq, ages: b.Ages}) + srcRev: hb.SrcRev, liveSeq: hb.LiveSeq, lpSeq: hb.LpSeq, ages: hb.Ages}) } } v.seq[e.T] = h.Seq @@ -242,7 +255,7 @@ func (v *View) Grade(nowMs int64) Grades { tableBehind := g.Anchored && v.m.srcRev > v.tableRev for id, s := range v.table { - state := s.State + state := worse(s.State, protocol.SourceLive) switch { case !g.Anchored || tableBehind: state = worse(state, protocol.SourceStale) @@ -335,12 +348,16 @@ var stateRank = map[protocol.SourceState]int{ protocol.SourceDown: 3, protocol.SourceNever: 4, } +// worse is the worse of two states. A state this phone does not know ranks +// as never, whichever side it is on, so it can never read as live. func worse(a, b protocol.SourceState) protocol.SourceState { - ra, ok := stateRank[a] - if !ok { - return protocol.SourceNever + if _, ok := stateRank[a]; !ok { + a = protocol.SourceNever + } + if _, ok := stateRank[b]; !ok { + b = protocol.SourceNever } - if stateRank[b] > ra { + if stateRank[b] > stateRank[a] { return b } return a diff --git a/go/protocol/cloudwire/view_test.go b/go/protocol/cloudwire/view_test.go index 06ce85158..4495cb54f 100644 --- a/go/protocol/cloudwire/view_test.go +++ b/go/protocol/cloudwire/view_test.go @@ -312,3 +312,62 @@ func TestHeartbeatIsClamped(t *testing.T) { } } } + +// 11. The byte budget runs out: the box sends no live and no hb, and the +// data ages exactly as a dead uplink's does. +func TestFreshnessBudgetSpentAgesOnTime(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.fullLive(t0, 2_000, protocol.SourceLive, 1), t0+transit) + // Nothing more arrives; only events would. + wantSource(t, v, t0+8_000, protocol.SourceLive) + wantSource(t, v, t0+8_001, protocol.SourceLagging) + wantSource(t, v, t0+58_001, protocol.SourceDown) +} + +// A message the phone drops changes nothing, even one from a later boot that +// would otherwise have replaced the one held. +func TestADroppedMessageLeavesTheViewAsItWas(t *testing.T) { + x := newFakeBox(t, 1) + v := x.view() + t0 := int64(2_000_000) + accept(t, v, x.fullLive(t0, 1_000, protocol.SourceLive, 1), t0+transit) + before := v.Grade(t0 + 1_000) + + y := newFakeBox(t, 2) + y.start = t0 + broken := y.seal(MsgLive, map[string]any{"seq": 1, "boot": y.boot, "uptimeMs": 5_000, + "wallMs": t0 + 5_000, "fields": "not a map"}) + if err := v.Accept(broken, t0+5_100); err == nil { + t.Fatal("an undecodable live was accepted") + } + zero := y.seal(MsgHB, HB{Header: Header{Seq: 0, Boot: y.boot, UptimeMs: 5_000, WallMs: &[]int64{t0 + 5_000}[0]}}) + if err := v.Accept(zero, t0+5_100); !errors.Is(err, ErrNotNewer) { + t.Fatalf("seq 0 from a new boot: %v", err) + } + ahead := x.seal(MsgHB, HB{Header: Header{Seq: 0, Boot: x.boot, UptimeMs: 5_000, WallMs: &[]int64{t0 + 60_000}[0]}}) + _ = v.Accept(ahead, t0+5_100) // dropped, and must not turn rule N off + if got := v.Grade(t0 + 1_000); got.Anchored != before.Anchored || got.Sources["m"] != before.Sources["m"] || + got.Fields["2"] != before.Fields["2"] { + t.Fatalf("a dropped message changed the view: %+v, was %+v", got, before) + } +} + +// A state this phone does not know ranks as never: neither the source nor +// a field that follows it can read as live. +func TestAnUnknownStateIsNeverLive(t *testing.T) { + box := newFakeBox(t, 1) + v := box.view() + t0 := int64(2_000_000) + accept(t, v, box.seal(MsgLive, Live{ + Header: box.header(t0), Fields: map[string]int64{"2": 1000}, + FieldSources: map[string][]string{"2": {"m"}}, + Sources: map[string]Source{"m": {Kind: "driver", State: "dormant", StaleAfterMs: 10_000}}, + SrcRev: 1, Ages: map[string]int64{}, + }), t0+transit) + g := v.Grade(t0 + 200) + if g.Sources["m"] != protocol.SourceNever || g.Fields["2"] != protocol.SourceNever { + t.Fatalf("an unknown state graded as %s, field %s", g.Sources["m"], g.Fields["2"]) + } +} From 19ea338cd3ec9bc2458722cd2744543fd7b023a7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:05:17 +0200 Subject: [PATCH 13/15] feat(cloud): send the box's notifications to FTW Cloud as signed events K5 and the Core half of FTW Cloud C4. - A catalogue message carries its kind, its arguments, the device it concerns and when it happened (dispatchCatalogue set no kind before). - Every transport publishes side by side under its own 10 s limit, so a gateway that hangs costs only itself. - notifications.CloudPublisher only queues. cloudlink.QueueEvent keeps up to 32 events; the writer signs each as an event with eventId, ts and seq, cuts opaque ids for the driver and loadpoint, and drops name arguments. An event older than ten minutes is dropped and counted in GET /api/cloud (eventsDropped). The grant's events scope gates it all. - Link.Bye sends bye{why:"update"} for a restart the box chose, and bye{why:"shutdown"} on SIGTERM, then stops dialling, so FTW Cloud holds back box.unreachable through an update. - charging.goal_at_risk: once per plug-in, from a fresh plan's shortfall (at least 1 kWh), only on a level that is not assumed and a ready time still ahead. Opt-in like the other charging rules. - charging.session_complete for a car that reports nothing: when the box's estimate from a level someone set reaches the target, in kWh, once, and not after a restart beside a car that is already done. - charging.connected waits 90 s and goes only when the level is assumed or no goal is set. Co-Authored-By: Claude Opus 5.5 --- .changeset/ftw-cloud-events.md | 5 + go/cmd/ftw/cloud_link.go | 14 ++ go/cmd/ftw/main.go | 28 +++ go/internal/cloudlink/conn.go | 2 +- go/internal/cloudlink/events.go | 196 ++++++++++++++++++ go/internal/cloudlink/events_test.go | 158 ++++++++++++++ go/internal/cloudlink/link.go | 26 ++- go/internal/cloudlink/sender.go | 12 +- go/internal/events/bus.go | 24 ++- go/internal/loadpoint/goal_risk.go | 59 ++++++ go/internal/loadpoint/goal_risk_test.go | 136 ++++++++++++ go/internal/loadpoint/loadpoint.go | 22 ++ go/internal/notifications/catalogue.go | 1 + go/internal/notifications/charging.go | 51 +++++ go/internal/notifications/cloud.go | 22 ++ .../notifications/cloud_events_test.go | 150 ++++++++++++++ go/internal/notifications/service.go | 98 ++++++--- 17 files changed, 964 insertions(+), 40 deletions(-) create mode 100644 .changeset/ftw-cloud-events.md create mode 100644 go/internal/cloudlink/events.go create mode 100644 go/internal/cloudlink/events_test.go create mode 100644 go/internal/loadpoint/goal_risk.go create mode 100644 go/internal/loadpoint/goal_risk_test.go create mode 100644 go/internal/notifications/charging.go create mode 100644 go/internal/notifications/cloud_events_test.go diff --git a/.changeset/ftw-cloud-events.md b/.changeset/ftw-cloud-events.md new file mode 100644 index 000000000..250b8348c --- /dev/null +++ b/.changeset/ftw-cloud-events.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +With FTW Cloud on and events in the grant, the box sends its notifications as signed events for the owners' phones; names never leave the house. A restart the box chooses, an update among them, tells FTW Cloud so it holds back "box out of reach". A new notification, "The car may not be ready", tells once per plug-in when the box knows the car's level and the plan cannot reach the goal by the ready time. A car that reports nothing itself gets "Car charged" in kWh once the box's estimate from a level someone set reaches the target. "Car plugged in" now waits 90 seconds and is sent only when the box needs the level or a goal. Each notification transport now has its own 10-second limit, so one that hangs no longer delays the others. diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 92120aab9..065969b23 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -337,6 +337,20 @@ func startCloudLink( return link } +// cloudByeWait bounds how long shutdown waits for the bye to leave. +const cloudByeWait = 2 * time.Second + +// cloudEventQueue turns a rendered notification into an event for the +// uplink, which cuts the names out before it signs. +func cloudEventQueue(link *cloudlink.Link) func(notifications.Message) bool { + return func(m notifications.Message) bool { + return link.QueueEvent(cloudlink.EventIn{ + Kind: m.Kind, Priority: m.Priority, Args: m.Args, + Driver: m.Device, Loadpoint: m.LoadpointID, At: m.At, + }) + } +} + // cloudForAPI hands the link to the API, or nothing: a typed nil in an // interface would pass the routes' nil check and panic on first use. func cloudForAPI(link *cloudlink.Link) api.CloudGrant { diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 95a9686cd..cb9e61603 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -68,6 +68,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -2657,6 +2658,20 @@ func main() { notifSvc.AddPublisher(webPush) deps.WebPush = webPush } + // FTW Cloud carries the catalogue's events to the owners' phones, + // signed, when the grant includes them. The publisher only queues. + if cloudLink != nil { + notifSvc.AddPublisher(notifications.CloudPublisher{Queue: cloudEventQueue(cloudLink)}) + } + notifSvc.SetChargingFacts(func(id string) (notifications.ChargingFacts, bool) { + lp, ok := lpMgr.State(id) + if !ok { + return notifications.ChargingFacts{}, false + } + return notifications.ChargingFacts{ + Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0, + }, true + }) if appUplink != nil { deps.PushResync = appUplink.ResyncDeadman } @@ -2773,6 +2788,7 @@ func main() { select { case <-sigc: slog.Info("shutting down") + cloudLink.Bye(cloudwire.ByeShutdown, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("shutdown"); err != nil { @@ -2785,6 +2801,9 @@ func main() { } else { slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") } + // A restart the box chose, an update among them: FTW Cloud holds + // back "box out of reach" until the box is back. + cloudLink.Bye(cloudwire.ByeUpdate, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("restart"); err != nil { @@ -3195,6 +3214,15 @@ func main() { } } + // A car whose plan cannot make its goal: the loadpoint manager + // tells the household once per plug-in, from a plan that is + // neither being remade nor out of date. + if mpcSvc != nil { + if snap := mpcSvc.PlanSnapshot(); snap.Plan != nil && !snap.Pending && !snap.Outdated { + lpMgr.ObservePlanShortfall(snap.Plan.LoadpointShortfallWh, tickNow) + } + } + // First-vehicle-SoC replan trigger: as soon as any // DerVehicle driver is online and reporting SoC, redo the // plan once with measured-truth instead of the pluginSoC diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 0d5bf20fe..4559c59a6 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -69,7 +69,7 @@ func (l *Link) once(ctx context.Context, snap grantSnapshot) (time.Duration, err l.attempt = 0 } l.mu.Unlock() - if errors.Is(err, errPurged) { + if errors.Is(err, errPurged) || errors.Is(err, errClosed) { return 0, nil } return l.backoff(), err diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go new file mode 100644 index 000000000..3ad8cce27 --- /dev/null +++ b/go/internal/cloudlink/events.go @@ -0,0 +1,196 @@ +package cloudlink + +import ( + "encoding/base64" + "errors" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Events and the last word. +// +// The box's notifications reach FTW Cloud as signed events, which the +// service wraps unchanged in an alert for the owners' phones. QueueEvent only +// queues; the writer signs and sends on its own goroutine, ahead of anything +// else due on that tick. An event waits at most cloudwire.AlertMaxAgeMs for +// the uplink: older, a phone would show it without its text and buttons, so +// it is dropped instead. A reconnect sends the events still waiting and +// replays none that left. + +// EventIn is one notification the box raised, before ids are cut. +type EventIn struct { + Kind string + // Priority is the rule's, 1 to 5. It becomes the event's severity. + Priority int + // Args are the catalogue's placeholders. Names are never sent: the + // link drops the name placeholders and sends the device's opaque id. + Args map[string]string + // Driver and Loadpoint are the box's own names for what the event + // concerns, empty when it concerns neither. + Driver string + Loadpoint string + At time.Time +} + +const ( + // eventRing is how many events wait for the uplink. When it is full + // the oldest goes, and the drop is counted. + eventRing = 32 + // eventIDBytes makes a 12-character event id. + eventIDBytes = 9 +) + +// nameArgs are catalogue placeholders that hold a name. The phone fills +// them from the table it holds from the box. +var nameArgs = map[string]bool{"name": true} + +// errClosed ends the uplink after its bye, as the box shuts down. +var errClosed = errors.New("cloudlink: the box is shutting down") + +// QueueEvent keeps an event for the uplink to sign and send. It never +// blocks. False means FTW Cloud will not carry it: off, being revoked, or a +// grant that leaves events out. +func (l *Link) QueueEvent(e EventIn) bool { + l.mu.Lock() + g := l.grant + if g == nil || g.Revoked || !g.has(protocol.ScopeEventsRead) || l.closing { + l.mu.Unlock() + return false + } + if e.At.IsZero() { + e.At = time.Now() + } + if len(l.events) >= eventRing { + l.events = l.events[1:] + l.eventsDropped++ + } + l.events = append(l.events, e) + l.mu.Unlock() + l.signal() + return true +} + +// sendEvents signs and sends what waits, oldest first. +func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { + l := w.link + if !snap.allowed(cloudwire.MsgEvent) { + return nil + } + l.mu.Lock() + queued := l.events + l.events = nil + l.mu.Unlock() + for i, e := range queued { + if now.Sub(e.At) > cloudwire.AlertMaxAgeMs*time.Millisecond { + l.mu.Lock() + l.eventsDropped++ + l.mu.Unlock() + continue + } + ev, err := l.projectEvent(snap, e) + if err != nil { + return err + } + if _, err := w.sendBody(snap, cloudwire.MsgEvent, &ev, true); err != nil { + // The one that failed may have left; the rest have not. + l.mu.Lock() + l.events = append(queued[i+1:len(queued):len(queued)], l.events...) + l.mu.Unlock() + return err + } + } + return nil +} + +// projectEvent cuts opaque ids and drops names. +func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { + id, err := randomBytes(eventIDBytes) + if err != nil { + return cloudwire.Event{}, err + } + gk := snap.g.GrantKey + ev := cloudwire.Event{ + EventID: base64.RawURLEncoding.EncodeToString(id), Kind: e.Kind, + Severity: severity(e.Priority), Args: map[string]string{}, + } + for k, v := range e.Args { + if !nameArgs[k] { + ev.Args[k] = v + } + } + if e.Driver != "" { + ev.Src = cloudwire.SourceID(gk, e.Driver) + } + if e.Loadpoint != "" { + ev.LP = cloudwire.LoadpointID(gk, e.Loadpoint) + } + if l.clockSynced() { + ts := e.At.UnixMilli() + ev.Ts = &ts + } + return ev, nil +} + +func severity(priority int) string { + switch { + case priority >= 5: + return cloudwire.SeverityAlarm + case priority == 4: + return cloudwire.SeverityWarning + default: + return cloudwire.SeverityInfo + } +} + +// Bye tells the service why the box is going and stops the uplink for good. +// why is cloudwire.ByeUpdate for a restart the box chose, such as an update, +// which holds back box.unreachable for an hour; ByeShutdown otherwise. It +// waits at most wait for the bye to leave, and returns at once when the +// uplink is down. +func (l *Link) Bye(why string, wait time.Duration) { + if l == nil { + return + } + l.mu.Lock() + if l.closing { + l.mu.Unlock() + return + } + l.closing, l.byeWhy = true, why + done := make(chan struct{}) + l.byeDone = done + connected := l.connected + l.mu.Unlock() + l.signal() + if !connected { + return + } + select { + case <-done: + case <-time.After(wait): + } +} + +// closeIfAsked sends the bye Bye asked for and ends the connection. +func (w *writer) closeIfAsked(snap grantSnapshot) error { + l := w.link + l.mu.Lock() + closing, why, done := l.closing, l.byeWhy, l.byeDone + l.mu.Unlock() + if !closing { + return nil + } + err := w.bye(snap, why, false) + l.mu.Lock() + l.byeDone = nil + l.mu.Unlock() + if done != nil { + close(done) + } + if err != nil { + return err + } + return errClosed +} diff --git a/go/internal/cloudlink/events_test.go b/go/internal/cloudlink/events_test.go new file mode 100644 index 000000000..a91f36b45 --- /dev/null +++ b/go/internal/cloudlink/events_test.go @@ -0,0 +1,158 @@ +package cloudlink + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// An event leaves signed, numbered and timed, with opaque ids for what it +// concerns and no name in it anywhere. +func TestAnEventLeavesSignedWithoutNames(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + at := time.Now().Add(-3 * time.Second) + if !h.link.QueueEvent(EventIn{Kind: protocol.PushDriverOffline, Priority: 4, + Args: map[string]string{"name": chargerName}, Driver: chargerName, At: at}) { + t.Fatal("a grant with events refused an event") + } + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted, Priority: 4, Loadpoint: lpName}) { + t.Fatal("a grant with events refused an event") + } + first := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + second := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + + pub, err := cloudwire.ParsePublicKey(h.link.current().pk) + if err != nil { + t.Fatal(err) + } + var a, b cloudwire.Event + for _, x := range []struct { + env cloudwire.Envelope + out *cloudwire.Event + }{{first, &a}, {second, &b}} { + if err := cloudwire.Verify(pub, svc.home, x.env); err != nil { + t.Fatalf("event not signed by the box: %v", err) + } + if err := cloudwire.Unmarshal(x.env.B, x.out); err != nil { + t.Fatal(err) + } + } + gk := h.link.current().g.GrantKey + if a.Kind != protocol.PushDriverOffline || a.Severity != cloudwire.SeverityWarning || + a.Src != cloudwire.SourceID(gk, chargerName) || len(a.Args) != 0 || a.LP != "" { + t.Fatalf("driver.offline went as %+v", a) + } + if a.Ts == nil || *a.Ts != at.UnixMilli() || a.WallMs == nil || len(a.EventID) != 12 { + t.Fatalf("driver.offline has no time or id: %+v", a) + } + if b.Kind != protocol.PushChargingInterrupted || b.LP != cloudwire.LoadpointID(gk, lpName) || + b.Seq <= a.Seq || b.EventID == a.EventID { + t.Fatalf("charging.interrupted went as %+v after %+v", b, a) + } + for _, raw := range [][]byte{first.B, second.B} { + if bytes.Contains(raw, []byte(chargerName)) || bytes.Contains(raw, []byte(lpName)) { + t.Fatalf("a name left the house in %x", raw) + } + } +} + +// Events are their own scope: a grant without it sends none. +func TestAGrantWithoutEventsQueuesNothing(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued with FTW Cloud off") + } + h.grant(t, protocol.ScopeLiveRead) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued under a grant that leaves events out") + } +} + +// An event that waited longer than a phone would act on it is dropped and +// counted, not sent late. +func TestAnEventTooOldForThePhoneIsDropped(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected, At: time.Now().Add(-11 * time.Minute)}) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted}) + env := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + var e cloudwire.Event + if err := cloudwire.Unmarshal(env.B, &e); err != nil { + t.Fatal(err) + } + if e.Kind != protocol.PushChargingInterrupted { + t.Fatalf("sent %s, an event eleven minutes old", e.Kind) + } + if st := h.link.Status(); st.EventsDropped != 1 { + t.Fatalf("dropped %d", st.EventsDropped) + } +} + +// The queue holds the newest events and counts what it pushed out. Here the +// service fails the binding, so nothing drains the queue. +func TestTheEventQueueKeepsTheNewest(t *testing.T) { + svc := newFakeService(t) + svc.badEKM = true + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + for range eventRing + 3 { + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("refused") + } + } + h.link.mu.Lock() + n := len(h.link.events) + h.link.mu.Unlock() + if st := h.link.Status(); n != eventRing || st.EventsDropped != 3 { + t.Fatalf("queue %d, dropped %d", n, st.EventsDropped) + } +} + +// A restart the box chose says so, and the link stops dialling. +func TestByeForAnUpdateLeavesAndTheLinkStops(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + for !h.link.Status().Connected { + time.Sleep(10 * time.Millisecond) + } + + h.link.Bye(cloudwire.ByeUpdate, 2*time.Second) + env := svc.waitFor(cloudwire.MsgBye, time.Second) + var b cloudwire.Bye + if err := cloudwire.Unmarshal(env.B, &b); err != nil { + t.Fatal(err) + } + if b.Why != cloudwire.ByeUpdate || b.Purge { + t.Fatalf("bye %+v", b) + } + select { + case err := <-h.done: + if err != nil { + t.Fatalf("Run ended with %v", err) + } + h.done <- nil // for the cleanup + case <-time.After(3 * time.Second): + t.Fatal("the link kept running after its bye") + } + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued after the bye") + } + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 1 { + t.Fatalf("the box dialled %d times", conns) + } +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 7f4f15823..3b15fe923 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -160,6 +160,15 @@ type Link struct { attempt int purgedAt int64 rosterDue bool + + // events wait for the writer; eventsDropped counts those that never + // left. closing is set by Bye, after which the link does not dial + // again. + events []EventIn + eventsDropped int64 + closing bool + byeWhy string + byeDone chan struct{} } // New loads the grant and the key and returns a link that runs once Run is @@ -461,6 +470,10 @@ type Status struct { BudgetReached bool `json:"budgetReached"` // PurgedAtMs is the time on the service's receipt for the last deletion. PurgedAtMs int64 `json:"purgedAtMs,omitempty"` + // EventsDropped counts notifications that never reached the service: + // the queue was full, or the uplink was down for longer than a phone + // would still act on them. + EventsDropped int64 `json:"eventsDropped"` } // Status reports the grant and the link. @@ -470,7 +483,7 @@ func (l *Link) Status() Status { 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, + LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, BytesLast24h: l.budget.total(l.uptimeMs()), BudgetBytes: budgetBytes, } s.BudgetReached = s.BytesLast24h >= budgetBytes @@ -517,6 +530,9 @@ func (l *Link) current() grantSnapshot { // Run keeps the uplink up while a grant is on, until ctx ends. func (l *Link) Run(ctx context.Context) error { for { + if l.isClosing() { + return nil + } snap := l.current() if snap.g == nil || len(l.opts.ServiceKeys) == 0 { select { @@ -543,6 +559,12 @@ func (l *Link) Run(ctx context.Context) error { } } +func (l *Link) isClosing() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.closing +} + // allowed reports whether the grant lets a class leave the house. func (snap grantSnapshot) allowed(class string) bool { scope, forwarded := cloudwire.ClassScope[class] @@ -555,7 +577,7 @@ func (snap grantSnapshot) allowed(class string) bool { // 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} { + for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { if snap.allowed(c) { out = append(out, c) } diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 04d528a70..499e735ad 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -120,8 +120,16 @@ func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) erro } 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 + } else { + if err := w.closeIfAsked(snap); err != nil { + return err + } + if err := w.sendEvents(snap, now); err != nil { + return err + } + if err := w.tick(snap, st, now); err != nil { + return err + } } select { diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index 27072c483..fceaad484 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -71,6 +71,7 @@ const ( KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" + KindChargingGoalAtRisk = "charging.goal_at_risk" ) // HealthTick is fired every control-loop tick with the current telemetry @@ -156,9 +157,11 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted once per session when a fresh, -// matched vehicle BMS reading confirms that the active target was reached. -// A charger refusing current does not establish battery state. +// ChargingSessionComplete is emitted once per session when the active target +// was reached: by a fresh, matched vehicle BMS reading, or, for a car that +// reports nothing itself, by the box's estimate from a level someone set. +// The estimate is energy the box measured, so subscribers say it in kWh. A +// charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered @@ -185,3 +188,18 @@ type ChargingConnected struct { } func (ChargingConnected) Kind() string { return KindChargingConnected } + +// ChargingGoalAtRisk is emitted once per plug-in when the plan cannot bring +// the car to its target by the ready time, and the box knows the car's +// level rather than assuming it. +type ChargingGoalAtRisk struct { + LoadpointID string + TargetSoC float64 // fraction + By time.Time + // ByClock is By as a household reads it, "07:00" in the box's zone. + ByClock string + ShortfallWh float64 + At time.Time +} + +func (ChargingGoalAtRisk) Kind() string { return KindChargingGoalAtRisk } diff --git a/go/internal/loadpoint/goal_risk.go b/go/internal/loadpoint/goal_risk.go new file mode 100644 index 000000000..af76420cc --- /dev/null +++ b/go/internal/loadpoint/goal_risk.go @@ -0,0 +1,59 @@ +package loadpoint + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +const ( + // GoalRiskMinWh is the smallest shortfall worth telling anyone about. + // Below it the plan is close enough that a rounding of the estimate or + // a slightly cheaper slot changes the answer. + GoalRiskMinWh = 1000 + // minCompletionWh keeps "car charged" for sessions that charged. + minCompletionWh = 100 +) + +// ObservePlanShortfall takes a fresh plan's shortfall per loadpoint, in +// battery watt-hours, and raises charging.goal_at_risk once per plug-in for +// a car that will not reach its target by its ready time. Only when the box +// knows the car's level, from the car or from someone who set it: on an +// assumed level the shortfall is a guess, and the plug-in notification +// already asks for the level. The caller passes only plans that are neither +// being remade nor out of date. +func (m *Manager) ObservePlanShortfall(shortfallWh map[string]float64, now time.Time) { + if m == nil || len(shortfallWh) == 0 { + return + } + var fired []events.Event + m.mu.Lock() + bus := m.bus + for _, id := range m.order { + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn || lp.goalRiskNotified || !lp.socConfirmed || lp.completionNotified || + lp.targetSoC <= 0 || lp.targetTime.IsZero() || !lp.targetTime.After(now) { + continue + } + wh := shortfallWh[id] + if wh < GoalRiskMinWh { + continue + } + lp.goalRiskNotified = true + by := lp.targetTime.Local() + if m.loc != nil { + by = lp.targetTime.In(m.loc) + } + fired = append(fired, events.ChargingGoalAtRisk{ + LoadpointID: id, TargetSoC: lp.targetSoC, By: lp.targetTime, ByClock: by.Format("15:04"), + ShortfallWh: wh, At: now, + }) + } + m.mu.Unlock() + if bus == nil { + return + } + for _, e := range fired { + bus.Publish(e) + } +} diff --git a/go/internal/loadpoint/goal_risk_test.go b/go/internal/loadpoint/goal_risk_test.go new file mode 100644 index 000000000..6a99541f0 --- /dev/null +++ b/go/internal/loadpoint/goal_risk_test.go @@ -0,0 +1,136 @@ +package loadpoint + +import ( + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +func goalRiskLog(bus *events.Bus) func() []events.ChargingGoalAtRisk { + var mu sync.Mutex + var got []events.ChargingGoalAtRisk + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + mu.Lock() + got = append(got, ev) + mu.Unlock() + } + }) + return func() []events.ChargingGoalAtRisk { + mu.Lock() + defer mu.Unlock() + return append([]events.ChargingGoalAtRisk(nil), got...) + } +} + +// A car that reports nothing is done when the box's estimate, from a level +// someone set, reaches the target. Once, in kWh. +func TestACarWithoutItsOwnDataIsDoneAtTheBoxsEstimate(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.SetCurrentSoC("garage", 0.5) + r.tick(time.Hour, true, 11_000, 10_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("done before the estimate reached the target") + } + need := 0.3 * 60_000 / DefaultChargeEfficiency + r.tick(time.Hour, true, 11_000, need+50, true) + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d, want 1", c) + } + if got := r.log.complete[0]; got.KWh < need/1000 || got.LoadpointID != "garage" { + t.Fatalf("event %+v", got) + } + for range 5 { + r.tick(10*time.Minute, true, 0, need+50, false) + } + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d after hours, want 1", c) + } +} + +// On an assumed level the estimate is a guess, so nothing claims the car is +// done. +func TestAnAssumedLevelNeverSaysDone(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.tick(5*time.Hour, true, 11_000, 60_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d on an assumed level", c) + } +} + +// A car that reports its own level is done by its own reading, not by the +// box's estimate. +func TestACarWithItsOwnDataIsDoneOnlyByItsReading(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.AnchorVehicleSoC("garage", 0.5) + r.tick(5*time.Hour, true, 11_000, 30_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d from an estimate for a car that reports", c) + } +} + +// After a restart beside a car that is already done, nothing is said: it +// has not drawn power since the box started. +func TestARestartBesideAFinishedCarSaysNothing(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 0, 25_000, false) + r.mgr.SetCurrentSoC("garage", 0.85) + for range 5 { + r.tick(time.Minute, true, 0, 25_000, false) + } + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d after a restart", c) + } +} + +// The plan's shortfall tells the household once per plug-in, and only when +// the level is known, the goal has a time ahead, and the gap matters. +func TestGoalAtRiskOncePerPlugInOnAKnownLevel(t *testing.T) { + r := newSessionRig(t) + got := goalRiskLog(r.mgr.bus) + by := r.now.Add(8 * time.Hour) + r.mgr.SetTarget("garage", 0.8, by) + r.tick(0, true, 0, 0, true) + + short := map[string]float64{"garage": 9_000} + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk on an assumed level") + } + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(map[string]float64{"garage": GoalRiskMinWh - 1}, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk for a gap under the threshold") + } + r.mgr.ObservePlanShortfall(short, r.now) + r.mgr.ObservePlanShortfall(short, r.now.Add(time.Minute)) + evs := got() + if len(evs) != 1 { + t.Fatalf("goal at risk %d times, want once", len(evs)) + } + if e := evs[0]; e.LoadpointID != "garage" || e.TargetSoC != 0.8 || !e.By.Equal(by) || e.ShortfallWh != 9_000 { + t.Fatalf("event %+v", e) + } + + // A new plug-in may speak again, but not about a ready time that passed. + r.tick(time.Minute, false, 0, 0, false) + r.tick(time.Minute, true, 0, 0, true) + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(short, by.Add(time.Minute)) + if len(got()) != 1 { + t.Fatal("goal at risk for a ready time already gone") + } + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 2 { + t.Fatal("the next plug-in was not told") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index ce4dcfa46..530348366 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -363,6 +363,13 @@ type loadpointRuntime struct { sessionID string socRetention string completionNotified bool + // goalRiskNotified latches charging.goal_at_risk once per plug-in. + // vehicleAnchored is true once the car itself reported a level this + // session; chargedSinceStart once it drew power in this process, so a + // restart beside a car that is already done says nothing. + goalRiskNotified bool + vehicleAnchored bool + chargedSinceStart bool Config pluggedIn bool @@ -771,6 +778,9 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.sessionPluginSoC = anchor lp.socConfirmed = false lp.completionNotified = false + lp.goalRiskNotified = false + lp.vehicleAnchored = false + lp.chargedSinceStart = false lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false @@ -878,6 +888,17 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) + if powerW >= DeliveringW { + lp.chargedSinceStart = true + } + // A car that reports nothing itself is done when the box's own + // estimate, from a level someone set, reaches the target. Said in + // kWh, which the box measured; the level is an estimate. + if !lp.completionNotified && !lp.vehicleAnchored && lp.socConfirmed && lp.chargedSinceStart && + lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && deliveredWh >= minCompletionWh { + lp.completionNotified = true + fired = append(fired, events.ChargingSessionComplete{LoadpointID: id, KWh: deliveredWh / 1000, At: now}) + } } else { lp.currentSoC = 0 } @@ -1140,6 +1161,7 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. } } reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) + lp.vehicleAnchored = true if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 52a38c122..868ca2753 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -14,6 +14,7 @@ const ( PushChargingConnected = protocol.PushChargingConnected PushChargingSessionComplete = protocol.PushChargingSessionComplete PushChargingInterrupted = protocol.PushChargingInterrupted + PushChargingGoalAtRisk = protocol.PushChargingGoalAtRisk PushUpdateInstalled = protocol.PushUpdateInstalled PushDriverOffline = protocol.PushDriverOffline PushFuseOverLimit = protocol.PushFuseOverLimit diff --git a/go/internal/notifications/charging.go b/go/internal/notifications/charging.go new file mode 100644 index 000000000..4ca51b792 --- /dev/null +++ b/go/internal/notifications/charging.go @@ -0,0 +1,51 @@ +package notifications + +import "time" + +// ConnectedSettle is how long after a plug-in the box decides whether to +// tell anyone. A car that reports its own level usually does so within it, +// and a car whose level and goal are both known needs no question. +const ConnectedSettle = 90 * time.Second + +// ChargingFacts is what the plug-in rule needs to know about a loadpoint. +type ChargingFacts struct { + Plugged bool + // LevelKnown is true when the car's level came from the car or from + // someone who set it, not from the plug-in assumption. + LevelKnown bool + // HasGoal is true when a target level is set. + HasGoal bool +} + +// SetChargingFacts wires what the plug-in rule reads. Nil tells every +// plug-in, as before. +func (s *Service) SetChargingFacts(f func(loadpointID string) (ChargingFacts, bool)) { + if s == nil { + return + } + s.mu.Lock() + s.charging = f + s.mu.Unlock() +} + +// handleConnected tells a plug-in only when the box needs the household: +// the car's level is an assumption, or no goal is set. It decides after +// ConnectedSettle, and says nothing if the cable came out meanwhile. +func (s *Service) handleConnected(loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + facts, settle := s.charging, s.connectedSettle + s.mu.Unlock() + if facts != nil { + if settle > 0 { + time.Sleep(settle) + } + f, ok := facts(loadpointID) + if !ok || !f.Plugged || (f.LevelKnown && f.HasGoal) { + return + } + } + s.handleCatalogued(PushChargingConnected, loadpointID, nil) +} diff --git a/go/internal/notifications/cloud.go b/go/internal/notifications/cloud.go index f2e62af18..e5a4e0141 100644 --- a/go/internal/notifications/cloud.go +++ b/go/internal/notifications/cloud.go @@ -1,5 +1,7 @@ package notifications +import "context" + // NotifyCloudGrant tells every owner that FTW Cloud was turned on or off, // with the catalogue's sentence for kind. The rule gates it like any other, // but a box whose stored rules predate these kinds still sends it: a missing @@ -34,3 +36,23 @@ func (s *Service) NotifyCloudGrant(kind string) { } s.deliver(kind, "", Message{Kind: kind, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags)}) } + +// CloudPublisher hands each catalogue-worded message to FTW Cloud's uplink, +// which signs it and sends it as an event for the owners' phones. It only +// queues: the uplink sends on its own goroutine, so a slow or absent service +// never holds up another transport. Template rules carry no kind and stay on +// the box's own transports. +type CloudPublisher struct { + // Queue takes one message and reports whether FTW Cloud will carry it. + // False means FTW Cloud is off or its grant leaves events out. + Queue func(Message) bool +} + +// Publish queues m, or answers ErrNothingToSend when FTW Cloud will not +// carry it. +func (p CloudPublisher) Publish(_ context.Context, m Message) error { + if m.Kind == "" || p.Queue == nil || !p.Queue(m) { + return ErrNothingToSend + } + return nil +} diff --git a/go/internal/notifications/cloud_events_test.go b/go/internal/notifications/cloud_events_test.go new file mode 100644 index 000000000..2bf9a1edc --- /dev/null +++ b/go/internal/notifications/cloud_events_test.go @@ -0,0 +1,150 @@ +package notifications + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/events" +) + +// K5: a catalogue sentence carries its kind and its arguments, so a +// transport whose phones write their own words, as FTW Cloud's do, gets the +// facts and not only the English. +func TestACatalogueMessageCarriesItsKindAndArgs(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: true, ThresholdS: 600, Priority: 4, CooldownS: 3600, + }), pub) + last := clk.now() + clk.advance(800 * time.Second) + svc.Observe(healthStale(last)) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("got %d msgs", len(msgs)) + } + m := msgs[0] + if m.Kind != PushDriverOffline || m.Args["name"] != "ferroamp" || m.Device != "ferroamp" || m.At.IsZero() { + t.Fatalf("message %+v", m) + } +} + +// hangingPub never answers until its context ends. +type hangingPub struct{ entered chan struct{} } + +func (h hangingPub) Publish(ctx context.Context, _ Message) error { + close(h.entered) + <-ctx.Done() + return ctx.Err() +} + +// Each transport has its own time: one that hangs does not hold up another. +func TestAHangingTransportDoesNotDelayAnother(t *testing.T) { + quick := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{ + Type: PushChargingInterrupted, Enabled: true, Priority: 4, + }), hangingPub{entered: make(chan struct{})}) + svc.AddPublisher(quick) + bus := events.NewBus() + svc.Subscribe(bus) + + start := time.Now() + bus.Publish(events.ChargingInterrupted{LoadpointID: "garage", At: start}) + select { + case <-quick.published: + case <-time.After(2 * time.Second): + t.Fatal("a hanging transport held up the other") + } + if waited := time.Since(start); waited > time.Second { + t.Fatalf("the quick transport waited %s", waited) + } +} + +// The FTW Cloud transport only queues, and only messages with a kind. +func TestCloudPublisherQueuesOnlyCatalogueKinds(t *testing.T) { + var mu sync.Mutex + var queued []Message + on := true + p := CloudPublisher{Queue: func(m Message) bool { + mu.Lock() + defer mu.Unlock() + queued = append(queued, m) + return on + }} + if err := p.Publish(context.Background(), Message{Title: "FTW: test"}); err != ErrNothingToSend { + t.Fatalf("a template message went to FTW Cloud: %v", err) + } + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != nil { + t.Fatal(err) + } + on = false + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("FTW Cloud off answered %v", err) + } + if len(queued) != 2 { + t.Fatalf("queued %d", len(queued)) + } + if err := (CloudPublisher{}).Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("no queue answered %v", err) + } +} + +// The plug-in notification asks only when the box needs the household: the +// level is assumed or no goal is set. It decides after a pause, and says +// nothing about a cable already out. +func TestPlugInAsksOnlyWhenTheBoxNeedsTheHousehold(t *testing.T) { + cases := []struct { + name string + facts ChargingFacts + ok bool + want int + }{ + {"assumed level, goal set", ChargingFacts{Plugged: true, HasGoal: true}, true, 1}, + {"known level, no goal", ChargingFacts{Plugged: true, LevelKnown: true}, true, 1}, + {"known level and goal", ChargingFacts{Plugged: true, LevelKnown: true, HasGoal: true}, true, 0}, + {"unplugged meanwhile", ChargingFacts{}, true, 0}, + {"unknown loadpoint", ChargingFacts{}, false, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true}), pub) + svc.connectedSettle = 0 + svc.SetChargingFacts(func(id string) (ChargingFacts, bool) { + if id != "garage" { + t.Errorf("asked about %q", id) + } + return c.facts, c.ok + }) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: time.Now()}) + if c.want == 1 { + waitForMsgs(t, pub, 1) + return + } + settle(svc) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("sent %d", n) + } + }) + } +} + +// Goal at risk says the target and the ready time. +func TestGoalAtRiskSaysTargetAndTime(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingGoalAtRisk, Enabled: true, Priority: 4}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingGoalAtRisk{LoadpointID: "garage", TargetSoC: 0.8, ByClock: "07:00", At: time.Now()}) + m := waitForMsgs(t, pub, 1)[0] + if m.Kind != PushChargingGoalAtRisk || m.LoadpointID != "garage" || m.Args["target"] != "80" || m.Args["by"] != "07:00" { + t.Fatalf("message %+v", m) + } + if m.Title != "The car may not be ready" || m.Body != "It will not reach 80 % by 07:00 at this pace." { + t.Fatalf("words %q / %q", m.Title, m.Body) + } +} diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index e481fe4bd..bc63b5908 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "log/slog" + "math" "sort" "strconv" "strings" @@ -97,6 +98,9 @@ func DefaultRules() []config.NotificationRule { {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, + // Once per plug-in, only when the box knows the car's level and the + // plan cannot make the goal: rare, and the moment to act. + {Type: PushChargingGoalAtRisk, Enabled: false, Priority: 4}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, // Phone lock-screen counterparts of the operator rules above. // Same thresholds so a blip or a kettle is not a notification; @@ -122,7 +126,7 @@ func KnownRuleTypes() []string { EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, - PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, PushCloudGranted, PushCloudRevoked, } } @@ -145,6 +149,14 @@ type Message struct { Body string Priority int Tags []string + // Args are the catalogue's placeholders, for a transport whose phones + // write their own words, as FTW Cloud's do. Nil for template rules. + Args map[string]string + // Device is the driver the event concerns, for a transport that names + // it some other way than by its name. + Device string + // At is when the event happened. + At time.Time } // Publisher dispatches a rendered Message to its transport. @@ -261,6 +273,10 @@ type Service struct { sent uint64 failed uint64 now func() time.Time + // charging answers what the plug-in rule needs to know about a + // loadpoint; connectedSettle is how long it waits before asking. + charging func(loadpointID string) (ChargingFacts, bool) + connectedSettle time.Duration } // New constructs a Service. cfg may be nil (no-op until Reload is called). @@ -274,6 +290,7 @@ func New(cfg *config.Notifications, pub Publisher, lookup DeviceLookup) *Service activeAlert: map[string]bool{}, fuseFirstOverAt: map[string]time.Time{}, now: time.Now, + connectedSettle: ConnectedSettle, } } @@ -398,7 +415,15 @@ func (s *Service) Subscribe(bus *events.Bus) { // cooldown. Words come from the catalogue and nowhere else. bus.Subscribe(events.KindChargingConnected, func(e events.Event) { if ev, ok := e.(events.ChargingConnected); ok { - go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + go s.handleConnected(ev.LoadpointID) + } + }) + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + go s.handleCatalogued(PushChargingGoalAtRisk, ev.LoadpointID, map[string]string{ + "target": strconv.Itoa(int(math.Round(ev.TargetSoC * 100))), + "by": ev.ByClock, + }) } }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { @@ -477,6 +502,8 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + At: now, }) } @@ -874,19 +901,7 @@ func (s *Service) SendTest() error { Body: fmt.Sprintf("Test notification sent at %s.", time.Now().UTC().Format(time.RFC3339)), Priority: prio, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(pubs, msg) var err error if sent == 0 { if len(errs) > 0 { @@ -953,7 +968,8 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t } func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { - title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) + args := catalogueArgs(rule.Type, data) + title, body, err := RenderPush(rule.Type, args) if err != nil { slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) s.bumpFailed() @@ -965,10 +981,14 @@ func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.Notif prio = cfg.DefaultPriority } s.deliver(rule.Type, data.Device, Message{ + Kind: rule.Type, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + Device: data.Device, + At: s.now(), }) } @@ -1040,22 +1060,7 @@ func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRu // enabled with no provider installed — and stays a truthful failure in the // history rather than a panic or a silence. func (s *Service) deliver(eventType, device string, msg Message) { - pubs := s.publishers() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - // Absence, not failure. - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(s.publishers(), msg) switch { case sent > 0: if len(errs) > 0 { @@ -1076,6 +1081,35 @@ func (s *Service) deliver(eventType, device string, msg Message) { } } +// PublishTimeout bounds one transport's Publish. Each transport has its own, +// and they run side by side, so a gateway that hangs costs only itself. +const PublishTimeout = 10 * time.Second + +// publishAll hands msg to every transport at once, each under its own +// timeout, and counts the outcome. ErrNothingToSend is absence, not failure. +func publishAll(pubs []Publisher, msg Message) (sent int, errs []error) { + results := make([]error, len(pubs)) + var wg sync.WaitGroup + for i, pub := range pubs { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), PublishTimeout) + defer cancel() + results[i] = pub.Publish(ctx, msg) + }) + } + wg.Wait() + for _, err := range results { + switch { + case err == nil: + sent++ + case errors.Is(err, ErrNothingToSend): + default: + errs = append(errs, err) + } + } + return sent, errs +} + // emitDispatched publishes a NotificationDispatched event for history // loggers + future audit subscribers. Safe when the bus is nil. func (s *Service) emitDispatched(eventType, driver string, m Message, status, errStr string) { From fe359327050dafe3a301575a485358a53190217c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:17:39 +0200 Subject: [PATCH 14/15] test(sim-ocpp): a charger that stalls with the car still asking POST /charger/{id}/stall makes the simulated charger deliver nothing while it still reports Charging, as a tripped relay does; /resume lets it go on. It is what charging.interrupted is for, and the FTW Cloud C4 end-to-end test raises one with it. Co-Authored-By: Claude Opus 5.5 --- go/cmd/sim-ocpp/main.go | 11 +++++++++++ go/cmd/sim-ocpp/ocppcp/physics.go | 5 ++++- go/cmd/sim-ocpp/ocppcp/sim.go | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/go/cmd/sim-ocpp/main.go b/go/cmd/sim-ocpp/main.go index bef67bd43..92a1b46c9 100644 --- a/go/cmd/sim-ocpp/main.go +++ b/go/cmd/sim-ocpp/main.go @@ -121,6 +121,17 @@ func main() { } w.WriteHeader(204) }) + for path, stalled := range map[string]bool{"stall": true, "resume": false} { + mux.HandleFunc("POST /charger/{id}/"+path, func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + sim.Stall(stalled) + w.WriteHeader(204) + }) + } go func() { slog.Info("control listening", "addr", *control) if err := http.ListenAndServe(*control, mux); err != nil { diff --git a/go/cmd/sim-ocpp/ocppcp/physics.go b/go/cmd/sim-ocpp/ocppcp/physics.go index 40377afa1..badd76fb1 100644 --- a/go/cmd/sim-ocpp/ocppcp/physics.go +++ b/go/cmd/sim-ocpp/ocppcp/physics.go @@ -19,6 +19,9 @@ type Physics struct { DrawA float64 EnergyWh float64 Plugged bool + // Stalled is a charger that stops delivering while the car still wants + // energy, as a tripped relay or a cloud-side fault does. + Stalled bool } func newPhysics(m Model, tauS float64) Physics { @@ -39,7 +42,7 @@ func (p Physics) PowerW() float64 { } func (p Physics) targetA() float64 { - if !p.Plugged || p.LimitA < minChargeAmps { + if !p.Plugged || p.Stalled || p.LimitA < minChargeAmps { return 0 } if p.LimitA > p.MaxAmps { diff --git a/go/cmd/sim-ocpp/ocppcp/sim.go b/go/cmd/sim-ocpp/ocppcp/sim.go index 685135a05..59886a807 100644 --- a/go/cmd/sim-ocpp/ocppcp/sim.go +++ b/go/cmd/sim-ocpp/ocppcp/sim.go @@ -169,6 +169,13 @@ func (s *Sim) Unplug() error { return s.statusPlugged(false) } +// Stall stops delivery with the car still asking, or lets it go on. +func (s *Sim) Stall(stalled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.physics.Stalled = stalled +} + // Tick advances physics. Tests call this with a positive dt and TauS=0 to settle. func (s *Sim) Tick(dt time.Duration) { s.mu.Lock() From a9e6f66f1b8946135355a49bc6e470ebc076ae15 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 09:59:09 +0200 Subject: [PATCH 15/15] feat(cloudwire): an alert can be anchored by rule U A box without NTP sends events with no wallMs, and the phone showed them as the generic line even when it held an uptime offset for that boot. ReadAlert now takes the offset the phone keeps for a home and anchors an event as View anchors a message: the earlier of rule N and rule U, with rule U's drift margin counted against the age. The protocol's Push table already says "anchored", so the text does not change. Four push vectors cover rule U: no box clock, a box clock ahead, an offset from another boot, and one learned over a day ago. -update now keeps stored signatures that still verify, so adding a case leaves the other cases' bytes alone. Co-Authored-By: Claude Opus 5.5 --- go/protocol/cloudwire/push.go | 45 +++++++++-- go/protocol/cloudwire/push_vectors_test.go | 73 ++++++++++++++---- .../cloudwire/testdata/push-vectors.json | 76 +++++++++++++++++++ 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go index 57650dfc3..33137865f 100644 --- a/go/protocol/cloudwire/push.go +++ b/go/protocol/cloudwire/push.go @@ -1,6 +1,7 @@ package cloudwire import ( + "bytes" "encoding/json" "github.com/srcfl/ftw/go/protocol" @@ -160,7 +161,8 @@ type AlertView struct { Boot []byte Seq uint64 // AtMs is when the event happened by the phone's reading of the box - // clock: never later than it arrived. + // clock, less rule U's drift margin when rule U anchors it: never later + // than it arrived. AtMs int64 } @@ -168,12 +170,21 @@ type AlertView struct { // before the phone stops trusting it (rule N). const ClockLeadMs = 2000 +// Offset is rule U's anchor for one home, as the phone keeps it for its +// notification extension: phone time minus box uptime, the smallest learned +// over Noise for Boot, and when it was last learned. +type Offset struct { + Boot []byte + OffsetMs int64 + LearnedAtMs int64 +} + // ReadAlert is the reference for what a phone's notification extension does // with an alert payload. boxKey returns the key the phone learned over Noise -// for a home, nil when it has none; nowMs is the phone's clock at arrival. -// Whether a (boot, seq) was seen before is the caller's state, not this -// function's. -func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) AlertView { +// for a home, nil when it has none; offset returns rule U's anchor for a +// home, nil when it has none; nowMs is the phone's clock at arrival. Whether +// a (boot, seq) was seen before is the caller's state, not this function's. +func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home string) *Offset, nowMs int64) AlertView { var p struct { FTW *Alert `json:"ftw"` } @@ -204,10 +215,13 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) Ale return view } var ev Event - if Unmarshal(env.B, &ev) != nil || ev.WallMs == nil || *ev.WallMs > nowMs+ClockLeadMs { + if Unmarshal(env.B, &ev) != nil { + return view + } + at, ok := alertSendTime(ev, offset(a.Home), nowMs) + if !ok { return view } - at := min(*ev.WallMs, nowMs) if ev.Ts != nil { at = min(at, *ev.Ts) } @@ -218,3 +232,20 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) Ale return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, Boot: ev.Boot, Seq: ev.Seq, AtMs: at} } + +// alertSendTime anchors an event as View anchors a message: the earlier of +// rule N and rule U, and when rule U gives it, earlier by the drift margin so +// the margin counts against the age. A box clock more than ClockLeadMs ahead +// gives no rule N. ok is false when neither rule applies. +func alertSendTime(ev Event, off *Offset, nowMs int64) (at int64, ok bool) { + if ev.WallMs != nil && *ev.WallMs <= nowMs+ClockLeadMs { + at, ok = min(*ev.WallMs, nowMs), true + } + if off == nil || !bytes.Equal(off.Boot, ev.Boot) || nowMs-off.LearnedAtMs > RuleULifetimeMs { + return at, ok + } + if u := min(off.OffsetMs+ev.UptimeMs, nowMs); !ok || u <= at { + at, ok = u-max(0, nowMs-off.LearnedAtMs)/ruleUDriftDivisor, true + } + return at, ok +} diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go index 66002ba5d..0fd94d6b3 100644 --- a/go/protocol/cloudwire/push_vectors_test.go +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -41,9 +41,18 @@ type pushVectorCase struct { Payload string `json:"payload"` // Sig is the event's stored signature, hex, empty when there is no // event or it is unsigned. - Sig string `json:"sig"` - NowMs int64 `json:"nowMs"` - Want pushVectorOut `json:"want"` + Sig string `json:"sig"` + // Offset is rule U's anchor the phone holds, absent when it holds none. + Offset *pushVectorOffset `json:"offset,omitempty"` + NowMs int64 `json:"nowMs"` + Want pushVectorOut `json:"want"` +} + +type pushVectorOffset struct { + Home string `json:"home"` + Boot string `json:"boot"` + OffsetMs int64 `json:"offsetMs"` + LearnedAtMs int64 `json:"learnedAtMs"` } type pushVectorOut struct { @@ -71,17 +80,18 @@ func TestPushVectors(t *testing.T) { at := func(uptime int64) *int64 { return i64(wall + uptime - 60_000) } var stored pushVectorFile - if !*update { - raw, err := os.ReadFile(pushVectorsPath) - if err != nil { - t.Fatalf("%v (run with -update to create it)", err) - } + if raw, err := os.ReadFile(pushVectorsPath); err == nil { if err := json.Unmarshal(raw, &stored); err != nil { t.Fatal(err) } + } else if !*update { + t.Fatalf("%v (run with -update to create it)", err) } storedSig := func(i int, name string) []byte { if i >= len(stored.Cases) || stored.Cases[i].Name != name { + if *update { + return nil + } t.Fatalf("push vector %d is not %q in %s; regenerate with -update", i, name, pushVectorsPath) } sig, _ := hex.DecodeString(stored.Cases[i].Sig) @@ -112,6 +122,18 @@ func TestPushVectors(t *testing.T) { signedAsOther := connected signedAsOther.Seq = 16 + // Rule U: the phone learned phone time minus box uptime an hour before + // the event, so the anchor carries 0.01 % of that hour as margin. + learned := *at(71_000) - 60*60*1000 + ruleU := &Offset{Boot: boot, OffsetMs: wall - 60_000, LearnedAtMs: learned} + otherBoot := &Offset{Boot: bytes.Repeat([]byte{0xb1}, BootBytes), OffsetMs: ruleU.OffsetMs, LearnedAtMs: learned} + dayOld := &Offset{Boot: boot, OffsetMs: ruleU.OffsetMs, LearnedAtMs: fresh0(connected) - RuleULifetimeMs - 1} + byRuleU := func(e Event, nowMs int64) pushVectorOut { + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, + AtMs: ruleU.OffsetMs + e.UptimeMs - (nowMs-learned)/ruleUDriftDivisor} + } + type spec struct { name string key *ecdsa.PrivateKey // signer; nil for no event @@ -119,10 +141,11 @@ func TestPushVectors(t *testing.T) { event *Event alert Alert // Home and Kind; Event is filled in mutate func(env []byte) []byte + offset *Offset // rule U's anchor the phone holds for home nowMs int64 want pushVectorOut } - fresh := func(e Event) int64 { return *e.WallMs + 1_200 } + fresh := fresh0 event := func(e Event) pushVectorOut { at := min(*e.WallMs, fresh(e)) if e.Ts != nil { @@ -174,6 +197,14 @@ func TestPushVectors(t *testing.T) { {name: "a home the phone does not hold", key: box, signFor: home, event: &connected, alert: Alert{Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}, nowMs: fresh(connected), want: pushVectorOut{Verdict: AlertGeneric, Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + {name: "box clock not synced, anchored by rule U", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: ruleU, nowMs: fresh(connected), want: byRuleU(noClock, fresh(connected))}, + {name: "box clock ahead, anchored by rule U", key: box, signFor: home, event: &ahead, + alert: Alert{Home: home}, offset: ruleU, nowMs: fresh(connected), want: byRuleU(ahead, fresh(connected))}, + {name: "rule U from another boot", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: otherBoot, nowMs: fresh(connected), want: generic}, + {name: "rule U learned over a day ago", key: box, signFor: home, event: &noClock, + alert: Alert{Home: home}, offset: dayOld, nowMs: fresh(connected), want: generic}, } keys := map[string][]byte{home: boxPK, otherHome: otherPK} @@ -189,12 +220,11 @@ func TestPushVectors(t *testing.T) { if err != nil { t.Fatal(err) } - if *update { + sig = storedSig(i, s.name) + if *update && Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}) != nil { if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { t.Fatal(err) } - } else { - sig = storedSig(i, s.name) } if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { t.Fatalf("%s: stored signature does not verify: %v", s.name, err) @@ -216,7 +246,13 @@ func TestPushVectors(t *testing.T) { t.Fatalf("%s: %d bytes is over APNs' 4 KB", s.name, len(payload)) } - view := ReadAlert(payload, func(h string) []byte { return keys[h] }, s.nowMs) + offset := func(h string) *Offset { + if h != home { + return nil + } + return s.offset + } + view := ReadAlert(payload, func(h string) []byte { return keys[h] }, offset, s.nowMs) got := pushVectorOut{Verdict: view.Verdict, Home: view.Home, Kind: view.Kind, Args: view.Args, Src: view.Src, LP: view.LP, Seq: view.Seq, AtMs: view.AtMs} if view.Boot != nil { @@ -225,8 +261,12 @@ func TestPushVectors(t *testing.T) { if !equalOut(got, s.want) { t.Errorf("%s: got %+v, want %+v", s.name, got, s.want) } - out.Cases = append(out.Cases, pushVectorCase{Name: s.name, Payload: string(payload), - Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want}) + c := pushVectorCase{Name: s.name, Payload: string(payload), Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want} + if s.offset != nil { + c.Offset = &pushVectorOffset{Home: home, Boot: hex.EncodeToString(s.offset.Boot), + OffsetMs: s.offset.OffsetMs, LearnedAtMs: s.offset.LearnedAtMs} + } + out.Cases = append(out.Cases, c) } got, err := json.MarshalIndent(out, "", " ") @@ -247,6 +287,9 @@ func TestPushVectors(t *testing.T) { } } +// fresh0 is a phone clock 1.2 s after the event's wallMs. +func fresh0(e Event) int64 { return *e.WallMs + 1_200 } + func equalOut(a, b pushVectorOut) bool { if len(a.Args) == 0 && len(b.Args) == 0 { a.Args, b.Args = nil, nil diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json index 218d502fc..e6b4ac67c 100644 --- a/go/protocol/cloudwire/testdata/push-vectors.json +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -176,6 +176,82 @@ "verdict": "generic", "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" } + }, + { + "name": "box clock not synced, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQFNotNXPkQUfb07DO7RwspScyA+HtdtuE/R6EAOdZFzmMBTAeJfa+x5KFmmfsdQNg7Q3ffV+WLE0KNonhgL6OXY=\"}}", + "sig": "5368b4d5cf91051f6f4ec33bb470b2949cc80f87b5db6e13f47a10039d645ce63014c07897dafb1e4a16699fb1d40d83b4377df57e58b13428da278602fa3976", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 12, + "atMs": 1791820810640 + } + }, + { + "name": "box clock ahead, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAT0WQabV0OknG7SmU38U/KFBl7E6LdxOZZMNh9lOK/hi2WqRrVsSfQKUrKe8k65zfTUYHCQjuGSyi03B3vwg4W\"}}", + "sig": "13d1641a6d5d0e9271bb4a6537f14fca14197b13a2ddc4e65930d87d94e2bf862d96a91ad5b127d0294aca7bc93ae737d35181c2423b864b28b4dc1defc20e16", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 13, + "atMs": 1791820810640 + } + }, + { + "name": "rule U from another boot", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQGk/DH/IlyDThxxoaZe5v0fNthUnu8QnNIlvDjReA36nwi4S1d0vPiDfNV2JwE4iyT57heDhaU27bERXLSNGRnc=\"}}", + "sig": "693f0c7fc89720d3871c686997b9bf47cdb61527bbc42734896f0e345e037ea7c22e12d5dd2f3e20df355d89c04e22c93e7b85e0e1694dbb6c44572d23464677", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "rule U learned over a day ago", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQJYY1B25q3N9kJEsHpndMcA9zIZD70klDsFbQdtklHF3eIWxYd1qbRN1eQaszxJWNQc8zatRazb3KwxDyiFMj/0=\"}}", + "sig": "9618d41db9ab737d90912c1e99dd31c03dcc8643ef49250ec15b41db649471777885b161dd6a6d13757906accf125635073ccdab516b36f72b0c43ca214c8ffd", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791734412199 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } } ] }