From cc6b36cb445fd50c814751176219d1f7092f3847 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:18:48 +0200 Subject: [PATCH 1/4] 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 00000000..5da59e49 --- /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 00000000..7ce9de4b --- /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 00000000..d7005687 --- /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 00000000..d5749cfc --- /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 00000000..b49c1b36 --- /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 2/4] 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 7ce9de4b..158f7277 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 8df400bd..f9303a38 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 c85761d687276ccdaf85a8e4af85640967f2acb0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:53:35 +0200 Subject: [PATCH 3/4] 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 747354aa..80b4ebed 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 92120aab..ef6ac90f 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 58cf37b9..c268185b 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 95a9686c..5b4561f1 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 92ba4e6a..e73e2e01 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 fb064be2..58be0f41 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 992e09d6..8627b3ac 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 e9bfd4db..5d843f84 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 348a7550..317ea3e1 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 22a83e33..fcff0073 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 7f4f1582..94802e13 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 8a66da29..51bba7d7 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 f296c6e0..7876a89a 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 4/4] 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 00000000..aec2119b --- /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 9df86a01..218a4c10 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 06ce8515..4495cb54 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"]) + } +}