From d1ce6d8cb7df91d576a5aaeebf28277448771807 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:36:19 +0200 Subject: [PATCH] fix(cloud): remove retired relay code and DNS dependency Signed-off-by: Fredrik Ahlgren --- .changeset/remove-retired-relay.md | 5 + go/cmd/ftw/app_link.go | 4 +- go/cmd/ftw/main.go | 4 +- go/internal/appproto/exchange.go | 306 ------ go/internal/appproto/exchange_test.go | 196 ---- go/internal/appproto/handler.go | 27 +- go/internal/appproto/plan_feed_test.go | 32 +- go/internal/appuplink/agents_test.go | 14 +- go/internal/appuplink/arrival_test.go | 61 +- go/internal/appuplink/client.go | 697 +------------- go/internal/appuplink/cloudtunnel_test.go | 24 +- go/internal/appuplink/cmdlog_test.go | 159 +--- go/internal/appuplink/deadman.go | 226 ----- go/internal/appuplink/deadman_test.go | 381 -------- go/internal/appuplink/exchange.go | 557 ----------- go/internal/appuplink/exchange_test.go | 878 ------------------ go/internal/appuplink/legacy_test.go | 20 - go/internal/appuplink/livebox_test.go | 2 +- go/internal/appuplink/realrelay_test.go | 236 ----- go/internal/appuplink/relay_contract_test.go | 47 - go/internal/appuplink/relay_test.go | 380 -------- go/internal/appuplink/rendezvous.go | 76 -- go/internal/appuplink/roster.go | 2 +- go/internal/appuplink/rotation_test.go | 450 --------- go/internal/appuplink/session.go | 333 +------ go/internal/appuplink/site_test.go | 91 ++ .../appuplink/testdata/exchange-vectors.json | 70 -- go/internal/appuplink/tunnel.go | 31 +- go/internal/appuplink/tunnel_test.go | 217 ++--- go/internal/appuplink/uplink_test.go | 813 ---------------- go/internal/appuplink/vectors_test.go | 320 ------- go/internal/appuplink/watch_test.go | 60 -- go/internal/appuplink/watchclient_test.go | 204 ---- go/internal/config/config.go | 8 +- go/internal/gatewayidentity/stableid.go | 2 +- go/internal/gatewayidentity/stableid_test.go | 9 +- 36 files changed, 303 insertions(+), 6639 deletions(-) create mode 100644 .changeset/remove-retired-relay.md delete mode 100644 go/internal/appproto/exchange.go delete mode 100644 go/internal/appproto/exchange_test.go delete mode 100644 go/internal/appuplink/deadman.go delete mode 100644 go/internal/appuplink/deadman_test.go delete mode 100644 go/internal/appuplink/exchange.go delete mode 100644 go/internal/appuplink/exchange_test.go delete mode 100644 go/internal/appuplink/legacy_test.go delete mode 100644 go/internal/appuplink/realrelay_test.go delete mode 100644 go/internal/appuplink/relay_contract_test.go delete mode 100644 go/internal/appuplink/relay_test.go delete mode 100644 go/internal/appuplink/rendezvous.go delete mode 100644 go/internal/appuplink/rotation_test.go create mode 100644 go/internal/appuplink/site_test.go delete mode 100644 go/internal/appuplink/testdata/exchange-vectors.json delete mode 100644 go/internal/appuplink/uplink_test.go delete mode 100644 go/internal/appuplink/vectors_test.go delete mode 100644 go/internal/appuplink/watch_test.go delete mode 100644 go/internal/appuplink/watchclient_test.go diff --git a/.changeset/remove-retired-relay.md b/.changeset/remove-retired-relay.md new file mode 100644 index 000000000..da7fabfa6 --- /dev/null +++ b/.changeset/remove-retired-relay.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Remove the retired relay client and use FTW Cloud for the network route lookup that binds software identity. App sessions continue through the existing Cloud tunnel. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index a3df6a366..9287eecb2 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -646,8 +646,8 @@ func goalRev(st loadpoint.State) uint32 { return h.Sum32() } -// startAppLink connects the box to the app relay when its loaded config leaves -// the link enabled. Config defaults an omitted app_link section on. +// startAppLink prepares app sessions for the FTW Cloud tunnel when enabled. +// Config defaults an omitted app_link section on. // // It returns the enrollment identity whether or not the uplink runs, because // the QR code on the lid is minted from it and a box with the link switched diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d971f274e..cbbae4ec4 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2383,7 +2383,7 @@ func main() { } // The FTW app's uplink. It speaks the app's own protocol end to end and - // reaches the box through a relay that holds no keys. + // reaches the box through the encrypted FTW Cloud tunnel. appLinkWatchdog := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second if appLinkWatchdog <= 0 { appLinkWatchdog = 60 * time.Second @@ -2410,7 +2410,7 @@ func main() { case appLinkErr != nil: slog.Warn("app link unavailable", "err", appLinkErr) case appLinkEnabled: - slog.Info("app link connected", "relay", appuplink.Endpoint, + slog.Info("app link ready", "transport", "cloud", "paired_devices", appEnroll.AuthorisedCount()) default: slog.Info("app link disabled — set app_link.enabled to turn it on") diff --git a/go/internal/appproto/exchange.go b/go/internal/appproto/exchange.go deleted file mode 100644 index b4153f962..000000000 --- a/go/internal/appproto/exchange.go +++ /dev/null @@ -1,306 +0,0 @@ -package appproto - -import ( - "context" - "encoding/hex" - "github.com/srcfl/ftw/go/protocol" - "sort" - - "github.com/srcfl/ftw/go/internal/units" -) - -// A watch's exchange session (ADR 0009, decision 2). -// -// A watch speaks HTTPS and nothing lower, so each of its requests is one Noise -// message through the relay and each answer one HTTP body. The session takes -// hello, cmd and now. It never streams: there is no socket to stream on. Every -// answer is two lane 0 frames of one size whatever the op or the outcome, so -// the relay cannot tell a read from a command or a refusal from a success by -// length. appuplink pads an answer to two frames with Pad. - -func (h *Handler) dispatchExchange(ctx context.Context, env Envelope) error { - switch env.T { - case MsgHello: - return h.onExchangeHello(env) - case MsgCmd: - return h.onCmd(ctx, env, "") - case MsgNow: - return h.onNow() - default: - // sub, api.req, hist.query, plan.get, price.get and anything newer. - // Answered rather than ignored, because the watch waits for an - // answer either way and a named refusal costs the same frame. - return h.sendError(env.ID, ErrorBody{ - Code: ErrUnknownOp, - Retryable: ErrorRetryable[ErrUnknownOp], - Args: map[string]any{"t": env.T}, - }) - } -} - -// onExchangeHello answers a watch's hello with what it needs: the protocol, -// the box's mode, the clock, its role and scopes, the controlRev a command -// expects, and FTW Cloud's session information while FTW Cloud is on. -func (h *Handler) onExchangeHello(env Envelope) error { - var hello Hello - if err := Unmarshal(env.B, &hello); err != nil { - h.log.Warn("undecodable hello dropped", "err", err) - return nil - } - - proto := ProtoMax - if hello.Proto.Max < ProtoMax { - proto = max(ProtoFloor, hello.Proto.Max) - } - mode := BoxModeFull - switch { - case h.cfg.Info.Boot() != nil: - mode = BoxModeBooting - case proto == ProtoFloor: - mode = BoxModeFloor - case !h.canWrite(): - mode = BoxModeReadonly - } - - var cloud *CloudSession - if h.cfg.Cloud != nil && proto != ProtoFloor { - cloud = h.cfg.Cloud() - } - source, syncedAtMs := h.cfg.Clock.Sync() - - h.mu.Lock() - h.proto = proto - h.mu.Unlock() - - body := ExchangeHelloOK{ - Proto: proto, - Mode: mode, - Clock: Clock{ - Source: source, - SyncedAtMs: syncedAtMs, - UptimeMs: h.cfg.Clock.UptimeMs(), - }, - Role: h.cfg.Caller.Role, - Scopes: h.cfg.Caller.Scopes.Names(), - Rev: h.cfg.Site.Snapshot().ControlRev, - Cloud: cloud, - } - if body.Cloud != nil { - size, err := encodedSize(MsgHelloOK, body) - if err != nil { - return err - } - if bucket := bulkBucketFor(size); bucket == 0 || bucket > ExchangeHelloMaxBucket { - // A house with too many sources to name in 1 KiB says less. The - // watch then reads now on the exchange, as with FTW Cloud off. - h.log.Warn("exchange hello_ok left FTW Cloud out to fit the relay's answer", "bytes", size) - body.Cloud = nil - } - } - return h.sendBulk(MsgHelloOK, nil, body) -} - -// ExchangeHelloMaxBucket is the largest bucket an exchange hello_ok may take: -// the smallest one. The relay answers a watch with at most 4 KiB behind its -// id, and handshake message 2 adds 48 bytes to the frame, so the next bucket, -// 4 KiB, never fits. -const ExchangeHelloMaxBucket = 1024 - -func encodedSize(t string, body any) (int, error) { - env, err := newEnvelope(t, nil, body) - if err != nil { - return 0, err - } - raw, err := EncodeEnvelope(env) - if err != nil { - return 0, err - } - return len(raw), nil -} - -// onNow answers a watch's read with the Now screen in one lane 0 frame. -func (h *Handler) onNow() error { - caller, enrolled := h.liveCaller() - if !enrolled { - if err := h.sendError(nil, ErrorBody{ - Code: ErrGrantRevoked, - Retryable: ErrorRetryable[ErrGrantRevoked], - }); err != nil { - return err - } - return h.Terminate(TerminateRevoked) - } - if !caller.Scopes.Has(ScopeLiveRead) { - return h.sendError(nil, ErrorBody{ - Code: ErrScopeDenied, - Retryable: ErrorRetryable[ErrScopeDenied], - Args: map[string]any{"t": MsgNow, "needScope": ScopeLiveRead, "role": caller.Role}, - }) - } - if boot := h.cfg.Info.Boot(); boot != nil { - return h.sendError(nil, ErrorBody{ - Code: ErrBooting, - Retryable: ErrorRetryable[ErrBooting], - Args: map[string]any{"phase": boot.Phase, "pct": boot.Pct}, - }) - } - - uptimeMs := h.cfg.Clock.UptimeMs() - snap := h.cfg.Site.Snapshot() - blocked := snap.DispatchBlockedBy - if blocked == nil { - blocked = []string{} - } - body := Now{ - UptimeMs: uptimeMs, - ControlRev: snap.ControlRev, - Fields: fieldValues(snap, h.modes), - Sources: h.nowSources(snap, uptimeMs), - DispatchBlockedBy: blocked, - Loadpoints: h.nowLoadpoints(), - } - - h.mu.Lock() - bucket := h.bucket - h.mu.Unlock() - flags, err := fitNow(&body, bucket) - if err != nil { - return err - } - return h.sendControl(MsgNow, nil, body, flags, bucket) -} - -// nowSources ages the three sources the power fields draw on, keyed by the -// frozen field that names each one. -func (h *Handler) nowSources(snap Snapshot, uptimeMs int64) map[string]NowSource { - out := map[string]NowSource{} - for _, ref := range []struct { - fid Fid - id string - }{ - {FidSrcGrid, h.cfg.SrcGrid}, - {FidSrcPv, h.cfg.SrcPV}, - {FidSrcBattery, h.cfg.SrcBattery}, - } { - for _, s := range snap.Sources { - if ref.id == "" || s.ID != ref.id { - continue - } - age := int64(-1) - if s.LastOkUptimeMs >= 0 { - age = max(0, uptimeMs-s.LastOkUptimeMs) - } - out[fidKey(ref.fid)] = NowSource{State: sourceState(s, uptimeMs), AgeMs: age} - break - } - } - return out -} - -func (h *Handler) nowLoadpoints() []NowLoadpoint { - out := []NowLoadpoint{} - if h.cfg.Loadpoints == nil { - return out - } - for _, r := range h.cfg.Loadpoints.Readings() { - lp := NowLoadpoint{ID: r.ID, Plugged: r.Plugged, PowerW: roundW(r.PowerW), - CarID: rawCarID(r.Car.CarID), CarIdentifiedBy: r.Car.CarIdentifiedBy, CarCorrected: r.Car.CarCorrected} - for _, id := range r.Car.Choose { - if raw := rawCarID(id); raw != nil { - lp.Choose = append(lp.Choose, raw) - } - } - if r.Car.CarIdentifiedBy == protocol.CarIdentifiedByAsking { - // A watch taps only while the box asks; the session costs - // lane 0 bytes on every other charger. - lp.Session = r.Car.Session - } - if r.SoCKnown { - soc := units.PermilleFromFraction(r.SoC) - lp.SocPermille = &soc - lp.SocSource, lp.SocAgeS, lp.SocEstimate = r.SoCSource, r.SoCAgeS, r.SoCEstimate - } - out = append(out, lp) - } - return out -} - -// fitNow trims a now until it fits its bucket, and flags what it dropped. -// -// The car comes before the sources' ages, because charging is what a watch is -// for. Chargers go from the last, then the blocked list, then the source ages, -// then the first charger, then fields from the highest id. -func fitNow(body *Now, bucket int) (uint8, error) { - const headerBytes = 6 - fits := func() (bool, error) { - env, err := newEnvelope(MsgNow, nil, body) - if err != nil { - return false, err - } - raw, err := EncodeEnvelope(env) - if err != nil { - return false, err - } - return len(raw)+headerBytes <= bucket, nil - } - - ok, err := fits() - if err != nil || ok { - return 0, err - } - for len(body.Loadpoints) > 1 { - body.Loadpoints = body.Loadpoints[:len(body.Loadpoints)-1] - if ok, err = fits(); err != nil || ok { - return FlagTrunc, err - } - } - body.DispatchBlockedBy = []string{} - if ok, err = fits(); err != nil || ok { - return FlagTrunc, err - } - body.Sources = map[string]NowSource{} - if ok, err = fits(); err != nil || ok { - return FlagTrunc, err - } - body.Loadpoints = []NowLoadpoint{} - if ok, err = fits(); err != nil || ok { - return FlagTrunc, err - } - keys := make([]string, 0, len(body.Fields)) - for k := range body.Fields { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return fidOf(keys[i]) < fidOf(keys[j]) }) - for len(keys) > 0 { - delete(body.Fields, keys[len(keys)-1]) - keys = keys[:len(keys)-1] - if ok, err = fits(); err != nil || ok { - return FlagTrunc, err - } - } - return FlagTrunc, nil -} - -// PadFrame is one tick on lane 0, encoded and not sent. The exchange fills -// every answer to two frames with it, so a refusal, a read and a command -// answer with the same length. -func (h *Handler) PadFrame() ([]byte, error) { - h.mu.Lock() - bucket := h.bucket - h.mu.Unlock() - env, err := newEnvelope(MsgTick, nil, Tick{UptimeMs: h.cfg.Clock.UptimeMs()}) - if err != nil { - return nil, err - } - return h.cfg.Codec.EncodeFrame(Frame{Lane: LaneControl, Bucket: bucket, Envelope: env}) -} - -// rawCarID is a car id as the watch's now carries it: its 16 bytes. Nil for -// none, or for an id that is not 32 hex digits. -func rawCarID(id string) []byte { - raw, err := hex.DecodeString(id) - if err != nil || len(raw) != 16 { - return nil - } - return raw -} diff --git a/go/internal/appproto/exchange_test.go b/go/internal/appproto/exchange_test.go deleted file mode 100644 index e483856cc..000000000 --- a/go/internal/appproto/exchange_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package appproto - -import ( - "encoding/hex" - "fmt" - "testing" - - "github.com/fxamacker/cbor/v2" - "github.com/srcfl/ftw/go/protocol" -) - -// newExchangeRig is a watch's exchange session over the EV rig's box. -func newExchangeRig(t *testing.T) (*Handler, *fakeLoadpoints, *fakeBox, *recorder) { - t.Helper() - h, lp, box, rec, _ := newEVRig(t) - h.cfg.Exchange = true - h.cfg.Caller = watchCaller() - h.cfg.Grants = newWatchGrants() - lp.plugged, lp.soc, lp.socKnown, lp.powerW = true, 0.62, true, 7360 - return h, lp, box, rec -} - -// hello_ok on the exchange says what a watch needs, in a bulk bucket, and -// carries the controlRev a command expects. -func TestAnExchangeHelloNamesTheRevAndNoCatalogue(t *testing.T) { - h, _, box, rec := newExchangeRig(t) - h.cfg.Cloud = func() *CloudSession { - return &CloudSession{Home: "home-1", PK: make([]byte, 65), Boot: make([]byte, 16)} - } - - deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) - frame := rec.only(t, MsgHelloOK) - if frame.lane != LaneBulk || frame.size != 1024 { - t.Fatalf("hello_ok went on lane %d in %d B, want the 1 KiB bulk bucket", frame.lane, frame.size) - } - ok := body[ExchangeHelloOK](t, frame) - if ok.Rev != box.snap.ControlRev || ok.Role != watchCaller().Role || ok.Cloud == nil || ok.Cloud.Home != "home-1" { - t.Fatalf("hello_ok = %+v", ok) - } - keys := body[map[string]any](t, frame) - for _, stream := range []string{"modes", "caps", "capsHash", "box"} { - if _, ok := keys[stream]; ok { - t.Fatalf("the exchange hello_ok carries %q, which only the stream needs", stream) - } - } -} - -// The relay answers a watch with at most 4 KiB, so hello_ok never takes the -// 4 KiB bucket. A house too big for 1 KiB leaves FTW Cloud out, and the watch -// reads now instead. -func TestAnExchangeHelloTooBigForTheRelayLeavesCloudOut(t *testing.T) { - h, _, _, rec := newExchangeRig(t) - sources := map[string]string{} - for i := 0; i < 80; i++ { - sources[fmt.Sprintf("opaque-source-%03d", i)] = fmt.Sprintf("a-rather-long-driver-name-%03d", i) - } - h.cfg.Cloud = func() *CloudSession { return &CloudSession{Home: "home-1", Sources: sources} } - - deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) - frame := rec.only(t, MsgHelloOK) - if frame.size > ExchangeHelloMaxBucket { - t.Fatalf("hello_ok took %d B", frame.size) - } - if ok := body[ExchangeHelloOK](t, frame); ok.Cloud != nil { - t.Fatal("hello_ok kept FTW Cloud past what the relay can carry") - } -} - -// An applied command on the exchange is answered by its ack and result and -// nothing after them: the plan the stream pushes would be a third frame. -func TestAnExchangeCommandPushesNoPlan(t *testing.T) { - h, _, _, rec := newExchangeRig(t) - deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointHold, "x-hold", holdArgs(), 7, 200_000)) - - if got := rec.types(); got != fmt.Sprint([]string{MsgCmdAck, MsgCmdResult}) { - t.Fatalf("an applied command sent %s", got) - } - if res := body[CmdResult](t, rec.only(t, MsgCmdResult)); res.State != CmdApplied { - t.Fatalf("the hold ended %q", res.State) - } -} - -// now is one lane 0 frame. What does not fit is left out and flagged, never -// sent in a bigger frame. -func TestNowFitsLaneZeroOrSaysWhatItLeftOut(t *testing.T) { - h, lp, _, rec := newExchangeRig(t) - deliver(t, h, MsgNow, nil, struct{}{}) - frame := rec.only(t, MsgNow) - if frame.lane != LaneControl || frame.size != 512 || frame.flags&FlagTrunc != 0 { - t.Fatalf("now went on lane %d in %d B with flags %d", frame.lane, frame.size, frame.flags) - } - now := body[Now](t, frame) - if len(now.Loadpoints) != 1 || now.Loadpoints[0].SocPermille == nil || *now.Loadpoints[0].SocPermille != 620 { - t.Fatalf("now = %+v", now) - } - if _, ok := now.Sources[fidKey(FidSrcGrid)]; !ok { - t.Fatalf("now ages no grid source: %+v", now.Sources) - } - rec.reset() - - for i := 0; i < 40; i++ { - lp.ids[fmt.Sprintf("charger-with-a-long-name-%02d", i)] = true - } - deliver(t, h, MsgNow, nil, struct{}{}) - frame = rec.only(t, MsgNow) - if frame.size != 512 || frame.flags&FlagTrunc == 0 { - t.Fatalf("an overfull now went in %d B with flags %d", frame.size, frame.flags) - } - now = body[Now](t, frame) - if len(now.Loadpoints) == 0 || len(now.Fields) == 0 { - t.Fatalf("a trimmed now kept %d chargers and %d fields", len(now.Loadpoints), len(now.Fields)) - } -} - -// A watch shows the car on each charger and offers the tap when the box -// does not know it (docs/cars.md). One charger asking with four cars to -// choose from, the most it carries, still fits lane 0 beside a second -// charger. Two chargers asking at once with four cars each do not; the box -// then leaves the last charger out and flags TRUNC, as for any now. -func TestNowNamesTheCarOnEachCharger(t *testing.T) { - h, lp, _, rec := newExchangeRig(t) - ids := []string{"5f0c1e2d3a4b5c6d7e8f90a1b2c3d4e5", "0123456789abcdef0123456789abcdef", - "89abcdef0123456789abcdef01234567", "fedcba9876543210fedcba9876543210"} - lp.car = protocol.ChargerCar{Session: "7d1f0c2a9b3e4f50", CarIdentifiedBy: protocol.CarIdentifiedByAsking, - CarCorrected: true, CarCorrectedFrom: ids[1], Choose: ids} - lp.socSource, lp.socEstimate = protocol.CarSoCVehicle, true - deliver(t, h, MsgNow, nil, struct{}{}) - frame := rec.only(t, MsgNow) - if frame.size != 512 || frame.flags&FlagTrunc != 0 { - t.Fatalf("now with an asking charger went in %d B with flags %d", frame.size, frame.flags) - } - now := body[Now](t, frame) - got := now.Loadpoints[0] - if got.Session != lp.car.Session || got.CarIdentifiedBy != protocol.CarIdentifiedByAsking || len(got.Choose) != 4 || - hex.EncodeToString(got.Choose[3]) != ids[3] || !got.CarCorrected || got.SocSource != protocol.CarSoCVehicle || !got.SocEstimate { - t.Fatalf("charger %+v", got) - } - raw := body[map[string]any](t, frame) - lps, _ := raw["loadpoints"].([]any) - first := stringKeys(lps[0]) - if _, has := first["carCorrectedFrom"]; has { - t.Fatal("now carries carCorrectedFrom, which has no room in 512 B") - } - one, err := cborBytes(got) - if err != nil { - t.Fatal(err) - } - t.Logf("an asking charger with four cars takes %d B of now", one) - - // A second charger with its car fits beside it. - rec.reset() - lp.ids["lp2"] = true - lp.carByID = map[string]protocol.ChargerCar{"lp2": {Session: "0a1b2c3d4e5f6071", CarID: ids[0], CarIdentifiedBy: protocol.CarIdentifiedByTap}} - deliver(t, h, MsgNow, nil, struct{}{}) - if frame := rec.only(t, MsgNow); frame.flags&FlagTrunc != 0 || len(body[Now](t, frame).Loadpoints) != 2 { - t.Fatalf("two chargers with their cars did not fit: flags %d", frame.flags) - } -} - -// A removed watch's read is refused and its session ended, as a command is. -func TestARemovedWatchReadsNothing(t *testing.T) { - h, _, _, rec := newExchangeRig(t) - h.cfg.Grants.(*fakeGrants).revoke() - - deliver(t, h, MsgNow, nil, struct{}{}) - if rec.has(MsgNow) { - t.Fatal("a removed watch read the house") - } - if e := body[ErrorBody](t, rec.only(t, MsgError)); e.Code != ErrGrantRevoked { - t.Fatalf("the refusal is %s", e.Code) - } - if !rec.has(MsgSessionTerminate) { - t.Fatal("the session went on") - } -} - -// The pad is a lane 0 tick of the session's one size. -func TestThePadIsALaneZeroTick(t *testing.T) { - h, _, _, _ := newExchangeRig(t) - raw, err := h.PadFrame() - if err != nil { - t.Fatal(err) - } - f, err := testCodec{}.DecodeFrame(raw) - if err != nil { - t.Fatal(err) - } - if len(raw) != 512 || f.Lane != LaneControl || f.Envelope.T != MsgTick { - t.Fatalf("the pad is %d B, lane %d, %q", len(raw), f.Lane, f.Envelope.T) - } -} - -func cborBytes(v any) (int, error) { - b, err := cbor.Marshal(v) - return len(b), err -} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index fa049f666..d8d89331f 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -81,12 +81,6 @@ type Config struct { // next session act a second time. Commands *CmdLog - // Exchange makes this a watch's exchange session (ADR 0009, decision 2). - // It takes hello, cmd and now, and answers every other request - // E_UNKNOWN_OP. It never streams and pushes no plan after a command. - // Every frame it sends is lane 0 apart from hello_ok. - Exchange bool - // Caps is what this box advertises. Names must come from the generated // contract constants; a typo is refused at construction rather than // silently hiding a feature in the app. @@ -300,7 +294,7 @@ func New(cfg Config) (*Handler, error) { // An exchange sends nothing unasked; a watch reads the plan from FTW // Cloud instead. An agent reads the plan by asking, never by a push, // so its reads stay counted and in the audit. - if cfg.PlanFeed != nil && !cfg.Exchange && !h.isAgent() { + if cfg.PlanFeed != nil && !h.isAgent() { cfg.PlanFeed.join(h) } return h, nil @@ -433,9 +427,6 @@ func dropped(err error) error { } func (h *Handler) dispatch(ctx context.Context, env Envelope) error { - if h.cfg.Exchange { - return h.dispatchExchange(ctx, env) - } if h.isAgent() { return h.dispatchAgent(ctx, env) } @@ -1217,9 +1208,7 @@ func (h *Handler) settleAndReport(cmdID string, res CmdResult) error { if err := h.sendCmdResult(res); err != nil { return err } - // An exchange answers with two frames of one size and nothing after - // them, so a watch reads the new plan from FTW Cloud instead. - if res.State == CmdApplied && !h.cfg.Exchange { + if res.State == CmdApplied { return h.sendPlan(nil) } return nil @@ -1511,3 +1500,15 @@ func (h *Handler) sendInLine(f Frame) error { } return s.SendInLine(bytes) } + +func encodedSize(t string, body any) (int, error) { + env, err := newEnvelope(t, nil, body) + if err != nil { + return 0, err + } + raw, err := EncodeEnvelope(env) + if err != nil { + return 0, err + } + return len(raw), nil +} diff --git a/go/internal/appproto/plan_feed_test.go b/go/internal/appproto/plan_feed_test.go index 5e4899855..07d3fac0e 100644 --- a/go/internal/appproto/plan_feed_test.go +++ b/go/internal/appproto/plan_feed_test.go @@ -32,7 +32,7 @@ func planAt(startMs int64) *mpc.Plan { } // newFeedRig is a phone's session on a box with a charger and a feed. -func newFeedRig(t *testing.T, feed *PlanFeed, caller apiauth.Caller, grants *fakeGrants, exchange bool) (*Handler, *fakeBox, *recorder, *fakeChargers) { +func newFeedRig(t *testing.T, feed *PlanFeed, caller apiauth.Caller, grants *fakeGrants) (*Handler, *fakeBox, *recorder, *fakeChargers) { t.Helper() clock := &fakeClock{uptimeMs: 60_000, now: time.UnixMilli(1_760_000_000_000)} box := &fakeBox{identity: Identity{ID: "ftw-0001", Build: "test", TZ: "Europe/Stockholm"}} @@ -42,7 +42,7 @@ func newFeedRig(t *testing.T, feed *PlanFeed, caller apiauth.Caller, grants *fak rec := &recorder{} h, err := New(Config{ Clock: clock, Site: box, Info: box, Modes: box, Plans: box, - PlanChargers: chargers, PlanFeed: feed, Exchange: exchange, + PlanChargers: chargers, PlanFeed: feed, Codec: testCodec{}, Sender: rec, Caller: caller, Grants: grants, Commands: NewCmdLog(), }) if err != nil { @@ -81,7 +81,7 @@ func settled(rec *recorder) []sent { func TestAReplanPushesThePlanWithTheChargerToAStreamingPhone(t *testing.T) { feed := NewPlanFeed(testSettle) - h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) // A car is plugged in and the planner publishes a plan that charges it. @@ -116,7 +116,7 @@ func TestAReplanPushesThePlanWithTheChargerToAStreamingPhone(t *testing.T) { // is one more push, of the newest plan. func TestTheFirstChangeGoesAtOnceAndABurstIsOneFollowUp(t *testing.T) { feed := NewPlanFeed(time.Second) - h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) start := time.Now() @@ -147,7 +147,7 @@ func TestTheFirstChangeGoesAtOnceAndABurstIsOneFollowUp(t *testing.T) { // another, says the same thing. func TestTheSamePlanIsNotPushedTwice(t *testing.T) { feed := NewPlanFeed(testSettle) - h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) box.replan(2, box.plan.GeneratedAtMs+60_000) @@ -167,7 +167,7 @@ func TestTheSamePlanIsNotPushedTwice(t *testing.T) { // Pushes race on goroutines of their own; the newest plan still lands last. func TestTheNewestPlanLandsLast(t *testing.T) { feed := NewPlanFeed(time.Millisecond) - h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) var plansMu sync.Mutex @@ -199,7 +199,7 @@ func (l lockedPlans) CeilingW() *int64 { return nil } func TestAPhoneWithoutThePlanScopeGetsNoPush(t *testing.T) { feed := NewPlanFeed(testSettle) - h, _, rec, _ := newFeedRig(t, feed, viewerCaller(), newViewerGrants(), false) + h, _, rec, _ := newFeedRig(t, feed, viewerCaller(), newViewerGrants()) subscribe(t, h, rec) feed.Changed() @@ -211,7 +211,7 @@ func TestAPhoneWithoutThePlanScopeGetsNoPush(t *testing.T) { func TestADemotedPhoneStopsGettingPushes(t *testing.T) { feed := NewPlanFeed(testSettle) grants := newGrants() - h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), grants, false) + h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), grants) subscribe(t, h, rec) grants.setRole(apiauth.RoleViewer) @@ -223,7 +223,7 @@ func TestADemotedPhoneStopsGettingPushes(t *testing.T) { func TestNoPushBeforeTheStreamOrAfterTheSessionCloses(t *testing.T) { feed := NewPlanFeed(testSettle) - h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants()) feed.Changed() if got := settled(rec); len(got) != 0 { @@ -238,20 +238,10 @@ func TestNoPushBeforeTheStreamOrAfterTheSessionCloses(t *testing.T) { } } -func TestAWatchExchangeIsNotOnTheFeed(t *testing.T) { - feed := NewPlanFeed(testSettle) - newFeedRig(t, feed, ownerCaller(), newGrants(), true) - feed.mu.Lock() - defer feed.mu.Unlock() - if len(feed.members) != 0 { - t.Fatal("an exchange joined the feed") - } -} - // The far tail of a long plan gives way to the chargers, never the session. func TestALongPlanWithTwoChargersStillFitsABulkFrame(t *testing.T) { feed := NewPlanFeed(testSettle) - h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) start := box.plan.GeneratedAtMs @@ -290,7 +280,7 @@ func TestALongPlanWithTwoChargersStillFitsABulkFrame(t *testing.T) { // far windows go too, never the session. func TestManyChargersGiveUpTheirFarWindowsBeforeTheSession(t *testing.T) { feed := NewPlanFeed(testSettle) - h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants()) subscribe(t, h, rec) start := box.plan.GeneratedAtMs diff --git a/go/internal/appuplink/agents_test.go b/go/internal/appuplink/agents_test.go index fa39474ac..2b5693de8 100644 --- a/go/internal/appuplink/agents_test.go +++ b/go/internal/appuplink/agents_test.go @@ -47,9 +47,8 @@ func agentTunRig(t *testing.T, now *time.Time) (*tunRig, *AgentAccess) { return r, access } -// An agent's session runs through FTW Cloud's tunnel, and the same key over -// the relay is a stranger. -func TestAnAgentComesThroughTheTunnelAndNotTheRelay(t *testing.T) { +// An agent's session runs through FTW Cloud and ends when its grant is revoked. +func TestAnAgentComesThroughTheTunnel(t *testing.T) { now := time.UnixMilli(1_792_000_000_000) r, access := agentTunRig(t, &now) agent, err := newAppClient() @@ -63,15 +62,6 @@ func TestAnAgentComesThroughTheTunnelAndNotTheRelay(t *testing.T) { t.Fatal(err) } - streams := relayTable(r.slots, r.enroll) - m1, err := agent.message1(r.enroll.StaticKey().Public(), nil) - if err != nil { - t.Fatal(err) - } - if err := streams.open(m1); err == nil { - t.Error("the relay took an agent's handshake") - } - r.open(t, agent, row.ID, 3, nil) r.send(t, agent, row.ID, 3, appproto.MsgHello, appproto.Hello{ Proto: appproto.ProtoRange{Max: appproto.ProtoMax}, App: appproto.AppInfo{Build: "mcp", UA: "agent"}}) diff --git a/go/internal/appuplink/arrival_test.go b/go/internal/appuplink/arrival_test.go index f4f2a5257..e24a86209 100644 --- a/go/internal/appuplink/arrival_test.go +++ b/go/internal/appuplink/arrival_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/gorilla/websocket" "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" @@ -60,7 +59,7 @@ func newArrivalRig(t *testing.T) *arrivalRig { go r.tunnel.Run(ctx) r.app = r.pair(t) - dev := deviceIDOf(r.app.staticPublic()) + dev := cloudwire.DeviceID(r.app.staticPublic()) r.open(t, r.app, dev, 7, nil) r.hello(t, r.app, dev, 7) r.send(t, r.app, dev, 7, appproto.MsgSub, appproto.Sub{Bucket: 512, Hz: 1}) @@ -212,7 +211,7 @@ func (r *arrivalRig) arrive(gap, d time.Duration) { // second and the three of a burst, no more. func TestFastPollsArePacedToFiveFramesASecond(t *testing.T) { r := newArrivalRig(t) - dev := deviceIDOf(r.app.staticPublic()) + dev := cloudwire.DeviceID(r.app.staticPublic()) r.gather(t, r.app, dev, 300*time.Millisecond) go r.arrive(20*time.Millisecond, 2*time.Second) if n := len(r.gather(t, r.app, dev, 2*time.Second)); n < 10 || n > 16 { @@ -224,7 +223,7 @@ func TestFastPollsArePacedToFiveFramesASecond(t *testing.T) { func TestEveryLiveSessionHearsAPoll(t *testing.T) { r := newArrivalRig(t) other := r.pair(t) - dev2 := deviceIDOf(other.staticPublic()) + dev2 := cloudwire.DeviceID(other.staticPublic()) r.open(t, other, dev2, 8, nil) r.send(t, other, dev2, 8, appproto.MsgHello, appproto.Hello{ Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, @@ -280,7 +279,7 @@ func (d *dropLaneZero) TunBudgetUsed() float64 { return float64(d.used.Load()) / // carries it, and the session goes on. func TestAReadingTheLinkDroppedGoesWithTheNextFrame(t *testing.T) { r := newArrivalRig(t) - dev := deviceIDOf(r.app.staticPublic()) + dev := cloudwire.DeviceID(r.app.staticPublic()) link := &dropLaneZero{inner: r.out} r.tunnel.Attach(link) r.gather(t, r.app, dev, 300*time.Millisecond) @@ -307,7 +306,7 @@ func TestAReadingTheLinkDroppedGoesWithTheNextFrame(t *testing.T) { // past 90 % no readings at all, so handshakes, answers and ends still go. func TestTheBudgetStopsPushesThenBeats(t *testing.T) { r := newArrivalRig(t) - dev := deviceIDOf(r.app.staticPublic()) + dev := cloudwire.DeviceID(r.app.staticPublic()) link := &dropLaneZero{inner: r.out} r.tunnel.Attach(link) r.gather(t, r.app, dev, 300*time.Millisecond) @@ -329,7 +328,7 @@ func TestTheBudgetStopsPushesThenBeats(t *testing.T) { // a beat and each poll goes again. func TestHiddenThenVisible(t *testing.T) { r := newArrivalRig(t) - dev := deviceIDOf(r.app.staticPublic()) + dev := cloudwire.DeviceID(r.app.staticPublic()) r.send(t, r.app, dev, 7, appproto.MsgSub, appproto.Sub{Bucket: 512, Hz: 0.2}) r.gather(t, r.app, dev, 300*time.Millisecond) @@ -355,54 +354,6 @@ func TestHiddenThenVisible(t *testing.T) { } } -// The relay keeps its pulse: polls arriving fast change nothing about when -// a relay session's frames go. -func TestARelaySessionKeepsItsPulseWhileArrivalsCome(t *testing.T) { - arrivals := make(chan struct{}, 1) - r := newRigOpts(t, 481234, func(o *Options) { o.Handler = newHandler; o.Arrivals = arrivals }) - app, err := newAppClient() - if err != nil { - t.Fatal(err) - } - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatal(err) - } - conn := openSession(t, r, app, code) - send(t, conn, app, appproto.MsgSub, 512, appproto.Sub{Bucket: 512, Hz: 1}) - if f := receive(t, conn, app); f.Envelope.T != appproto.MsgSnap { - t.Fatalf("got %s, want the snapshot", f.Envelope.T) - } - - stop := make(chan struct{}) - defer close(stop) - go func() { - for { - select { - case <-stop: - return - case arrivals <- struct{}{}: - default: - } - time.Sleep(20 * time.Millisecond) - } - }() - frames := 0 - for start := time.Now(); time.Since(start) < 3*time.Second; { - _ = conn.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)) - kind, _, err := conn.ReadMessage() - if err != nil { - break - } - if kind == websocket.BinaryMessage { - frames++ - } - } - if frames < 2 || frames > 4 { - t.Fatalf("a relay session sent %d frames in 3 s of fast polls, want its pulse's 3", frames) - } -} - // A bulk message the link paces must not fall out of the phone's Noise // replay window behind the lane 0 frames that overtake it. func TestLaneZeroCannotPushABulkFrameOutOfTheReplayWindow(t *testing.T) { diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index a48216432..91766efac 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -1,244 +1,40 @@ -// Package appuplink keeps the box's outbound connection to the content-blind -// relay, and runs the app sessions that arrive over it. -// -// The relay is the only remote path between a box and a phone, and it cannot -// read a watt: it joins one box uplink to a handful of browser streams under a -// rendezvous handle and moves opaque bytes between them. Everything that makes -// those bytes meaningful is in go/protocol/appwire (the Noise session) and -// go/protocol (the messages). See srcfl/ftw-webapp relay/README.md -// for the server's half. -// -// One rule shapes the whole file: a dropped connection is this package's -// problem. There is no reconnect button in the app and no place to put one, so -// every failure here retries on its own and the user sees nothing but a -// freshness stamp slipping behind while it is being solved. +// Package appuplink serves encrypted app sessions through FTW Cloud. package appuplink import ( "context" - "crypto/rand" - "crypto/tls" "errors" - "fmt" "log/slog" - "math/big" - "net/http" - "net/url" - "regexp" - "slices" - "strconv" - "strings" - "sync" - "time" - "github.com/gorilla/websocket" "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" ) -// Endpoint is the production relay. Not configurable: a box that can be -// pointed at another relay is a box a support call can point at an attacker's, -// and the relay is fixed by the protocol, so there is nothing to gain by -// choosing one. -const Endpoint = "wss://relay.ftw.energy" - -// The relay's close codes and control words. Held here rather than shared with -// the server so the box builds without it; relay_contract_test.go fails if -// they drift from srcfl/ftw-webapp relay/src/protocol.ts. -const ( - CloseBadJoin = 4400 - CloseEpoch = 4409 - CloseRotated = 4410 - CloseBusy = 4429 - - CtrlReady = "ready" - CtrlGone = "gone" -) - -const ( - dialTimeout = 15 * time.Second - // writeTimeout is generous because a stalled write is a dead socket, not - // a slow one, and the reconnect path handles dead sockets well. - writeTimeout = 10 * time.Second - - // defaultReadTimeout is three relay heartbeats. The relay pings every fifteen - // seconds; missing three means the socket is gone whatever TCP thinks, - // which is the case a NAT drop produces and neither end otherwise sees. - defaultReadTimeout = 45 * time.Second - - // backoffBase is where the first retry lands. - backoffBase = 500 * time.Millisecond - // backoffCap keeps a box off a struggling relay without letting it sulk. - backoffCap = 60 * time.Second - - // rotateJitter is the longest a box waits before it joins a new epoch's - // handle: after its clock turns the hour, or after the relay closed the - // old handle. - // - // Not zero. Every box turns at the same hour, so this spreads their - // joins, and coming back the instant the relay closes the old handle - // gives it a stronger timing link between the two protocol handles. It - // cannot hide IP or connection continuity. - rotateJitter = 5 * time.Second - - // turnRetry is how long a box waits to try the new hour's handle again - // when the relay refused it with the old epoch. The relay reads its - // clock on its fifteen-second heartbeat, so it can still be on the old - // hour that long after ours turned; turnTries tries cover that. After - // them the box waits for the relay to close the old room, as it always - // has. - turnRetry = 5 * time.Second - turnTries = 4 - - // turnCheck is how often a connected box looks at its clock for a new - // hour. - turnCheck = time.Second - - // correctionLimit is how many epoch corrections we take at full speed - // before backing off. More than a couple means something is wrong rather - // than stale. - correctionLimit = 2 - - // maxEpochCorrection bounds how far the relay may move our clock, in - // epochs. An hour either way covers a box whose RTC drifted; it does not - // cover a relay steering us onto handles of its choosing so it can build - // a table of this household's future names. - maxEpochCorrection = 1 - - // maxFrameBytes matches the relay's own ceiling. A larger message is - // refused there anyway; refusing it here keeps the memory bound local. - maxFrameBytes = 65536 - - // tickInterval is lane 0's cadence over the relay, fixed for the life - // of a session. - tickInterval = time.Second -) - -// Options wires the uplink. Everything except the injectable seams is required. +// Options wires the box's app sessions to the Cloud tunnel. type Options struct { - // Endpoint is the relay origin, no path. Defaults to Endpoint. - Endpoint string - - // Enroll owns the static key, the rendezvous secret and the record of - // which phones are allowed in. - Enroll *appenroll.Identity - - // Handler builds one message-layer handler per app session. The caller - // supplies it so this package needs to know nothing about telemetry, - // control or the planner. + Enroll *appenroll.Identity Handler HandlerBuilder - - // ExchangeHandler builds one handler per watch session, with - // appproto.Config.Exchange set (ADR 0009). Nil leaves the exchange - // socket closed: the box takes no watch, and nothing else changes. - ExchangeHandler HandlerBuilder - - Logger *slog.Logger - - // Arrivals hears each finished driver poll (telemetry.Store.Arrivals). - // Sessions through FTW Cloud send on it; nil leaves them on the beat - // alone, one frame per subscribed period. Relay sessions never use it. + Logger *slog.Logger + // Arrivals hears each finished driver poll. Nil sends on the beat alone. Arrivals <-chan struct{} - - // Deadman, when set, arms the dead man's switch: rows posted to the - // relay's HTTPS side, claims spoken on every connection. Nil leaves - // the uplink exactly as it was — no rows, no claims, no text frames. - Deadman DeadmanSource - - // Injectable seams. Production leaves them nil (and zero). - Dialer *websocket.Dialer - Now func() time.Time - Random func() float64 - DeadmanHTTP *http.Client - // DeadmanRefresh shortens the six-hour re-signing cadence in tests. - DeadmanRefresh time.Duration } -// HandlerBuilder makes one message-layer handler for one app session. -// -// The caller is passed in rather than looked up later, because the handshake -// has just authenticated the device and that is the only moment the answer is -// certain. The grant reader is passed beside it because the caller is a -// snapshot and a revoke can land a second afterwards: the handler re-reads -// the enrolment on every privileged request, and refuses one whose epoch has -// moved. +// HandlerBuilder builds one message handler after the handshake proves the +// caller's key. The grant reader checks that the caller still has access. type HandlerBuilder func(appproto.Sender, apiauth.Caller, appproto.GrantReader) (*appproto.Handler, error) -// Uplink maintains the connection and the sessions on it. +// Uplink serves app sessions on the box's existing FTW Cloud connection. type Uplink struct { - opts Options - log *slog.Logger - dialer *websocket.Dialer - now func() time.Time - random func() float64 - - // epochOffset is the difference between the relay's clock and ours, - // learned from a correction. An offset rather than a remembered epoch - // number, so it stays right as time passes instead of going stale the - // moment the hour turns. A box with a working clock carries zero here - // for its whole life. - epochOffset int64 - attempt int - corrections int - - // liveMu guards live, the relay connections up right now, oldest first. - // Usually one; two for the few minutes after the hour while the box - // holds the old handle and the new. Kept on the uplink so a revoke can - // reach whatever is up right now; empty between connections, when there - // is nothing to drop and nothing to say. - liveMu sync.Mutex - live []*liveLink - - // exchanges is the box's table of watch sessions, nil without an - // ExchangeHandler. One for the box rather than one per connection, so a - // watch's session survives the hour turning under it. - exchanges *exchanges - - // slots holds each device's one session, across the relay's - // connections and the tunnel through FTW Cloud. - slots *slots - // tunnel is the table of sessions FTW Cloud carries. It runs whether or - // not the relay can be reached. tunnel *Tunnel - - // The dead man's switch bookkeeping: which row ids the relay holds, - // so a removed subscription's row is deleted rather than left armed. - deadmanMu sync.Mutex - deadmanPosted map[string]bool - deadmanOrigin string - deadmanHTTP *http.Client - deadmanRefresh time.Duration - readTimeout time.Duration - turnCheck time.Duration - turnRetry time.Duration -} - -// liveLink is one relay connection's sessions and its text-frame writer, -// which the deadman claims ride on. -type liveLink struct { - sessions *sessions - text func(string) error } -// DropSessionsByAppKey tears down every live session a revoked key holds. -// Returns how many went; zero is normal when the phone is not connected. +// DropSessionsByAppKey ends every session a revoked key holds. func (u *Uplink) DropSessionsByAppKey(pub []byte) int { - u.liveMu.Lock() - live := slices.Clone(u.live) - u.liveMu.Unlock() - dropped := 0 - for _, l := range live { - dropped += l.sessions.dropByAppKey(pub) - } - if u.exchanges != nil { - dropped += u.exchanges.dropByAppKey(pub) - } - dropped += u.tunnel.dropByAppKey(pub) - return dropped + return u.tunnel.dropByAppKey(pub) } -// New builds an uplink. +// New builds the Cloud tunnel's app session table. func New(opts Options) (*Uplink, error) { switch { case opts.Enroll == nil: @@ -246,482 +42,15 @@ func New(opts Options) (*Uplink, error) { case opts.Handler == nil: return nil, errors.New("appuplink: Handler is required") } - - endpoint := opts.Endpoint - if endpoint == "" { - endpoint = Endpoint - } - parsed, err := url.Parse(endpoint) - if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" { - return nil, fmt.Errorf("appuplink: %q is not a relay origin", endpoint) - } - if parsed.Scheme != "wss" && parsed.Scheme != "ws" { - return nil, fmt.Errorf("appuplink: %q is not a WebSocket scheme", parsed.Scheme) - } - opts.Endpoint = strings.TrimRight(endpoint, "/") - if opts.Logger == nil { opts.Logger = slog.Default() } - - u := &Uplink{ - opts: opts, - log: opts.Logger.With("component", "appuplink"), - dialer: opts.Dialer, - now: opts.Now, - random: opts.Random, - deadmanOrigin: deadmanOrigin(opts.Endpoint), - deadmanHTTP: opts.DeadmanHTTP, - deadmanPosted: map[string]bool{}, - deadmanRefresh: opts.DeadmanRefresh, - readTimeout: defaultReadTimeout, - turnCheck: turnCheck, - turnRetry: turnRetry, - } - if u.dialer == nil { - u.dialer = productionDialer() - } - if u.now == nil { - u.now = time.Now - } - if u.random == nil { - u.random = randomFloat - } - if u.deadmanHTTP == nil { - u.deadmanHTTP = &http.Client{Timeout: 10 * time.Second} - } - if u.deadmanRefresh <= 0 { - u.deadmanRefresh = deadmanRefreshInterval - } - u.slots = &slots{} - u.tunnel = newTunnel(opts.Handler, opts.Enroll, u.slots, opts.Arrivals, u.log) - if opts.ExchangeHandler != nil { - u.exchanges = &exchanges{ - build: opts.ExchangeHandler, - enroll: opts.Enroll, - static: opts.Enroll.StaticKey(), - log: u.log, - now: time.Now, - cmdFloor: ExchangeCmdFloor, - } - } - return u, nil -} - -func productionDialer() *websocket.Dialer { - return &websocket.Dialer{ - Proxy: http.ProxyFromEnvironment, - HandshakeTimeout: dialTimeout, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, - // A compressed frame's length depends on its content, which would - // undo the padding that keeps the household's load pattern off the - // wire. The relay refuses it too; refusing it here as well means - // neither end can be talked into it alone. - EnableCompression: false, - } + return &Uplink{tunnel: newTunnel(opts.Handler, opts.Enroll, &slots{}, opts.Arrivals, + opts.Logger.With("component", "appuplink"))}, nil } // Run serves phone sessions carried by FTW Cloud until the context ends. -// It opens no connection to the retired relay. func (u *Uplink) Run(ctx context.Context) error { u.tunnel.Run(ctx) return ctx.Err() } - -// once dials, serves until the connection ends, and says how long to wait. -func (u *Uplink) once(ctx context.Context) (time.Duration, error) { - epoch := u.epoch() - handle, err := Handle(u.opts.Enroll.RendezvousSecret(), epoch) - if err != nil { - // Unrecoverable and not the network's fault. Back off rather than - // spin; the operator sees the log and nothing else changes. - return backoffCap, err - } - - conn, err := u.dial(ctx, epoch, handle) - if err != nil { - return u.backoff(), fmt.Errorf("appuplink: dialling the relay: %w", err) - } - - // A connection that lasted is not a connection that is failing. Without - // this, an hourly rotation would compound the backoff until a box that - // works perfectly waits a minute after every rotation. - connectedAt := u.now() - end := u.ride(ctx, u.start(ctx, conn, epoch, handle)) - if u.now().Sub(connectedAt) >= backoffCap { - u.attempt = 0 - } - - switch end.code { - case CloseRotated: - u.adoptEpoch(end.reason) - u.attempt = 0 - u.corrections = 0 - u.log.Info("relay epoch rotated", "epoch", u.epoch()) - return time.Duration(u.random() * float64(rotateJitter)), nil - case CloseEpoch: - u.adoptEpoch(end.reason) - u.corrections++ - if u.corrections > correctionLimit { - return u.backoff(), fmt.Errorf("appuplink: the relay keeps refusing our epoch") - } - // Straight back with the right number. A box whose RTC reads 1970 is - // right after one round trip. - return 0, nil - default: - return u.backoff(), end.err - } -} - -// epoch is the epoch the box would join now: its clock's guess, corrected by -// what the relay last told it. -func (u *Uplink) epoch() int64 { - return CurrentEpoch(u.now().UnixMilli()) + u.epochOffset -} - -// dial opens a socket under one epoch's handle. The relay answers a wrong -// epoch after the upgrade, with a close, so an open socket is not yet an -// accepted one. -func (u *Uplink) dial(ctx context.Context, epoch int64, handle string) (*websocket.Conn, error) { - target := fmt.Sprintf("%s/r/%d/%s/box", u.opts.Endpoint, epoch, handle) - - dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) - defer cancel() - conn, response, err := u.dialer.DialContext(dialCtx, target, nil) - if response != nil && response.Body != nil { - _ = response.Body.Close() - } - return conn, err -} - -// link is one relay connection, served on its own goroutine. -type link struct { - epoch int64 - stop context.CancelFunc - done chan linkEnd -} - -// linkEnd is how a connection ended: the relay's close code and reason, or -// the error when it ended some other way. -type linkEnd struct { - code int - reason string - err error -} - -func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64, handle string) *link { - ctx, stop := context.WithCancel(ctx) - l := &link{epoch: epoch, stop: stop, done: make(chan linkEnd, 1)} - go func() { - defer stop() - // The exchange socket rides beside this connection, under the same - // epoch and handle, and ends with it. - if u.exchanges != nil { - go u.keepExchange(ctx, epoch, handle) - } - code, reason, err := u.serve(ctx, conn) - l.done <- linkEnd{code: code, reason: reason, err: err} - }() - return l -} - -// end stops a connection and waits until its sessions are closed. -func (l *link) end() { - if l == nil { - return - } - l.stop() - <-l.done -} - -// doneOf is l.done, or nil, which never fires in a select, when there is no -// connection. -func doneOf(l *link) chan linkEnd { - if l == nil { - return nil - } - return l.done -} - -// ride serves cur until it ends, and joins each new hour's handle beside it. -// -// The relay turns its epoch on the hour and from then on refuses the old -// one, but it closes each old room only on that room's turn in a five-minute -// spread. A box that moved only when closed left every phone that dialled in -// between waiting in an empty room for up to five minutes; on 13 Oct 2026 a -// phone waited from 09:00 until the relay closed its box's room at 09:03:18. -// So when the hour turns the box also joins the new handle, and keeps the -// old one until the relay closes it: a phone already talking to the box keeps -// it, and a phone that dials the new handle finds it. -// -// It returns how the last connection ended, once no other is up to carry on. -func (u *Uplink) ride(ctx context.Context, cur *link) linkEnd { - // Early joins live only as long as this call, so none outlives it. - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var ( - next *link - joining chan *link - tries int - // tryAt is on the real clock, like every other delay here. Zero - // until this hour's turn has been seen. - tryAt time.Time - ) - check := time.NewTicker(u.turnCheck) - defer check.Stop() - - for { - select { - case end := <-cur.done: - if next == nil || ctx.Err() != nil { - next.end() - return end - } - if end.code == CloseRotated { - u.adoptEpoch(end.reason) - } - cur, next = next, nil - tries, tryAt = 0, time.Time{} - u.attempt, u.corrections = 0, 0 - u.log.Info("relay epoch rotated", "epoch", cur.epoch, "joined_early", true) - - case <-doneOf(next): - // The new handle did not hold. Most often the relay has not - // read its clock since the hour and still says the old epoch, - // which is not a correction to take. The old room carries on. - next = nil - tries++ - tryAt = time.Now().Add(u.turnRetry) - - case l := <-joining: - joining = nil - if l == nil { - tries++ - tryAt = time.Now().Add(u.turnRetry) - continue - } - next = l - - case <-check.C: - want := u.epoch() - if next != nil || joining != nil || tries >= turnTries || want <= cur.epoch { - continue - } - if tryAt.IsZero() { - tryAt = time.Now().Add(time.Duration(u.random() * float64(rotateJitter))) - } - if time.Now().Before(tryAt) { - continue - } - joining = make(chan *link, 1) - go func(result chan<- *link) { result <- u.join(ctx, want) }(joining) - } - } -} - -// join opens the new hour's handle beside the old one. Nil is a failed try. -func (u *Uplink) join(ctx context.Context, epoch int64) *link { - handle, err := Handle(u.opts.Enroll.RendezvousSecret(), epoch) - if err != nil { - return nil - } - conn, err := u.dial(ctx, epoch, handle) - if err != nil { - u.log.Debug("could not join the new hour's handle yet", "err", err) - return nil - } - return u.start(ctx, conn, epoch, handle) -} - -// serve runs one connection. It returns the close code and reason when the -// relay closed us, so the caller can tell a rotation from a fault. -func (u *Uplink) serve(ctx context.Context, conn *websocket.Conn) (int, string, error) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var writeMu sync.Mutex - write := func(message []byte) error { - writeMu.Lock() - defer writeMu.Unlock() - // Socket deadlines take the real clock, never u.now. u.now answers - // "which epoch is it", and a test that pins it to an hour boundary - // would otherwise set every deadline in the past. - if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { - return err - } - return conn.WriteMessage(websocket.BinaryMessage, message) - } - // Text frames share the socket and the mutex. Only the deadman claims - // use them; a box with the switch unarmed never says a text word, so a - // relay that predates the contract never sees one. - writeText := func(word string) error { - writeMu.Lock() - defer writeMu.Unlock() - if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { - return err - } - return conn.WriteMessage(websocket.TextMessage, []byte(word)) - } - - live := &sessions{ - slots: u.slots, - build: u.opts.Handler, - enroll: u.opts.Enroll, - static: u.opts.Enroll.StaticKey(), - write: write, - log: u.log, - } - here := &liveLink{sessions: live, text: writeText} - u.liveMu.Lock() - u.live = append(u.live, here) - u.liveMu.Unlock() - defer func() { - u.liveMu.Lock() - u.live = slices.DeleteFunc(u.live, func(l *liveLink) bool { return l == here }) - u.liveMu.Unlock() - live.closeAll() - }() - - // Post the rows and claim their ids for this connection. The claim is - // per-socket state on the relay, so it must happen on every connect — - // and off this goroutine, because it talks HTTP and the read loop must - // start missing heartbeats on time. And while the connection lasts, the - // rows are re-posted on a six-hour cadence: each row's auth is a VAPID - // signature with 24 hours to live, and a connection quiet for a day - // would otherwise leave the relay holding a farewell it could no longer - // deliver. Same shape as the lane-0 ticker below. - if u.opts.Deadman != nil { - go u.ResyncDeadman() - refresh := time.NewTicker(u.deadmanRefresh) - defer refresh.Stop() - go func() { - for { - select { - case <-ctx.Done(): - return - case <-refresh.C: - u.ResyncDeadman() - } - } - }() - } - - conn.SetReadLimit(maxFrameBytes) - _ = conn.SetReadDeadline(time.Now().Add(u.readTimeout)) - conn.SetPongHandler(func(string) error { - return conn.SetReadDeadline(time.Now().Add(u.readTimeout)) - }) - conn.SetPingHandler(func(message string) error { - if err := conn.SetReadDeadline(time.Now().Add(u.readTimeout)); err != nil { - return err - } - // Gorilla's default PingHandler answers with the same payload. Keep - // that response while also treating the relay heartbeat as activity. - return conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(writeTimeout)) - }) - - // The context ending has to reach a blocked read, and gorilla has no - // other way to interrupt one. - go func() { - <-ctx.Done() - _ = conn.Close() - }() - - ticker := time.NewTicker(tickInterval) - defer ticker.Stop() - go func() { - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - live.tick() - } - } - }() - - for { - kind, message, err := conn.ReadMessage() - if err != nil { - var closeErr *websocket.CloseError - if errors.As(err, &closeErr) { - return closeErr.Code, closeErr.Text, nil - } - return 0, "", err - } - _ = conn.SetReadDeadline(time.Now().Add(u.readTimeout)) - - switch kind { - case websocket.BinaryMessage: - live.deliver(ctx, message) - case websocket.TextMessage: - // The relay says exactly two words, and neither is routed. Any - // third word is a relay this box does not understand, which is - // not a reason to drop a working connection. - switch string(message) { - case CtrlGone: - // The last app left. Its Noise session is gone with it — - // the app restarts the handshake on reconnect — so holding - // ours would be memory nobody can decrypt into. - live.closeAll() - case CtrlReady: - u.log.Debug("an app joined the room") - default: - u.log.Debug("ignoring an unknown relay control word") - } - } - } -} - -// adoptEpoch takes the relay's announced epoch as a clock correction, never as -// an order. -// -// The handle is derived from the epoch, so a relay that can name any epoch can -// make this box compute and publish handle(secret, N) for every N it likes, -// building a table of the household's future names. So: parse strictly, and -// accept only a correction small enough to be a real clock difference. -func (u *Uplink) adoptEpoch(announced string) { - text := strings.TrimSpace(announced) - if !epochPattern.MatchString(text) { - return - } - epoch, err := strconv.ParseInt(text, 10, 64) - if err != nil { - return - } - - offset := epoch - CurrentEpoch(u.now().UnixMilli()) - if offset > maxEpochCorrection || offset < -maxEpochCorrection { - u.log.Warn("ignoring an implausible epoch from the relay", "offset", offset) - return - } - u.epochOffset = offset -} - -var epochPattern = regexp.MustCompile(`^-?\d+$`) - -// backoff is full jitter: the whole delay is random, so a fleet coming back -// after a relay restart never resynchronises into a second outage. -func (u *Uplink) backoff() time.Duration { - ceiling := backoffBase << min(u.attempt, 16) - if ceiling > backoffCap { - ceiling = backoffCap - } - u.attempt = min(u.attempt+1, 16) - return time.Duration(u.random() * float64(ceiling)) -} - -// randomFloat draws from the system source. math/rand would do for jitter, but -// a single source for everything in this package is one fewer thing to reason -// about when the next use is not jitter. -func randomFloat() float64 { - const precision = 1 << 53 - n, err := rand.Int(rand.Reader, big.NewInt(precision)) - if err != nil { - // The system entropy source failing is not something jitter should - // turn into an outage. Half the ceiling is a fine delay. - return 0.5 - } - return float64(n.Int64()) / precision -} diff --git a/go/internal/appuplink/cloudtunnel_test.go b/go/internal/appuplink/cloudtunnel_test.go index 3f9cb4f32..4c9b64b6c 100644 --- a/go/internal/appuplink/cloudtunnel_test.go +++ b/go/internal/appuplink/cloudtunnel_test.go @@ -9,7 +9,6 @@ import ( "crypto/tls" "crypto/x509" "log/slog" - "net" "net/http" "net/http/httptest" "path/filepath" @@ -215,18 +214,6 @@ func (k *memState) SaveConfig(key, value string) error { return nil } -// unreachable is an address nothing listens on: the relay, gone. -func unreachable(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - addr := l.Addr().String() - _ = l.Close() - return "ws://" + addr -} - // With the relay unreachable, a phone the roster lists sends a command // through FTW Cloud and gets the box's answer. The readable copy stays off: // nothing but the roster and the tunnel leaves the box. @@ -254,16 +241,7 @@ func TestACommandThroughFTWCloudWithTheRelayGone(t *testing.T) { SrcGrid: "meter", SrcPV: "meter", SrcBattery: "meter", Logger: slog.New(slog.DiscardHandler), }) } - var relayDials atomic.Int64 - trap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { relayDials.Add(1); w.WriteHeader(503) })) - t.Cleanup(trap.Close) - relay := "ws" + trap.URL[4:] - t.Cleanup(func() { - if relayDials.Load() != 0 { - t.Errorf("retired relay dialled %d times", relayDials.Load()) - } - }) - uplink, err := New(Options{Endpoint: relay, Enroll: enroll, Handler: build, Logger: slog.New(slog.DiscardHandler)}) + uplink, err := New(Options{Enroll: enroll, Handler: build, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } diff --git a/go/internal/appuplink/cmdlog_test.go b/go/internal/appuplink/cmdlog_test.go index a9ca2c580..ed014f02f 100644 --- a/go/internal/appuplink/cmdlog_test.go +++ b/go/internal/appuplink/cmdlog_test.go @@ -7,11 +7,10 @@ import ( "testing" "time" - "github.com/gorilla/websocket" "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/control" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // countingModes is a mode controller that counts how often the box acted. @@ -60,133 +59,35 @@ func handlerOver(modes appproto.ModeController, cmds *appproto.CmdLog) HandlerBu } } -// openSession runs one handshake and hello for a phone and returns its socket. -// code is the pairing code on a first run and nil after it. -func openSession(t *testing.T, r *rig, app *appClient, code []byte) *websocket.Conn { - t.Helper() - - conn := r.dialApp(t, 481234) - message1, err := app.message1(r.enroll.StaticKey().Public(), code) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - if err := app.readMessage2(waitForBinary(t, conn)); err != nil { - t.Fatalf("readMessage2: %v", err) - } - send(t, conn, app, appproto.MsgHello, 512, appproto.Hello{ - Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "test", UA: "pwa"}, - }) - if got := receive(t, conn, app); got.Envelope.T != appproto.MsgHelloOK { - t.Fatalf("first reply is %q, want %q", got.Envelope.T, appproto.MsgHelloOK) - } - return conn -} - -// sendCmdAndRead sends one command and returns its ack and result. An applied -// command also pushes a plan, which is read off the socket here so it cannot -// reach the next session's socket. -func sendCmdAndRead(t *testing.T, conn *websocket.Conn, app *appClient, cmd appproto.Cmd) (appproto.CmdAck, appproto.CmdResult) { - t.Helper() - - send(t, conn, app, appproto.MsgCmd, 512, cmd) - - var ack appproto.CmdAck - if f := receive(t, conn, app); f.Envelope.T != appproto.MsgCmdAck { - t.Fatalf("first answer is %q, want %q", f.Envelope.T, appproto.MsgCmdAck) - } else if err := appproto.Unmarshal(f.Envelope.B, &ack); err != nil { - t.Fatalf("decoding the ack: %v", err) - } - - var res appproto.CmdResult - if f := receive(t, conn, app); f.Envelope.T != appproto.MsgCmdResult { - t.Fatalf("second answer is %q, want %q", f.Envelope.T, appproto.MsgCmdResult) - } else if err := appproto.Unmarshal(f.Envelope.B, &res); err != nil { - t.Fatalf("decoding the result: %v", err) - } - return ack, res -} - -func drainPlan(t *testing.T, conn *websocket.Conn, app *appClient) { - t.Helper() - if f := receive(t, conn, app); f.Envelope.T != appproto.MsgPlan { - t.Fatalf("an applied command was followed by %q, want %q", f.Envelope.T, appproto.MsgPlan) - } -} - -// A phone that loses its socket mid-command retries in a new session. The box -// must answer the retry with what happened the first time, not act again. -// Before the box kept the command log, each session had its own, and this -// retry set the mode twice. +// A command retried after reconnect gets its earlier result and acts once. func TestARetryInANewSessionActsOnce(t *testing.T) { - modes := &countingModes{} - r := newRigWith(t, 481234, handlerOver(modes, appproto.NewCmdLog())) - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - phone, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - - cmd := appproto.Cmd{ - CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", - Op: appproto.OpSetMode, - Args: map[string]any{"mode": string(control.ModeSelfConsumption)}, - NotValidAfterMs: 10 * 60 * 1000, - Expect: appproto.Expect{Rev: 1}, - } - - first := openSession(t, r, phone, code) - firstAck, firstRes := sendCmdAndRead(t, first, phone, cmd) - if firstRes.State != appproto.CmdApplied { - t.Fatalf("first result is %q, want applied", firstRes.State) - } - drainPlan(t, first, phone) - _ = first.Close() - - // The same phone, a new socket and a new handshake: the app keeps its - // static key and nothing else across a reconnect. - again := &appClient{static: phone.static} - second := openSession(t, r, again, nil) - retryAck, retryRes := sendCmdAndRead(t, second, again, cmd) - _ = second.Close() - - if n := modes.count(); n != 1 { - t.Fatalf("the box set the mode %d times for one command id", n) - } - if retryAck.LeaseID != firstAck.LeaseID { - t.Fatalf("the retry got lease %q, the first send %q", retryAck.LeaseID, firstAck.LeaseID) - } - if retryRes.State != firstRes.State { - t.Fatalf("the retry got %q, the first send %q", retryRes.State, firstRes.State) - } - - // Another phone that happens to mint the same id means its own intent. - code, _, err = r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - other, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - third := openSession(t, r, other, code) - otherAck, otherRes := sendCmdAndRead(t, third, other, cmd) - drainPlan(t, third, other) - - if n := modes.count(); n != 2 { - t.Fatalf("the box set the mode %d times; another device's id must count as new", n) - } - if otherRes.State != appproto.CmdApplied { - t.Fatalf("the other device's result is %q, want applied", otherRes.State) - } - if otherAck.LeaseID == firstAck.LeaseID { - t.Fatal("another device's command replayed the first device's lease") + r := newTunRig(t) + app := r.pair(t) + dev := cloudwire.DeviceID(app.staticPublic()) + cmd := setModeCmd("0192f2a0-7c1e-7000-8000-0123456789ab") + r.open(t, app, dev, 1, nil) + r.hello(t, app, dev, 1) + r.send(t, app, dev, 1, appproto.MsgCmd, cmd) + r.receive(t, app, appproto.MsgCmdAck) + var first appproto.CmdResult + if err := appproto.Unmarshal(r.receive(t, app, appproto.MsgCmdResult).Envelope.B, &first); err != nil { + t.Fatal(err) + } + if first.State != appproto.CmdApplied || r.modes.count() != 1 { + t.Fatalf("first command state %q, calls %d", first.State, r.modes.count()) + } + r.receive(t, app, appproto.MsgPlan) + r.tunnel.Drop() + again := &appClient{static: app.static} + r.open(t, again, dev, 2, nil) + r.hello(t, again, dev, 2) + r.send(t, again, dev, 2, appproto.MsgCmd, cmd) + r.receive(t, again, appproto.MsgCmdAck) + var retry appproto.CmdResult + if err := appproto.Unmarshal(r.receive(t, again, appproto.MsgCmdResult).Envelope.B, &retry); err != nil { + t.Fatal(err) + } + if retry.State != first.State || r.modes.count() != 1 { + t.Fatalf("retry state %q, calls %d", retry.State, r.modes.count()) } } diff --git a/go/internal/appuplink/deadman.go b/go/internal/appuplink/deadman.go deleted file mode 100644 index 9e2eca608..000000000 --- a/go/internal/appuplink/deadman.go +++ /dev/null @@ -1,226 +0,0 @@ -package appuplink - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// The dead man's switch, box side. -// -// The one sentence the box cannot send about itself is "your box is out of -// reach". So it writes that sentence down while it still can — rendered from -// the push catalogue and encrypted per subscription, RFC 8291, before it -// ever leaves home — and hands the relay one row per subscription: an id, a -// push endpoint, the ciphertext, a deadline. While the box's uplink socket -// holds a claim on an id the switch is held; when the socket goes and stays -// gone past the deadline, the relay posts the ciphertext to the endpoint, -// once. The relay can never read what it delivers, and what it delivers was -// composed by the box in better days. -// -// The wire contract, implemented identically by the relay in -// srcfl/ftw-webapp: -// -// POST /deadman -// {"id": <32 lowercase hex>, "endpoint": , -// "ct": , -// "auth": , -// "deadline_s": <60..86400>} -// → 204, upsert keyed on id, persisted by the relay -// DELETE /deadman/ → 204, idempotent; the id is the bearer -// capability and there is no other auth -// "deadman " as a text frame on the box uplink claims the id for -// that socket; disconnect starts the countdown, a fresh claim -// cancels it, expiry fires once and re-arms only on the next claim. - -// DeadmanDeadlineS is how long the box may be gone before the relay speaks -// for it. Ten minutes rides out a relay restart, an epoch rotation and an -// ISP hiccup; a power cut is still news while it is news. The contract -// allows 60..86400. -const DeadmanDeadlineS = 600 - -// deadmanRefreshInterval is how often a connected uplink re-posts its rows. -// Each row's auth is a VAPID signature with 24 hours to live (RFC 8292's -// ceiling), and the switch fires within DeadmanDeadlineS of a disconnect — -// so re-signing every six hours means the header the relay holds is never -// more than a quarter spent when it is needed, and a signature that could -// expire in the relay's hands would need the box to be gone for over 18 -// hours while somehow never disconnecting. -const deadmanRefreshInterval = 6 * time.Hour - -// CtrlDeadman is the claim word the box says on its uplink socket. -const CtrlDeadman = "deadman" - -// DeadmanRow is one subscription's pre-encrypted farewell. CT is the -// box.unreachable catalogue entry, rendered and encrypted for exactly this -// subscription's keys — which is why there is one row per subscription and -// never one for the house. -type DeadmanRow struct { - SubscriptionID string - Endpoint string - CT []byte - // Auth is the complete VAPID Authorization header value for Endpoint's - // origin, signed by the box. The subscription is bound to the box's key - // and the push service refuses a POST without it — and the relay must - // never hold the key, so it holds a signature instead, re-signed on - // every resync. See deadmanRefreshInterval for the arithmetic. - Auth string -} - -// DeadmanSource hands the uplink the current rows. main.go implements it -// over the stored subscriptions and the web-push encryptor; this package -// neither stores nor encrypts anything. -type DeadmanSource interface { - DeadmanRows() ([]DeadmanRow, error) -} - -// DeadmanID derives the row id for one subscription: HMAC of the enrolment's -// rendezvous secret over a versioned purpose tag and the subscription id, -// truncated to 16 bytes — 32 lowercase hex chars, as the contract asks. -// Deterministic so a reboot claims the same ids it posted; keyed and tagged -// so the values alone link neither id to a rendezvous handle nor id to id. -// Source IP and request timing remain visible to the relay. -func DeadmanID(secret []byte, subscriptionID string) string { - mac := hmac.New(sha256.New, secret) - mac.Write([]byte("ftw deadman v1\x00" + subscriptionID)) - return hex.EncodeToString(mac.Sum(nil)[:16]) -} - -// deadmanOrigin is the relay's HTTPS side of the same host the uplink dials. -func deadmanOrigin(endpoint string) string { - switch { - case strings.HasPrefix(endpoint, "wss://"): - return "https://" + strings.TrimPrefix(endpoint, "wss://") - case strings.HasPrefix(endpoint, "ws://"): - return "http://" + strings.TrimPrefix(endpoint, "ws://") - } - return endpoint -} - -// ResyncDeadman brings the relay's rows and claims up to date with the -// stored subscriptions: posts every current row, deletes rows whose -// subscription is gone, and claims every current id on the live socket if -// one is up. Called on every connect and after every subscription change; -// safe to call while disconnected, when it updates rows and leaves the -// claiming to the next connect. -func (u *Uplink) ResyncDeadman() { - if u == nil || u.opts.Deadman == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - rows, err := u.opts.Deadman.DeadmanRows() - if err != nil { - u.log.Warn("dead man's switch: could not list rows", "err", err) - return - } - secret := u.opts.Enroll.RendezvousSecret() - desired := make(map[string]DeadmanRow, len(rows)) - for _, row := range rows { - desired[DeadmanID(secret, row.SubscriptionID)] = row - } - - u.deadmanMu.Lock() - previous := make([]string, 0, len(u.deadmanPosted)) - for id := range u.deadmanPosted { - previous = append(previous, id) - } - u.deadmanMu.Unlock() - - posted := make(map[string]bool, len(desired)) - for id, row := range desired { - if err := u.postDeadman(ctx, id, row); err != nil { - // The next connect re-posts everything, and the uplink - // reconnects on its own — the switch self-heals on the same - // cadence as the connection it guards. - u.log.Warn("dead man's switch: post failed", "err", err) - continue - } - posted[id] = true - } - for _, id := range previous { - if _, still := desired[id]; still { - continue - } - if err := u.deleteDeadman(ctx, id); err != nil { - u.log.Warn("dead man's switch: delete failed", "err", err) - } - } - u.deadmanMu.Lock() - u.deadmanPosted = posted - u.deadmanMu.Unlock() - - // Claim what was posted on the newest socket, if there is one. The - // claim is per-connection state on the relay, so serve() runs this again - // on every connect. In the overlap after the hour the older socket keeps - // the claims it made on its own connect, and the relay counts claims, so - // closing one socket does not release the other's. - u.liveMu.Lock() - var text func(string) error - if n := len(u.live); n > 0 { - text = u.live[n-1].text - } - u.liveMu.Unlock() - if text == nil { - return - } - for id := range posted { - if err := text(CtrlDeadman + " " + id); err != nil { - u.log.Warn("dead man's switch: claim failed", "err", err) - return - } - } -} - -func (u *Uplink) postDeadman(ctx context.Context, id string, row DeadmanRow) error { - body, err := json.Marshal(map[string]any{ - "id": id, - "endpoint": row.Endpoint, - "ct": base64.StdEncoding.EncodeToString(row.CT), - "auth": row.Auth, - "deadline_s": DeadmanDeadlineS, - }) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - u.deadmanOrigin+"/deadman", bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return u.doDeadman(req) -} - -func (u *Uplink) deleteDeadman(ctx context.Context, id string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, - u.deadmanOrigin+"/deadman/"+id, nil) - if err != nil { - return err - } - return u.doDeadman(req) -} - -func (u *Uplink) doDeadman(req *http.Request) error { - resp, err := u.deadmanHTTP.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) - if resp.StatusCode >= 300 { - return fmt.Errorf("the relay answered %d", resp.StatusCode) - } - return nil -} diff --git a/go/internal/appuplink/deadman_test.go b/go/internal/appuplink/deadman_test.go deleted file mode 100644 index 86bfbcd48..000000000 --- a/go/internal/appuplink/deadman_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package appuplink - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "log/slog" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/appenroll" - "github.com/srcfl/ftw/go/internal/notifications" -) - -// testVAPIDKey mirrors nova.Identity's signing surface, the same double the -// notifications tests use, so the rows below carry real ES256 headers. -type testVAPIDKey struct{ priv *ecdsa.PrivateKey } - -func newTestVAPIDKey(t *testing.T) *testVAPIDKey { - t.Helper() - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("generate key: %v", err) - } - return &testVAPIDKey{priv: priv} -} - -func (k *testVAPIDKey) PublicKeyHex() string { - out := make([]byte, 64) - k.priv.X.FillBytes(out[:32]) - k.priv.Y.FillBytes(out[32:]) - return hex.EncodeToString(out) -} - -func (k *testVAPIDKey) SignRawHex(msg string) (string, error) { - hash := sha256.Sum256([]byte(msg)) - r, s, err := ecdsa.Sign(rand.Reader, k.priv, hash[:]) - if err != nil { - return "", err - } - sig := make([]byte, 64) - r.FillBytes(sig[:32]) - s.FillBytes(sig[32:]) - return hex.EncodeToString(sig), nil -} - -func (k *testVAPIDKey) publicB64() string { - out := make([]byte, 65) - out[0] = 0x04 - k.priv.X.FillBytes(out[1:33]) - k.priv.Y.FillBytes(out[33:]) - return base64.RawURLEncoding.EncodeToString(out) -} - -// testClock is a movable now, shared between the uplink and the row source -// so a test can let six hours pass without waiting for them. -type testClock struct { - mu sync.Mutex - t time.Time -} - -func (c *testClock) Now() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.t -} - -func (c *testClock) Advance(d time.Duration) { - c.mu.Lock() - c.t = c.t.Add(d) - c.mu.Unlock() -} - -// stubDeadman is main.go's row source in miniature: it holds the current -// subscriptions' rows and signs each row's auth freshly on every call, -// through the same exported builder production uses — which is what lets -// the refresh test below prove a re-post carries a new signature rather -// than a replay of the old one. -type stubDeadman struct { - key notifications.VAPIDKey - now func() time.Time - mu sync.Mutex - rows []DeadmanRow -} - -func (s *stubDeadman) DeadmanRows() ([]DeadmanRow, error) { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]DeadmanRow, 0, len(s.rows)) - for _, row := range s.rows { - auth, err := notifications.DeadmanAuthorization(s.key, row.Endpoint, s.now()) - if err != nil { - return nil, err - } - row.Auth = auth - out = append(out, row) - } - return out, nil -} - -func (s *stubDeadman) set(rows []DeadmanRow) { - s.mu.Lock() - s.rows = rows - s.mu.Unlock() -} - -// newDeadmanRig is newRig with the switch armed. now drives the epoch (and -// should be the same clock the source signs with); refresh shortens the -// six-hour re-posting cadence, zero keeping production's. -func newDeadmanRig(t *testing.T, epoch int64, source DeadmanSource, now func() time.Time, refresh time.Duration) *rig { - t.Helper() - - relay := newFakeRelay(epoch) - t.Cleanup(relay.close) - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - - uplink, err := New(Options{ - Endpoint: relay.url(), - Enroll: enroll, - Handler: newHandler, - Deadman: source, - Logger: slog.New(slog.DiscardHandler), - Now: now, - Random: func() float64 { return 0 }, - DeadmanRefresh: refresh, - }) - if err != nil { - t.Fatalf("New: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - done := make(chan error, 1) - go func() { done <- uplink.runRelay(ctx) }() - - return &rig{relay: relay, enroll: enroll, uplink: uplink, cancel: cancel, done: done} -} - -func fixedNow(epoch int64) func() time.Time { - now := time.UnixMilli(epoch * EpochMs) - return func() time.Time { return now } -} - -func waitForDeadman(t *testing.T, relay *fakeRelay, ok func(rows map[string]deadmanRowWire, claims []string) bool) (map[string]deadmanRowWire, []string) { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for { - rows, claims := relay.deadmanState() - if ok(rows, claims) { - return rows, claims - } - if time.Now().After(deadline) { - t.Fatalf("dead man state never settled: rows=%v claims=%v", rows, claims) - } - select { - case <-relay.deadman: - case <-time.After(20 * time.Millisecond): - } - } -} - -// vapidClaims unpacks a stored row's Authorization header: the JWT's aud -// and exp, and the k= public key it names. -func vapidClaims(t *testing.T, auth string) (aud string, exp int64, k string) { - t.Helper() - if !strings.HasPrefix(auth, "vapid t=") { - t.Fatalf("auth = %q", auth) - } - parts := strings.SplitN(strings.TrimPrefix(auth, "vapid t="), ", k=", 2) - if len(parts) != 2 { - t.Fatalf("auth = %q", auth) - } - segments := strings.Split(parts[0], ".") - if len(segments) != 3 { - t.Fatalf("JWT has %d segments", len(segments)) - } - raw, err := base64.RawURLEncoding.DecodeString(segments[1]) - if err != nil { - t.Fatalf("claims: %v", err) - } - var claims struct { - Aud string `json:"aud"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(raw, &claims); err != nil { - t.Fatal(err) - } - return claims.Aud, claims.Exp, parts[1] -} - -// The id is 32 lowercase hex chars, stable across calls, different per -// subscription and per secret — the relay must be able to key on it and -// learn nothing from it. -func TestDeadmanIDShape(t *testing.T) { - secret := make([]byte, 32) - a := DeadmanID(secret, "sub1") - if len(a) != 32 { - t.Fatalf("id is %d chars, want 32", len(a)) - } - if _, err := hex.DecodeString(a); err != nil { - t.Fatalf("id is not hex: %v", err) - } - if DeadmanID(secret, "sub1") != a { - t.Fatal("the id is not deterministic") - } - if DeadmanID(secret, "sub2") == a { - t.Fatal("two subscriptions share an id") - } - other := make([]byte, 32) - other[0] = 1 - if DeadmanID(other, "sub1") == a { - t.Fatal("two secrets share an id") - } -} - -// On connect the box posts one row per subscription — id, endpoint, -// pre-encrypted ct, auth, deadline, exactly the contract's body — and -// claims every id on the socket it just opened. Each row's auth is a VAPID -// header the push service could verify: aud is that row's own endpoint -// origin, exp the full 24 hours out, k the box's key. -func TestDeadmanRowsPostedAndClaimedOnConnect(t *testing.T) { - const epoch = 481234 - key := newTestVAPIDKey(t) - now := fixedNow(epoch) - source := &stubDeadman{key: key, now: now} - source.set([]DeadmanRow{ - {SubscriptionID: "s1", Endpoint: "https://push-a.example/a", CT: []byte("ct-one")}, - {SubscriptionID: "s2", Endpoint: "https://push-b.example/b", CT: []byte("ct-two")}, - }) - r := newDeadmanRig(t, epoch, source, now, 0) - - rows, claims := waitForDeadman(t, r.relay, func(rows map[string]deadmanRowWire, claims []string) bool { - return len(rows) == 2 && len(claims) >= 2 - }) - - secret := r.enroll.RendezvousSecret() - for _, want := range []struct { - sub, endpoint, origin, ct string - }{ - {"s1", "https://push-a.example/a", "https://push-a.example", "ct-one"}, - {"s2", "https://push-b.example/b", "https://push-b.example", "ct-two"}, - } { - id := DeadmanID(secret, want.sub) - row, ok := rows[id] - if !ok { - t.Fatalf("no row for subscription %s (id %s): %v", want.sub, id, rows) - } - if row.Endpoint != want.endpoint { - t.Fatalf("row endpoint = %q, want %q", row.Endpoint, want.endpoint) - } - ct, err := base64.StdEncoding.DecodeString(row.CT) - if err != nil || string(ct) != want.ct { - t.Fatalf("row ct = %q (%v), want %q", row.CT, err, want.ct) - } - if row.DeadlineS != DeadmanDeadlineS { - t.Fatalf("deadline_s = %d, want %d", row.DeadlineS, DeadmanDeadlineS) - } - aud, exp, k := vapidClaims(t, row.Auth) - if aud != want.origin { - t.Fatalf("auth aud = %q, want this row's own origin %q", aud, want.origin) - } - if wantExp := now().Add(24 * time.Hour).Unix(); exp != wantExp { - t.Fatalf("auth exp = %d, want 24h out (%d)", exp, wantExp) - } - if k != key.publicB64() { - t.Fatalf("auth k = %q, want the box's key %q", k, key.publicB64()) - } - claimed := false - for _, c := range claims { - if c == id { - claimed = true - } - } - if !claimed { - t.Fatalf("id %s was posted but never claimed: %v", id, claims) - } - } -} - -// A removed subscription's row is deleted on resync — the relay must not -// keep a farewell for a phone that no longer exists — and the remaining -// ids are re-claimed. -func TestDeadmanResyncDeletesRemovedRows(t *testing.T) { - const epoch = 481234 - key := newTestVAPIDKey(t) - now := fixedNow(epoch) - source := &stubDeadman{key: key, now: now} - source.set([]DeadmanRow{ - {SubscriptionID: "s1", Endpoint: "https://push.example/a", CT: []byte("ct-one")}, - {SubscriptionID: "s2", Endpoint: "https://push.example/b", CT: []byte("ct-two")}, - }) - r := newDeadmanRig(t, epoch, source, now, 0) - waitForDeadman(t, r.relay, func(rows map[string]deadmanRowWire, claims []string) bool { - return len(rows) == 2 && len(claims) >= 2 - }) - - // The phone behind s2 was revoked; its subscription went with it. - source.set([]DeadmanRow{ - {SubscriptionID: "s1", Endpoint: "https://push.example/a", CT: []byte("ct-one")}, - }) - r.uplink.ResyncDeadman() - - secret := r.enroll.RendezvousSecret() - gone := DeadmanID(secret, "s2") - kept := DeadmanID(secret, "s1") - rows, _ := waitForDeadman(t, r.relay, func(rows map[string]deadmanRowWire, claims []string) bool { - _, still := rows[gone] - return len(rows) == 1 && !still - }) - if _, ok := rows[kept]; !ok { - t.Fatalf("the surviving subscription's row went too: %v", rows) - } -} - -// While the connection lasts, the rows are re-posted on the refresh cadence -// with a freshly signed header each time: six hours after the first post -// the relay must hold a JWT expiring six hours later too, or the header -// would one day expire in the relay's hands and the switch would fire with -// a voice the push service refuses. -func TestDeadmanRefreshRepostsWithFreshJWT(t *testing.T) { - const epoch = 481234 - key := newTestVAPIDKey(t) - clock := &testClock{t: time.UnixMilli(epoch * EpochMs)} - source := &stubDeadman{key: key, now: clock.Now} - source.set([]DeadmanRow{ - {SubscriptionID: "s1", Endpoint: "https://push.example/a", CT: []byte("ct-one")}, - }) - r := newDeadmanRig(t, epoch, source, clock.Now, 40*time.Millisecond) - - id := DeadmanID(r.enroll.RendezvousSecret(), "s1") - rows, _ := waitForDeadman(t, r.relay, func(rows map[string]deadmanRowWire, _ []string) bool { - _, ok := rows[id] - return ok - }) - _, exp0, _ := vapidClaims(t, rows[id].Auth) - - // Six hours pass while the connection holds. The next refresh tick must - // re-post the row signed against the moved clock — a replayed header - // would keep the old exp. - clock.Advance(6 * time.Hour) - rows, _ = waitForDeadman(t, r.relay, func(rows map[string]deadmanRowWire, _ []string) bool { - row, ok := rows[id] - if !ok { - return false - } - _, exp, _ := vapidClaims(t, row.Auth) - return exp != exp0 - }) - _, exp1, _ := vapidClaims(t, rows[id].Auth) - if got, want := exp1-exp0, int64(6*3600); got != want { - t.Fatalf("exp moved by %ds, want the clock's 6h (%ds)", got, want) - } -} - -// With the switch unarmed the box says no text word at all — a relay that -// predates the contract must see exactly the traffic it always saw. -func TestUnarmedUplinkSaysNoTextWords(t *testing.T) { - r := newRig(t, 481234) - select { - case <-r.relay.joined: - case <-time.After(5 * time.Second): - t.Fatal("the box never joined") - } - time.Sleep(100 * time.Millisecond) - rows, claims := r.relay.deadmanState() - if len(rows) != 0 || len(claims) != 0 { - t.Fatalf("an unarmed box spoke: rows=%v claims=%v", rows, claims) - } -} diff --git a/go/internal/appuplink/exchange.go b/go/internal/appuplink/exchange.go deleted file mode 100644 index c2ceb903d..000000000 --- a/go/internal/appuplink/exchange.go +++ /dev/null @@ -1,557 +0,0 @@ -package appuplink - -import ( - "bytes" - "context" - "errors" - "fmt" - "log/slog" - "slices" - "sync" - "time" - - "github.com/gorilla/websocket" - "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/protocol/appwire" -) - -// The exchange: a watch's one-shot door to the box (ADR 0009, decisions 1 -// and 2; the relay's half is srcfl/ftw-webapp docs/watch-exchange.md). -// -// watchOS gives an app HTTPS and nothing lower, so a watch cannot hold a -// stream. It posts one Noise message to the relay, and the relay hands it to -// the box on a second socket, /r///exchange, as -// id(16) ‖ message. The box answers id ‖ reply on the same socket. The id is -// the relay's own random number; the box copies it back and reads nothing -// into it. -// -// Exchange sessions are a class of their own: a table of at most eight, apart -// from the four stream places, so a watch never pushes a phone out. Losing -// the exchange socket changes nothing for the uplink, local control or the -// planner. - -const ( - // MaxExchangeSessions is how many watch sessions the box holds at once. - MaxExchangeSessions = 8 - - // ExchangeIDBytes is the relay's id in front of every message. - ExchangeIDBytes = 16 - - // exchangeFrameBytes is the one size every frame takes but hello_ok: - // lane 0's 512 B bucket. - exchangeFrameBytes = 512 - // exchangeOpenBytes is handshake message 1 with a lane 0 hello. - exchangeOpenBytes = appwire.Message1Overhead + exchangeFrameBytes - // exchangeRequestBytes is one transport message with one lane 0 frame. - exchangeRequestBytes = appwire.TransportOverhead + exchangeFrameBytes - // exchangeAnswerFrames is how many frames every answer after the - // handshake carries, so a read, a command and a refusal have one length. - exchangeAnswerFrames = 2 - - // A session ends after four exchanges, a minute without one, or three - // minutes, whichever comes first. A watch opens one when its confirm - // screen appears and spends it within seconds. - exchangeUses = 4 - exchangeIdle = 60 * time.Second - exchangeLifetime = 3 * time.Minute - - // exchangeOpensPerMinute bounds handshakes, each of which costs the box - // up to four X25519 operations. The rest are dropped unanswered. - exchangeOpensPerMinute = 60 - - // exchangeSettle is how long the box holds an answer for a command's - // result. Core settles a charger's result in the pass that takes the - // intent, within 2 s for the EV save, so one answer carries both. Past - // this the answer carries a tick in the result's place and the watch - // asks again with the same cmdId. - exchangeSettle = 3 * time.Second - - // ExchangeCmdFloor is the soonest the box answers a watch's cmd, counted - // from when the request arrived (ADR 0009, decision F; Fredrik, 14 - // October 2026). A refusal is ready in a millisecond and an applied - // charge waits for the EV save, so without a floor the time to answer - // would tell the relay which happened. A command that takes longer, an - // EV save of up to 2 s, answers when it is done and waits no more. - ExchangeCmdFloor = 400 * time.Millisecond - - // exchangeInFlight bounds requests handled at once. The relay allows two - // open exchanges per room; this is the box's own ceiling. - exchangeInFlight = 8 - - // exchangeReadLimit admits the largest request plus its id. - exchangeReadLimit = 1024 -) - -// exchangePrologue binds an exchange handshake to this box, and keeps it -// apart from a stream handshake: one can never stand in for the other. -func exchangePrologue(boxStatic []byte) []byte { - return append([]byte("ftw.exchange.v1:"), boxStatic...) -} - -var errExchangeRate = errors.New("appuplink: too many exchange handshakes this minute") - -// exchangeSession is one watch's short conversation with the box. -type exchangeSession struct { - transport *appwire.Transport - handler *appproto.Handler - // appStatic is the watch's key as the handshake authenticated it. - appStatic []byte - openedAt time.Time - - // run lets one request at a time through the handler. A command whose - // answer went without its result may still be running when the watch - // asks again; the second request waits for it. - run sync.Mutex - - mu sync.Mutex - lastAt time.Time - uses int - // gen names the request whose frames are being caught, and catching - // says whether one is. A frame from any other run is dropped. - gen uint64 - catching bool - capture [][]byte -} - -// Send collects the handler's frames for the request being answered now. A -// frame sent when no answer is waiting, such as a result that settled after -// its answer went, is dropped: the command log holds it, and asking again -// returns it. -func (s *exchangeSession) Send(frame []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.catching && len(s.capture) < exchangeAnswerFrames { - s.capture = append(s.capture, append([]byte(nil), frame...)) - } - return nil -} - -// next numbers a request and stamps it at now. -func (s *exchangeSession) next(now time.Time) uint64 { - s.mu.Lock() - defer s.mu.Unlock() - s.uses++ - s.lastAt = now - return uint64(s.uses) -} - -// catch starts collecting frames for one request. -func (s *exchangeSession) catch(gen uint64) { - s.mu.Lock() - defer s.mu.Unlock() - s.gen, s.capture, s.catching = gen, nil, true -} - -// release stops collecting and returns what the request sent. Nil when the -// request never started catching, because an earlier one still held the -// handler. -func (s *exchangeSession) release(gen uint64) [][]byte { - s.mu.Lock() - defer s.mu.Unlock() - if s.gen != gen || !s.catching { - return nil - } - frames := s.capture - s.capture, s.catching = nil, false - return frames -} - -// spent reports whether the session has run its course. -func (s *exchangeSession) spent(now time.Time) bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.uses >= exchangeUses || - now.Sub(s.lastAt) > exchangeIdle || - now.Sub(s.openedAt) > exchangeLifetime -} - -func (s *exchangeSession) close() { - s.handler.Close() - s.transport.Close() -} - -// exchanges is the box's table of watch sessions. One per box: a watch's -// request may arrive on the old hour's socket or the new one, and finds its -// session either way. -type exchanges struct { - build HandlerBuilder - enroll *appenroll.Identity - static appwire.KeyPair - log *slog.Logger - // now is the real clock. Session ages are durations, and the uplink's - // injectable clock answers which epoch it is. - now func() time.Time - // settle is exchangeSettle; a test shortens it. - settle time.Duration - // cmdFloor is ExchangeCmdFloor as New wires it. Zero holds nothing, - // which only a test that builds the table by hand gets. - cmdFloor time.Duration - - mu sync.Mutex - live []*exchangeSession - opens []time.Time -} - -// answer takes one request off the exchange socket and returns the reply, or -// nil for silence. A stranger, a removed key, a spent session, a malformed -// body and a handshake over the rate all get silence. -func (x *exchanges) answer(ctx context.Context, body []byte) []byte { - arrived := time.Now() - x.prune() - switch len(body) { - case exchangeOpenBytes: - reply, err := x.open(ctx, body) - if err != nil { - x.log.Debug("exchange handshake opened no session", "err", err) - return nil - } - return reply - case exchangeRequestBytes: - return x.route(ctx, body, arrived) - default: - return nil - } -} - -// open runs the responder half of a handshake for a watch. The watch's hello -// rides in message 1 and its hello_ok in message 2, so a session costs one -// round trip. -func (x *exchanges) open(ctx context.Context, message1 []byte) ([]byte, error) { - if !x.allowOpen() { - return nil, errExchangeRate - } - handshake, err := appwire.NewResponder(appwire.HandshakeOptions{ - StaticKey: x.static, - Prologue: exchangePrologue(x.static.Public()), - }) - if err != nil { - return nil, err - } - payload, err := handshake.ReadMessage(message1) - if err != nil { - return nil, err - } - frame, err := appwire.DecodeFrame(payload) - if err != nil { - return nil, err - } - if frame.Envelope.T != appproto.MsgHello { - return nil, fmt.Errorf("appuplink: message 1 carries %q, not a hello", frame.Envelope.T) - } - var hello appproto.Hello - if err := appproto.Unmarshal(frame.Envelope.B, &hello); err != nil { - return nil, err - } - - // Checked before anything is written: answering a stranger would confirm - // that a box is on this handle and hand them a fresh ephemeral key. - grant, err := x.enroll.AuthoriseWatch(handshake.RemoteStatic(), hello.Code) - if err != nil { - return nil, err - } - - now := x.now() - sess := &exchangeSession{appStatic: handshake.RemoteStatic(), openedAt: now, lastAt: now} - handler, err := x.build(sess, apiauth.Caller{ - Subject: subjectOf(grant.DeviceID), - Kind: apiauth.KindApp, - Role: grant.Role, - Scopes: appproto.ScopesForGrant(grant.Role, grant.ParentRole), - Epoch: grant.Epoch, - }, liveGrant{enroll: x.enroll, deviceID: grant.DeviceID}) - if err != nil { - return nil, err - } - sess.handler = handler - - sess.catch(0) - err = handler.Handle(ctx, payload) - frames := sess.release(0) - if err != nil || len(frames) != 1 { - handler.Close() - return nil, fmt.Errorf("appuplink: hello made %d frames: %v", len(frames), err) - } - reply, err := handshake.WriteMessage(frames[0]) - if err != nil { - handler.Close() - return nil, err - } - noise, err := handshake.Split() - if err != nil { - handler.Close() - return nil, err - } - sess.transport = appwire.NewTransport(noise) - x.admit(sess) - return reply, nil -} - -// route finds the session a transport message belongs to by letting each -// one's AEAD try, and runs the exchange in it. -func (x *exchanges) route(ctx context.Context, message []byte, arrived time.Time) []byte { - x.mu.Lock() - live := slices.Clone(x.live) - x.mu.Unlock() - - for _, sess := range live { - frame, err := sess.transport.Decrypt(message) - if err != nil { - continue - } - reply := x.exchange(ctx, sess, frame) - if reply != nil && x.cmdFloor > 0 && isCmd(frame) { - holdUntil(ctx, arrived.Add(x.cmdFloor)) - } - if sess.spent(x.now()) { - x.drop(sess) - } - return reply - } - return nil -} - -// isCmd reports whether a request frame is a command. -func isCmd(frame []byte) bool { - f, err := appwire.DecodeFrame(frame) - return err == nil && f.Envelope.T == appproto.MsgCmd -} - -// holdUntil waits for a moment on the real clock, or for the socket to go. -func holdUntil(ctx context.Context, at time.Time) { - wait := time.Until(at) - if wait <= 0 { - return - } - timer := time.NewTimer(wait) - defer timer.Stop() - select { - case <-timer.C: - case <-ctx.Done(): - } -} - -// exchange runs one request through the session's handler and seals the -// answer: exactly two lane 0 frames. A command's ack and result when the -// result settles in time; otherwise a tick takes the missing place. -func (x *exchanges) exchange(ctx context.Context, sess *exchangeSession, frame []byte) []byte { - gen := sess.next(x.now()) - done := make(chan error, 1) - go func() { - sess.run.Lock() - defer sess.run.Unlock() - sess.catch(gen) - done <- sess.handler.Handle(ctx, frame) - }() - - wait := x.settle - if wait <= 0 { - wait = exchangeSettle - } - settle := time.NewTimer(wait) - select { - case err := <-done: - if err != nil { - x.log.Warn("watch exchange failed on a frame", "err", err) - } - case <-settle.C: - case <-ctx.Done(): - } - settle.Stop() - - frames := sess.release(gen) - for len(frames) < exchangeAnswerFrames { - tick, err := sess.handler.PadFrame() - if err != nil { - return nil - } - frames = append(frames, tick) - } - - var reply []byte - for _, f := range frames { - sealed, err := sess.transport.Encrypt(f) - if err != nil { - return nil - } - reply = append(reply, sealed...) - } - return reply -} - -// allowOpen counts a handshake against the minute's budget. -func (x *exchanges) allowOpen() bool { - x.mu.Lock() - defer x.mu.Unlock() - now := x.now() - x.opens = slices.DeleteFunc(x.opens, func(at time.Time) bool { return now.Sub(at) >= time.Minute }) - if len(x.opens) >= exchangeOpensPerMinute { - return false - } - x.opens = append(x.opens, now) - return true -} - -// admit keeps a new session, ending the oldest when the table is full. The -// newcomer is the watch somebody is holding now. -func (x *exchanges) admit(sess *exchangeSession) { - x.mu.Lock() - defer x.mu.Unlock() - x.live = append(x.live, sess) - for len(x.live) > MaxExchangeSessions { - x.live[0].close() - x.live = x.live[1:] - } -} - -// prune ends every session that has run its course. -func (x *exchanges) prune() { - now := x.now() - x.mu.Lock() - defer x.mu.Unlock() - x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { - if s.spent(now) { - s.close() - return true - } - return false - }) -} - -func (x *exchanges) drop(sess *exchangeSession) { - x.mu.Lock() - defer x.mu.Unlock() - x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { - if s == sess { - s.close() - return true - } - return false - }) -} - -// dropByAppKey ends every session a removed key holds. Its next message 1 -// meets AuthoriseWatch and gets silence. -func (x *exchanges) dropByAppKey(pub []byte) int { - x.mu.Lock() - defer x.mu.Unlock() - dropped := 0 - x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { - if bytes.Equal(s.appStatic, pub) { - s.close() - dropped++ - return true - } - return false - }) - return dropped -} - -func (x *exchanges) count() int { - x.mu.Lock() - defer x.mu.Unlock() - return len(x.live) -} - -// -------------------------------------------------------------------------- -// The socket -// -------------------------------------------------------------------------- - -// keepExchange holds the exchange socket for one relay connection's epoch and -// handle, and reconnects it with its own backoff until the connection ends. A -// relay that refuses the role, or a socket that drops, costs the watch its -// door and nothing else. -func (u *Uplink) keepExchange(ctx context.Context, epoch int64, handle string) { - attempt := 0 - for ctx.Err() == nil { - target := fmt.Sprintf("%s/r/%d/%s/exchange", u.opts.Endpoint, epoch, handle) - dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) - conn, response, err := u.dialer.DialContext(dialCtx, target, nil) - cancel() - if response != nil && response.Body != nil { - _ = response.Body.Close() - } - if err == nil { - started := time.Now() - err = u.serveExchange(ctx, conn) - if time.Since(started) >= backoffCap { - attempt = 0 - } - } - if ctx.Err() != nil { - return - } - u.log.Debug("exchange socket dropped; retrying", "err", err) - - ceiling := min(backoffBase<//exchange beside its uplink, under the -// same handle, and a watch charges through it. -func TestTheBoxOpensAnExchangeBesideItsUplink(t *testing.T) { - charger := &fakeCharger{} - r := newExchangeRig(t, charger) - waitForExchange(t, r.relay) - - handle, err := Handle(r.enroll.RendezvousSecret(), 481234) - if err != nil { - t.Fatal(err) - } - joinedUnder := 0 - for _, h := range r.relay.seenHandles() { - if h == handle { - joinedUnder++ - } - } - if joinedUnder < 2 { - t.Fatalf("the box joined its handle %d times; the uplink and the exchange share it", joinedUnder) - } - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatal(err) - } - phoneKey, err := appwire.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - phone, err := r.enroll.Authorise(phoneKey.Public(), code) - if err != nil { - t.Fatal(err) - } - watchCode, _, err := r.enroll.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) - if err != nil { - t.Fatal(err) - } - w, err := newWatchClient() - if err != nil { - t.Fatal(err) - } - message1, err := w.open(r.enroll.StaticKey().Public(), watchCode) - if err != nil { - t.Fatal(err) - } - message2 := r.relay.post(1, message1, 5*time.Second) - if message2 == nil { - t.Fatal("the box did not answer message 1 on its exchange socket") - } - if _, _, err := w.opened(message2); err != nil { - t.Fatal(err) - } - request, err := w.request(appproto.MsgCmd, chargeNow("socket-1")) - if err != nil { - t.Fatal(err) - } - frames, err := w.answer(r.relay.post(2, request, 5*time.Second)) - if err != nil { - t.Fatal(err) - } - if got := types(frames); len(got) != 2 || got[1] != appproto.MsgCmdResult { - t.Fatalf("the answer carries %v", got) - } - if charger.count() != 1 { - t.Fatalf("the charger saw %d holds", charger.count()) - } - - // Removing the watch through the uplink ends its exchange session too. - if dropped := r.uplink.DropSessionsByAppKey(w.static.Public()); dropped != 1 { - t.Fatalf("DropSessionsByAppKey dropped %d sessions, want the watch's one", dropped) - } -} - -// Losing the exchange socket changes nothing for the uplink, and the box -// opens it again. -func TestLosingTheExchangeLeavesTheUplink(t *testing.T) { - r := newExchangeRig(t, &fakeCharger{}) - waitForExchange(t, r.relay) - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatal(err) - } - conn := r.dialApp(t, 481234) - app, err := newAppClient() - if err != nil { - t.Fatal(err) - } - message1, err := app.message1(r.enroll.StaticKey().Public(), code) - if err != nil { - t.Fatal(err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatal(err) - } - if err := app.readMessage2(waitForBinary(t, conn)); err != nil { - t.Fatal(err) - } - - r.relay.dropExchange() - waitForExchange(t, r.relay) - - send(t, conn, app, appproto.MsgHello, 512, appproto.Hello{ - Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "test", UA: "pwa"}, - }) - if got := receive(t, conn, app); got.Envelope.T != appproto.MsgHelloOK { - t.Fatalf("after the exchange dropped, the phone's session answered %q", got.Envelope.T) - } -} - -// -------------------------------------------------------------------------- -// The floor (decision F) -// -------------------------------------------------------------------------- - -// timed sends one request and returns how long the box took to answer it. -func (r *xrig) timed(t *testing.T, w *watchClient, msgType string, body any) ([]appwire.Frame, time.Duration) { - t.Helper() - request, err := w.request(msgType, body) - if err != nil { - t.Fatal(err) - } - started := time.Now() - reply := r.x.answer(context.Background(), request) - took := time.Since(started) - frames, err := w.answer(reply) - if err != nil { - t.Fatal(err) - } - return frames, took -} - -// A command answers no sooner than the floor, applied or refused, so the -// time to answer does not say which. One that takes longer answers when it -// is done and waits no more. A read is not a command and is not held. -func TestACommandAnswersNoSoonerThanTheFloor(t *testing.T) { - r := newXRig(t) - r.x.cmdFloor = ExchangeCmdFloor - slack := 300 * time.Millisecond - - w, _ := r.addWatch(t) - frames, applied := r.timed(t, w, appproto.MsgCmd, chargeNow("floor-applied")) - if decodeAs[appproto.CmdResult](t, frames[1]).State != appproto.CmdApplied { - t.Fatalf("the charge answered %v", types(frames)) - } - _, refused := r.timed(t, w, appproto.MsgCmd, appproto.Cmd{CmdID: "floor-refused", Op: appproto.OpSetMode, - Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) - _, read := r.timed(t, w, appproto.MsgNow, struct{}{}) - - for name, took := range map[string]time.Duration{"applied": applied, "refused": refused} { - if took < ExchangeCmdFloor || took > ExchangeCmdFloor+slack { - t.Fatalf("an %s command answered after %s, want %s", name, took, ExchangeCmdFloor) - } - } - if read >= ExchangeCmdFloor { - t.Fatalf("a read was held %s", read) - } - - // An EV save longer than the floor sets the time alone. - r.charger.mu.Lock() - r.charger.save = 600 * time.Millisecond - r.charger.mu.Unlock() - w, _ = r.addWatch(t) - _, slow := r.timed(t, w, appproto.MsgCmd, chargeNow("floor-slow")) - if slow < 600*time.Millisecond || slow > 600*time.Millisecond+slack { - t.Fatalf("a charge with a 600 ms save answered after %s; the floor must not add to it", slow) - } -} - -// The box wires the floor; only a table built by hand in a test goes without. -func TestTheUplinkHoldsCommandsForTheFloor(t *testing.T) { - r := newExchangeRig(t, &fakeCharger{}) - if r.uplink.exchanges.cmdFloor != ExchangeCmdFloor { - t.Fatalf("the uplink holds commands for %s, want %s", r.uplink.exchanges.cmdFloor, ExchangeCmdFloor) - } -} diff --git a/go/internal/appuplink/legacy_test.go b/go/internal/appuplink/legacy_test.go deleted file mode 100644 index bbd20be88..000000000 --- a/go/internal/appuplink/legacy_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package appuplink - -import ( - "context" - "time" -) - -// runRelay is kept only for the old protocol's regression fixtures. -func (u *Uplink) runRelay(ctx context.Context) error { - for ctx.Err() == nil { - delay, _ := u.once(ctx) - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - case <-timer.C: - } - } - return ctx.Err() -} diff --git a/go/internal/appuplink/livebox_test.go b/go/internal/appuplink/livebox_test.go index 636f5d252..687117ab2 100644 --- a/go/internal/appuplink/livebox_test.go +++ b/go/internal/appuplink/livebox_test.go @@ -107,7 +107,7 @@ func TestLiveBoxForTheiPhone(t *testing.T) { }) } arrivals := make(chan struct{}, 1) - uplink, err := New(Options{Endpoint: unreachable(t), Enroll: enroll, Handler: build, Arrivals: arrivals, Logger: logger}) + uplink, err := New(Options{Enroll: enroll, Handler: build, Arrivals: arrivals, Logger: logger}) if err != nil { t.Fatal(err) } diff --git a/go/internal/appuplink/realrelay_test.go b/go/internal/appuplink/realrelay_test.go deleted file mode 100644 index 080373f74..000000000 --- a/go/internal/appuplink/realrelay_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package appuplink - -import ( - "bytes" - "context" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "testing" - "time" - - "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/protocol/appwire" -) - -// The exchange end to end: this box, srcfl/ftw-webapp's relay, and a watch in -// Go posting over HTTP (ADR 0009, milestone 5). Skipped unless a relay with -// the exchange route is running: -// -// PORT=8797 HOST=127.0.0.1 node relay/src/main.ts # in srcfl/ftw-webapp -// FTW_RELAY_URL=ws://127.0.0.1:8797 go test ./internal/appuplink -run RealRelay -v -// -// FTW_EV_SAVE_MS sets how long the charger's save takes, so the timing below -// can show what an applied command waits for. The relay allows 30 exchanges a -// minute per room, so the run stays under that. -func TestExchangeThroughARealRelay(t *testing.T) { - endpoint := os.Getenv("FTW_RELAY_URL") - if endpoint == "" { - t.Skip("set FTW_RELAY_URL to a relay with the exchange route") - } - base := "http" + strings.TrimPrefix(endpoint, "ws") - save, _ := strconv.Atoi(os.Getenv("FTW_EV_SAVE_MS")) - - charger := &fakeCharger{save: time.Duration(save) * time.Millisecond} - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatal(err) - } - cmds := appproto.NewCmdLog() - uplink, err := New(Options{ - Endpoint: endpoint, - Enroll: enroll, - Handler: handlerWith(false, charger, cmds), - ExchangeHandler: handlerWith(true, charger, cmds), - Logger: slog.New(slog.DiscardHandler), - }) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { _ = uplink.runRelay(ctx) }() - - code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatal(err) - } - phoneKey, err := appwire.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - phone, err := enroll.Authorise(phoneKey.Public(), code) - if err != nil { - t.Fatal(err) - } - watchCode, _, err := enroll.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) - if err != nil { - t.Fatal(err) - } - w, err := newWatchClient() - if err != nil { - t.Fatal(err) - } - - epoch := CurrentEpoch(time.Now().UnixMilli()) - post := func(body []byte) (int, []byte, time.Duration) { - t.Helper() - handle, err := Handle(enroll.RendezvousSecret(), epoch) - if err != nil { - t.Fatal(err) - } - started := time.Now() - resp, err := http.Post(fmt.Sprintf("%s/x/%d/%s", base, epoch, handle), "application/octet-stream", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - answer, _ := io.ReadAll(resp.Body) - return resp.StatusCode, answer, time.Since(started) - } - - // X1. The box may still be joining, which the relay answers 404 (no box) - // or 501 (a box with no exchange socket yet). - message1, err := w.open(enroll.StaticKey().Public(), watchCode) - if err != nil { - t.Fatal(err) - } - var status int - var message2 []byte - for try := 0; try < 6; try++ { - status, message2, _ = post(message1) - if status == http.StatusConflict { - if n, err := strconv.ParseInt(string(message2), 10, 64); err == nil { - epoch = n - } - continue - } - if status != http.StatusNotFound && status != http.StatusNotImplemented { - break - } - time.Sleep(500 * time.Millisecond) - } - if status != http.StatusOK { - t.Fatalf("X1 through the relay answered %d", status) - } - if len(message2) != appwire.Message2Overhead+1024 { - t.Fatalf("message 2 is %d bytes", len(message2)) - } - hello, _, err := w.opened(message2) - if err != nil { - t.Fatal(err) - } - if hello.Role != apiauth.RoleWatch { - t.Fatalf("hello_ok = %+v", hello) - } - - exchange := func(msgType string, body any) ([]appwire.Frame, time.Duration) { - t.Helper() - request, err := w.request(msgType, body) - if err != nil { - t.Fatal(err) - } - status, answer, took := post(request) - if status != http.StatusOK || len(answer) != exchangeAnswerFrames*exchangeRequestBytes { - t.Fatalf("%s through the relay answered %d with %d bytes", msgType, status, len(answer)) - } - frames, err := w.answer(answer) - if err != nil { - t.Fatal(err) - } - return frames, took - } - reopen := func() { - t.Helper() - message1, err := w.open(enroll.StaticKey().Public(), nil) - if err != nil { - t.Fatal(err) - } - status, message2, _ := post(message1) - if status != http.StatusOK { - t.Fatalf("reopening answered %d", status) - } - if _, _, err := w.opened(message2); err != nil { - t.Fatal(err) - } - } - - // A charge, the same charge again, a refusal and a read: one session. - frames, _ := exchange(appproto.MsgCmd, chargeNow("relay-1")) - if got := types(frames); got[0] != appproto.MsgCmdAck || got[1] != appproto.MsgCmdResult { - t.Fatalf("the charge answered %v", got) - } - frames, _ = exchange(appproto.MsgCmd, chargeNow("relay-1")) - if got := types(frames); got[1] != appproto.MsgCmdResult || charger.count() != 1 { - t.Fatalf("asking again answered %v after %d holds", got, charger.count()) - } - frames, _ = exchange(appproto.MsgCmd, appproto.Cmd{CmdID: "relay-2", Op: appproto.OpSetMode, - Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) - if res := decodeAs[appproto.CmdResult](t, frames[0]); res.Error == nil || res.Error.Code != appproto.ErrScopeDenied { - t.Fatalf("site.mode.set from a watch answered %+v", res) - } - frames, _ = exchange(appproto.MsgNow, struct{}{}) - if frames[0].Envelope.T != appproto.MsgNow { - t.Fatalf("now answered %v", types(frames)) - } - - // Timing (decision F): applied and refused, through the relay. - var applied, refused []time.Duration - for i := 0; i < 8; i++ { - if i%2 == 0 { - reopen() - } - _, took := exchange(appproto.MsgCmd, chargeNow(fmt.Sprintf("relay-timing-%d", i))) - applied = append(applied, took) - _, took = exchange(appproto.MsgCmd, appproto.Cmd{CmdID: fmt.Sprintf("relay-refused-%d", i), Op: appproto.OpSetMode, - Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) - refused = append(refused, took) - } - t.Logf("through the relay, EV save %d ms: applied %s; refused %s", save, spread(applied), spread(refused)) -} - -// TestExchangeTimingAtTheBox times the same two answers without the relay. -// Skipped unless FTW_EXCHANGE_TIMING is set; it reports and asserts nothing. -func TestExchangeTimingAtTheBox(t *testing.T) { - if os.Getenv("FTW_EXCHANGE_TIMING") == "" { - t.Skip("set FTW_EXCHANGE_TIMING=1 to time answers at the box") - } - save, _ := strconv.Atoi(os.Getenv("FTW_EV_SAVE_MS")) - r := newXRig(t) - r.x.cmdFloor = ExchangeCmdFloor - r.charger.save = time.Duration(save) * time.Millisecond - var applied, refused []time.Duration - var w *watchClient - for i := 0; i < 40; i++ { - if i%2 == 0 { - // A second on the rig's clock per handshake keeps the run under - // the sixty a minute the box allows. - r.advance(time.Second) - w, _ = r.addWatch(t) - } - started := time.Now() - r.ask(t, w, appproto.MsgCmd, chargeNow(fmt.Sprintf("box-timing-%d", i))) - applied = append(applied, time.Since(started)) - started = time.Now() - r.ask(t, w, appproto.MsgCmd, appproto.Cmd{CmdID: fmt.Sprintf("box-refused-%d", i), Op: appproto.OpSetMode, - Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) - refused = append(refused, time.Since(started)) - } - t.Logf("at the box, EV save %d ms: applied %s; refused %s", save, spread(applied), spread(refused)) -} - -// spread is p50 and p95 of a sample. -func spread(d []time.Duration) string { - s := slices.Clone(d) - slices.Sort(s) - at := func(p float64) time.Duration { return s[min(len(s)-1, int(p*float64(len(s))))] } - return fmt.Sprintf("p50 %s p95 %s (n=%d)", at(0.5).Round(10*time.Microsecond), at(0.95).Round(10*time.Microsecond), len(s)) -} diff --git a/go/internal/appuplink/relay_contract_test.go b/go/internal/appuplink/relay_contract_test.go deleted file mode 100644 index ffa485b72..000000000 --- a/go/internal/appuplink/relay_contract_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package appuplink - -import "testing" - -// The relay's close codes and control words, written out from srcfl/ftw-webapp -// relay/README.md. -// -// They are duplicated here rather than imported, because the relay is -// TypeScript and the box must build without it. Duplication without a check is -// how two implementations drift, so the check is this: the numbers below are -// copied from the document and compared against the constants the code uses. -// Changing one without the other fails here rather than in a house. -func TestRelayCloseCodesMatchTheDocument(t *testing.T) { - for _, c := range []struct { - name string - got int - want int - }{ - {"bad join, text on a binary channel, or an oversize message", CloseBadJoin, 4400}, - {"wrong epoch; the reason carries the right one", CloseEpoch, 4409}, - {"the epoch turned over; the reason carries the new one", CloseRotated, 4410}, - {"rate limited, room occupied, or the process is at its ceiling", CloseBusy, 4429}, - } { - if c.got != c.want { - t.Errorf("%s: %d, want %d", c.name, c.got, c.want) - } - } - - if CtrlReady != "ready" { - t.Errorf("ready word = %q", CtrlReady) - } - if CtrlGone != "gone" { - t.Errorf("gone word = %q", CtrlGone) - } -} - -// An hour, matching src/lib/carrier/rendezvous.ts. The two ends disagreeing -// costs a round trip rather than correctness, because the relay corrects a -// wrong guess — but only while the guesses are in the same units. -func TestEpochLengthMatchesTheApp(t *testing.T) { - if EpochMs != 3_600_000 { - t.Fatalf("epoch is %d ms, want one hour", EpochMs) - } - if HandleBytes != 16 { - t.Fatalf("handle is %d bytes, want 16", HandleBytes) - } -} diff --git a/go/internal/appuplink/relay_test.go b/go/internal/appuplink/relay_test.go deleted file mode 100644 index a0b0caece..000000000 --- a/go/internal/appuplink/relay_test.go +++ /dev/null @@ -1,380 +0,0 @@ -package appuplink - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" -) - -// fakeRelay is the routing half of srcfl/ftw-webapp relay/src/server.ts: one -// box uplink and up to four browser streams meet under a handle, binary -// messages cross between them, and the relay says two words. -// -// It is a fake rather than the real server because the real one is TypeScript; -// the parts that matter to this package — the join path, the two control -// words and the close codes — are copied, not invented, and -// relay_contract_test.go pins them to the constants the app compiled against. -type fakeRelay struct { - server *httptest.Server - - // wmu serialises writes. A websocket takes one writer at a time, and a - // stream joining or leaving writes to the uplink beside its own reader. - wmu sync.Mutex - - mu sync.Mutex - epoch int64 - uplink *websocket.Conn - streams []*websocket.Conn - handles []string - // rotateTo, when set, closes the next accepted uplink with 4410 and this - // epoch, which is what the relay does when the hour turns. - rotateTo *int64 - // refuseEpoch, when set, closes with 4409 and this epoch, which is what - // the relay does when a box guessed wrong. - refuseEpoch *int64 - - // The dead man's switch half of the contract: rows keyed on id, and - // the claims heard on the uplink socket, in order. - deadmanRows map[string]deadmanRowWire - deadmanClaims []string - - joined chan struct{} - deadman chan struct{} - - // The exchange half: the box's second socket, every answer it writes, - // and how many times it has joined. - exchange *websocket.Conn - answers chan []byte - exchangeJoins int - exchangeJoined chan struct{} -} - -// deadmanRowWire is the POST /deadman body, as the contract writes it. -type deadmanRowWire struct { - ID string `json:"id"` - Endpoint string `json:"endpoint"` - CT string `json:"ct"` - Auth string `json:"auth"` - DeadlineS int `json:"deadline_s"` -} - -func newFakeRelay(epoch int64) *fakeRelay { - r := &fakeRelay{ - epoch: epoch, - joined: make(chan struct{}, 16), - deadman: make(chan struct{}, 64), - answers: make(chan []byte, 64), - - exchangeJoined: make(chan struct{}, 16), - deadmanRows: map[string]deadmanRowWire{}, - } - r.server = httptest.NewServer(http.HandlerFunc(r.handle)) - return r -} - -func (r *fakeRelay) url() string { - return "ws" + strings.TrimPrefix(r.server.URL, "http") -} - -func (r *fakeRelay) close() { r.server.Close() } - -func (r *fakeRelay) handle(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/deadman" || strings.HasPrefix(req.URL.Path, "/deadman/") { - r.handleDeadman(w, req) - return - } - parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/") - if len(parts) != 4 || parts[0] != "r" { - http.Error(w, "join", http.StatusBadRequest) - return - } - epoch, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - http.Error(w, "join", http.StatusBadRequest) - return - } - handle, role := parts[2], parts[3] - - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, req, nil) - if err != nil { - return - } - - r.mu.Lock() - r.handles = append(r.handles, handle) - refuse, rotate, want := r.refuseEpoch, r.rotateTo, r.epoch - r.mu.Unlock() - - select { - case r.joined <- struct{}{}: - default: - } - - switch { - case refuse != nil: - r.mu.Lock() - r.refuseEpoch = nil - r.mu.Unlock() - closeWith(conn, CloseEpoch, strconv.FormatInt(*refuse, 10)) - return - case rotate != nil: - r.mu.Lock() - r.rotateTo = nil - r.epoch = *rotate - r.mu.Unlock() - closeWith(conn, CloseRotated, strconv.FormatInt(*rotate, 10)) - return - case epoch != want: - closeWith(conn, CloseEpoch, strconv.FormatInt(want, 10)) - return - } - - switch role { - case "box": - r.serveUplink(conn) - case "exchange": - r.serveExchange(conn) - default: - r.serveStream(conn) - } -} - -func (r *fakeRelay) serveUplink(conn *websocket.Conn) { - r.mu.Lock() - r.uplink = conn - streams := append([]*websocket.Conn(nil), r.streams...) - r.mu.Unlock() - - // The relay's only two words. Both sides hear them, which is how a peer - // knows there is anybody to talk to. - for _, s := range streams { - r.write(conn, websocket.TextMessage, []byte(CtrlReady)) - r.write(s, websocket.TextMessage, []byte(CtrlReady)) - } - - defer func() { - r.mu.Lock() - if r.uplink == conn { - r.uplink = nil - } - r.mu.Unlock() - _ = conn.Close() - }() - - for { - kind, message, err := conn.ReadMessage() - if err != nil { - return - } - if kind != websocket.BinaryMessage { - // The deadman-aware relay accepts exactly one text word from - // the box: "deadman " claims the id on this socket. - // Anything else remains a protocol breach and drops the - // connection, as before. - word := string(message) - if kind == websocket.TextMessage && strings.HasPrefix(word, CtrlDeadman+" ") { - r.mu.Lock() - r.deadmanClaims = append(r.deadmanClaims, strings.TrimPrefix(word, CtrlDeadman+" ")) - r.mu.Unlock() - select { - case r.deadman <- struct{}{}: - default: - } - continue - } - return - } - // Broadcast, because the relay cannot tell which stream a ciphertext - // belongs to without reading it. - r.mu.Lock() - streams := append([]*websocket.Conn(nil), r.streams...) - r.mu.Unlock() - for _, s := range streams { - r.write(s, websocket.BinaryMessage, message) - } - } -} - -func (r *fakeRelay) serveStream(conn *websocket.Conn) { - r.mu.Lock() - r.streams = append(r.streams, conn) - uplink := r.uplink - r.mu.Unlock() - - if uplink != nil { - r.write(uplink, websocket.TextMessage, []byte(CtrlReady)) - r.write(conn, websocket.TextMessage, []byte(CtrlReady)) - } - - defer func() { - r.mu.Lock() - for i, s := range r.streams { - if s == conn { - r.streams = append(r.streams[:i], r.streams[i+1:]...) - break - } - } - empty := len(r.streams) == 0 - up := r.uplink - r.mu.Unlock() - if empty && up != nil { - r.write(up, websocket.TextMessage, []byte(CtrlGone)) - } - _ = conn.Close() - }() - - for { - kind, message, err := conn.ReadMessage() - if err != nil { - return - } - if kind != websocket.BinaryMessage { - return - } - r.mu.Lock() - up := r.uplink - r.mu.Unlock() - if up != nil { - r.write(up, websocket.BinaryMessage, message) - } - } -} - -// serveExchange holds the box's exchange socket and keeps every answer it -// writes. A test plays the watch's side with post. -func (r *fakeRelay) serveExchange(conn *websocket.Conn) { - r.mu.Lock() - r.exchange = conn - r.exchangeJoins++ - r.mu.Unlock() - select { - case r.exchangeJoined <- struct{}{}: - default: - } - defer func() { - r.mu.Lock() - if r.exchange == conn { - r.exchange = nil - } - r.mu.Unlock() - _ = conn.Close() - }() - for { - kind, message, err := conn.ReadMessage() - if err != nil { - return - } - if kind == websocket.BinaryMessage { - r.answers <- message - } - } -} - -// post is the relay's /x/ route in one call: an id of its own in front of the -// body, down the exchange socket, and the answer that comes back behind the -// same id. Nil when the box stays silent. -func (r *fakeRelay) post(id byte, body []byte, wait time.Duration) []byte { - r.mu.Lock() - conn := r.exchange - r.mu.Unlock() - if conn == nil { - return nil - } - tag := bytes.Repeat([]byte{id}, 16) - r.write(conn, websocket.BinaryMessage, append(append([]byte(nil), tag...), body...)) - deadline := time.After(wait) - for { - select { - case answer := <-r.answers: - if bytes.Equal(answer[:16], tag) { - return answer[16:] - } - case <-deadline: - return nil - } - } -} - -// dropExchange closes the box's exchange socket from the relay's side. -func (r *fakeRelay) dropExchange() { - r.mu.Lock() - conn := r.exchange - r.mu.Unlock() - if conn != nil { - _ = conn.Close() - } -} - -func (r *fakeRelay) write(conn *websocket.Conn, kind int, message []byte) { - r.wmu.Lock() - defer r.wmu.Unlock() - _ = conn.WriteMessage(kind, message) -} - -func (r *fakeRelay) seenHandles() []string { - r.mu.Lock() - defer r.mu.Unlock() - return append([]string(nil), r.handles...) -} - -// handleDeadman is the relay's HTTP half of the dead man's switch contract: -// POST /deadman upserts a row on id, DELETE /deadman/ forgets one, -// idempotently. 204 both ways. A row without a VAPID auth header is refused -// the way the real relay refuses it: the subscription is bound to the box's -// key, so a row the relay could never deliver must not arm the switch. -func (r *fakeRelay) handleDeadman(w http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodPost: - var row deadmanRowWire - if err := json.NewDecoder(req.Body).Decode(&row); err != nil || - len(row.ID) != 32 || row.Endpoint == "" || row.CT == "" || - !strings.HasPrefix(row.Auth, "vapid t=") || - row.DeadlineS < 60 || row.DeadlineS > 86400 { - http.Error(w, "bad row", http.StatusBadRequest) - return - } - r.mu.Lock() - r.deadmanRows[row.ID] = row - r.mu.Unlock() - select { - case r.deadman <- struct{}{}: - default: - } - w.WriteHeader(http.StatusNoContent) - case http.MethodDelete: - id := strings.TrimPrefix(req.URL.Path, "/deadman/") - r.mu.Lock() - delete(r.deadmanRows, id) - r.mu.Unlock() - select { - case r.deadman <- struct{}{}: - default: - } - w.WriteHeader(http.StatusNoContent) - default: - http.Error(w, "method", http.StatusMethodNotAllowed) - } -} - -func (r *fakeRelay) deadmanState() (rows map[string]deadmanRowWire, claims []string) { - r.mu.Lock() - defer r.mu.Unlock() - rows = make(map[string]deadmanRowWire, len(r.deadmanRows)) - for id, row := range r.deadmanRows { - rows[id] = row - } - return rows, append([]string(nil), r.deadmanClaims...) -} - -func closeWith(conn *websocket.Conn, code int, reason string) { - _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason)) - _ = conn.Close() -} diff --git a/go/internal/appuplink/rendezvous.go b/go/internal/appuplink/rendezvous.go deleted file mode 100644 index 0b9a6d854..000000000 --- a/go/internal/appuplink/rendezvous.go +++ /dev/null @@ -1,76 +0,0 @@ -package appuplink - -import ( - "crypto/hkdf" - "crypto/sha256" - "encoding/hex" - "fmt" -) - -// The rendezvous handle: what the box and the app call each other on the relay. -// -// It is the one piece of protocol metadata the relay unavoidably sees, so what -// it is made of decides how much the protocol itself reveals. A stable handle -// — a hash of the box's static key, say — would work perfectly and hand the -// operator a household identifier good for years: every connection, every -// outage, every holiday, joined up under one string. -// -// So it is derived per epoch from a secret the relay never sees: -// -// handle = HKDF-SHA256(secret, info = "ftw/rendezvous/v1/")[0..16] -// -// HKDF-Expand is a PRF, so without the secret two epochs' handles are two -// cryptographically unrelated strings and there is no function the relay can -// compute from the handles alone that links them. This removes a stable -// protocol identifier; it does not hide source IP, timing or connection -// continuity. The relay's only contribution to the derivation is the epoch -// *number*, which it announces to everyone equally because it is its own clock. -// -// This must agree byte for byte with srcfl/ftw-webapp -// src/lib/carrier/rendezvous.ts. Disagreeing means the box and the app sit in -// two different rooms and nobody's house appears on their phone. -const ( - // EpochMs is how long a stable protocol handle lives. An hour limits that - // identifier's lifetime and keeps rotations rare next to the reconnects a - // phone does anyway. Network metadata may still correlate the sockets. - EpochMs = 3_600_000 - - // HandleBytes is 128 bits: long enough that handles never collide, - // short enough for a URL. - HandleBytes = 16 -) - -// CurrentEpoch is the epoch a peer guesses from its own clock. -// -// A guess, not an answer: the relay is the authority and says so in the close -// reason when this is wrong. A Pi with a dead RTC reads 1970, guesses epoch 0 -// and is corrected on its first connect — which is the whole reason the guess -// is allowed to be wrong. -func CurrentEpoch(nowMs int64) int64 { - // Floor, not truncation. Go divides toward zero and the app uses - // Math.floor, so a clock before 1970 would put the two on different - // epochs — the one case this function exists to survive. - if nowMs < 0 { - return -((-nowMs + EpochMs - 1) / EpochMs) - } - return nowMs / EpochMs -} - -// Handle derives this epoch's handle. The secret never leaves the box. -func Handle(secret []byte, epoch int64) (string, error) { - if len(secret) < 16 { - return "", fmt.Errorf("appuplink: rendezvous secret is %d bytes, too short to be a secret", len(secret)) - } - - // Extract with no salt, matching @noble/hashes hkdf(sha256, ikm, - // undefined, info, len), which uses a zero-filled salt of hash length. - prk, err := hkdf.Extract(sha256.New, secret, nil) - if err != nil { - return "", fmt.Errorf("appuplink: hkdf extract: %w", err) - } - okm, err := hkdf.Expand(sha256.New, prk, fmt.Sprintf("ftw/rendezvous/v1/%d", epoch), HandleBytes) - if err != nil { - return "", fmt.Errorf("appuplink: hkdf expand: %w", err) - } - return hex.EncodeToString(okm), nil -} diff --git a/go/internal/appuplink/roster.go b/go/internal/appuplink/roster.go index f445ec766..73c5cbfc1 100644 --- a/go/internal/appuplink/roster.go +++ b/go/internal/appuplink/roster.go @@ -16,7 +16,7 @@ import ( // device with the scopes the box derives for it, a phone's role, or for a // watch what its role and its phone's role share. With Tunnel set, a phone // may open a session through FTW Cloud and the QR code on the screen is -// listed; a watch has no stream and reaches the box through its exchange. +// listed. Watch commands use the paired phone's session. type CloudRoster struct { Enroll *appenroll.Identity Tunnel bool diff --git a/go/internal/appuplink/rotation_test.go b/go/internal/appuplink/rotation_test.go deleted file mode 100644 index 8e655fce7..000000000 --- a/go/internal/appuplink/rotation_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package appuplink - -import ( - "context" - "log/slog" - "net/http" - "net/http/httptest" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/appenroll" - "github.com/srcfl/ftw/go/internal/appproto" -) - -// roomRelay is the part of srcfl/ftw-webapp relay/src/server.ts that -// fakeRelay leaves out: a room per handle, an exact epoch on every join, and a -// rotation that closes each old room with 4410 on its own turn. -// -// The test is the relay's clock. turn() is the heartbeat that notices the -// hour; until then the relay still refuses the new epoch. rotate() is one -// room's turn in the five-minute spread; until then the old room stays open. -type roomRelay struct { - server *httptest.Server - - mu sync.Mutex - epoch int64 - rooms map[string]*relayRoom - // refused counts joins answered with 4409. - refused int -} - -type relayRoom struct { - epoch int64 - uplink *relayPeer - streams []*relayPeer -} - -type relayPeer struct { - conn *websocket.Conn - // writeMu because gorilla allows one writer at a time, and the relay - // writes to a peer from the other peers' read loops. - writeMu sync.Mutex - linked bool -} - -func (p *relayPeer) write(kind int, message []byte) { - p.writeMu.Lock() - defer p.writeMu.Unlock() - _ = p.conn.WriteMessage(kind, message) -} - -func (p *relayPeer) close(code int, reason string) { - p.writeMu.Lock() - defer p.writeMu.Unlock() - _ = p.conn.WriteControl(websocket.CloseMessage, - websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second)) - _ = p.conn.Close() -} - -func newRoomRelay(t *testing.T, epoch int64) *roomRelay { - t.Helper() - r := &roomRelay{epoch: epoch, rooms: map[string]*relayRoom{}} - r.server = httptest.NewServer(http.HandlerFunc(r.handle)) - t.Cleanup(r.server.Close) - return r -} - -func (r *roomRelay) url() string { - return "ws" + strings.TrimPrefix(r.server.URL, "http") -} - -// turn is the relay's heartbeat noticing that the hour has changed. -func (r *roomRelay) turn(epoch int64) { - r.mu.Lock() - r.epoch = epoch - r.mu.Unlock() -} - -// rotate is one old room's turn in the spread: everyone in it hears 4410 and -// the new epoch, and the room is gone. -func (r *roomRelay) rotate(handle string) { - r.mu.Lock() - room := r.rooms[handle] - if room == nil || room.epoch >= r.epoch { - r.mu.Unlock() - return - } - delete(r.rooms, handle) - peers := append([]*relayPeer(nil), room.streams...) - if room.uplink != nil { - peers = append(peers, room.uplink) - } - epoch := r.epoch - r.mu.Unlock() - - for _, p := range peers { - p.close(CloseRotated, strconv.FormatInt(epoch, 10)) - } -} - -func (r *roomRelay) refusals() int { - r.mu.Lock() - defer r.mu.Unlock() - return r.refused -} - -func (r *roomRelay) hasUplink(handle string) bool { - r.mu.Lock() - defer r.mu.Unlock() - room := r.rooms[handle] - return room != nil && room.uplink != nil -} - -func (r *roomRelay) handle(w http.ResponseWriter, req *http.Request) { - if strings.HasPrefix(req.URL.Path, "/deadman") { - w.WriteHeader(http.StatusNoContent) - return - } - parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/") - if len(parts) != 4 || parts[0] != "r" { - http.Error(w, "join", http.StatusBadRequest) - return - } - epoch, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - http.Error(w, "join", http.StatusBadRequest) - return - } - handle, role := parts[2], parts[3] - - conn, err := (&websocket.Upgrader{}).Upgrade(w, req, nil) - if err != nil { - return - } - peer := &relayPeer{conn: conn} - - r.mu.Lock() - if epoch != r.epoch { - // Exact, as server.ts is: the old epoch is refused the moment the - // relay has turned, even while old rooms are still open. - want := r.epoch - r.refused++ - r.mu.Unlock() - peer.close(CloseEpoch, strconv.FormatInt(want, 10)) - return - } - room := r.rooms[handle] - if room == nil { - room = &relayRoom{epoch: epoch} - r.rooms[handle] = room - } - if role == "box" { - if room.uplink != nil { - r.mu.Unlock() - peer.close(CloseBusy, "uplink") - return - } - room.uplink = peer - } else { - room.streams = append(room.streams, peer) - } - r.sync(room) - r.mu.Unlock() - - defer func() { - r.mu.Lock() - if room.uplink == peer { - room.uplink = nil - } - for i, s := range room.streams { - if s == peer { - room.streams = append(room.streams[:i], room.streams[i+1:]...) - break - } - } - if room.uplink == nil && len(room.streams) == 0 { - if r.rooms[handle] == room { - delete(r.rooms, handle) - } - } else { - r.sync(room) - } - r.mu.Unlock() - _ = conn.Close() - }() - - for { - kind, message, err := conn.ReadMessage() - if err != nil { - return - } - if kind != websocket.BinaryMessage { - if role == "box" && strings.HasPrefix(string(message), CtrlDeadman+" ") { - continue - } - return - } - r.mu.Lock() - var to []*relayPeer - if room.uplink == peer { - to = append(to, room.streams...) - } else if room.uplink != nil { - to = append(to, room.uplink) - } - r.mu.Unlock() - for _, p := range to { - p.write(websocket.BinaryMessage, message) - } - } -} - -// sync tells each side whether the other is there, as server.ts does. Called -// with r.mu held. -func (r *roomRelay) sync(room *relayRoom) { - notify := func(p *relayPeer, present bool) { - if p.linked == present { - return - } - p.linked = present - word := CtrlGone - if present { - word = CtrlReady - } - go p.write(websocket.TextMessage, []byte(word)) - } - if room.uplink != nil { - notify(room.uplink, len(room.streams) > 0) - } - for _, s := range room.streams { - notify(s, room.uplink != nil) - } -} - -// manualClock is the box's wall clock, moved by the test. -type manualClock struct { - mu sync.Mutex - t time.Time -} - -func (c *manualClock) Now() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.t -} - -func (c *manualClock) Set(t time.Time) { - c.mu.Lock() - c.t = t - c.mu.Unlock() -} - -type rotationRig struct { - relay *roomRelay - clock *manualClock - enroll *appenroll.Identity - uplink *Uplink -} - -// newRotationRig starts a box whose clock reads one minute before the end of -// epoch, on a relay that is on epoch. -func newRotationRig(t *testing.T, epoch int64) *rotationRig { - t.Helper() - relay := newRoomRelay(t, epoch) - clock := &manualClock{t: time.UnixMilli((epoch+1)*EpochMs - 60_000)} - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - u, err := New(Options{ - Endpoint: relay.url(), - Enroll: enroll, - Handler: newHandler, - Logger: slog.New(slog.DiscardHandler), - Now: clock.Now, - Random: func() float64 { return 0 }, - }) - if err != nil { - t.Fatalf("New: %v", err) - } - u.turnCheck = 5 * time.Millisecond - u.turnRetry = 100 * time.Millisecond - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- u.runRelay(ctx) }() - t.Cleanup(func() { - cancel() - <-done - }) - - r := &rotationRig{relay: relay, clock: clock, enroll: enroll, uplink: u} - r.waitForUplink(t, epoch) - return r -} - -func (r *rotationRig) handle(t *testing.T, epoch int64) string { - t.Helper() - h, err := Handle(r.enroll.RendezvousSecret(), epoch) - if err != nil { - t.Fatalf("Handle: %v", err) - } - return h -} - -func (r *rotationRig) waitForUplink(t *testing.T, epoch int64) { - t.Helper() - handle := r.handle(t, epoch) - deadline := time.Now().Add(5 * time.Second) - for !r.relay.hasUplink(handle) { - if time.Now().After(deadline) { - t.Fatalf("the box never joined epoch %d", epoch) - } - time.Sleep(5 * time.Millisecond) - } -} - -// openSession joins epoch's room as a phone and runs a handshake and a hello. -// It reports whether the box answered within wait. -func (r *rotationRig) openSession(t *testing.T, epoch int64, wait time.Duration) (*websocket.Conn, *appClient, bool) { - t.Helper() - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - conn, _, err := websocket.DefaultDialer.Dial( - r.relay.url()+"/r/"+strconv.FormatInt(epoch, 10)+"/"+r.handle(t, epoch)+"/app", nil) - if err != nil { - t.Fatalf("dialling as an app: %v", err) - } - t.Cleanup(func() { _ = conn.Close() }) - - // The app's own rule: wait for "ready", because a frame written into an - // empty room is dropped. - _ = conn.SetReadDeadline(time.Now().Add(wait)) - for { - kind, message, err := conn.ReadMessage() - if err != nil { - return conn, nil, false - } - if kind == websocket.TextMessage && string(message) == CtrlReady { - break - } - } - - app, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - message1, err := app.message1(r.enroll.StaticKey().Public(), code) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - if err := app.readMessage2(waitForBinary(t, conn)); err != nil { - t.Fatalf("readMessage2: %v", err) - } - send(t, conn, app, appproto.MsgHello, 512, appproto.Hello{ - Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "test", UA: "ios"}, - }) - if got := receive(t, conn, app); got.Envelope.T != appproto.MsgHelloOK { - t.Fatalf("first reply is %q, want %q", got.Envelope.T, appproto.MsgHelloOK) - } - return conn, app, true -} - -// A phone that opens the app just after the hour derives the new hour's -// handle from its clock. The relay has turned and refuses the old epoch, but -// it closes the box's old room only on that room's turn in its five-minute -// spread. Seen on 13 Oct 2026: the phone waited from 09:00 until the relay -// rotated the box at 09:03:18. The box must already be on the new handle. -func TestAPhoneOpenedAfterTheHourFindsTheBox(t *testing.T) { - const epoch = 497742 - r := newRotationRig(t, epoch) - - // 09:00:30 on both clocks. The old room's turn has not come. - r.clock.Set(time.UnixMilli((epoch+1)*EpochMs + 30_000)) - r.relay.turn(epoch + 1) - - if _, _, ok := r.openSession(t, epoch+1, 3*time.Second); !ok { - t.Fatal("a phone on the new hour's handle found no box until the relay rotated the old room") - } -} - -// A phone already talking to the box must not lose it at the hour. The box -// keeps the old handle until the relay closes that room, and the phone moves -// when the relay tells it to. -func TestAPhoneConnectedAcrossTheHourKeepsTheBox(t *testing.T) { - const epoch = 497742 - r := newRotationRig(t, epoch) - conn, app, ok := r.openSession(t, epoch, 3*time.Second) - if !ok { - t.Fatal("no session before the hour") - } - - r.clock.Set(time.UnixMilli((epoch+1)*EpochMs + 30_000)) - r.relay.turn(epoch + 1) - r.waitForUplink(t, epoch+1) - - // Still the old room, still answered. - send(t, conn, app, appproto.MsgSub, 512, appproto.Sub{Bucket: 512}) - if got := receive(t, conn, app); got.Envelope.T != appproto.MsgSnap { - t.Fatalf("after the hour the old session got %q, want %q", got.Envelope.T, appproto.MsgSnap) - } - - // The room's turn. The phone follows to the new handle and the box is - // there; the box did not have to move. - r.relay.rotate(r.handle(t, epoch)) - if _, _, ok := r.openSession(t, epoch+1, 3*time.Second); !ok { - t.Fatal("after the old room rotated the phone found no box on the new handle") - } - if !r.relay.hasUplink(r.handle(t, epoch+1)) { - t.Fatal("the box left the new handle when the old room rotated") - } -} - -// The relay reads its clock on a fifteen-second heartbeat, so just after the -// hour it may still refuse the new epoch. That is "not yet", not a correction -// to an hour ago: the box keeps the old room and tries again. -func TestTheBoxWaitsForARelayThatHasNotTurned(t *testing.T) { - const epoch = 497742 - r := newRotationRig(t, epoch) - - r.clock.Set(time.UnixMilli((epoch+1)*EpochMs + 1_000)) - deadline := time.Now().Add(5 * time.Second) - for r.relay.refusals() == 0 { - if time.Now().After(deadline) { - t.Fatal("the box never tried the new hour's handle") - } - time.Sleep(2 * time.Millisecond) - } - if !r.relay.hasUplink(r.handle(t, epoch)) { - t.Fatal("a refused try took the box out of the old room") - } - - r.relay.turn(epoch + 1) - r.waitForUplink(t, epoch+1) - if !r.relay.hasUplink(r.handle(t, epoch)) { - t.Fatal("the box left the old room before the relay closed it") - } -} diff --git a/go/internal/appuplink/session.go b/go/internal/appuplink/session.go index 91126e8c2..5a35e9576 100644 --- a/go/internal/appuplink/session.go +++ b/go/internal/appuplink/session.go @@ -1,10 +1,6 @@ package appuplink import ( - "bytes" - "context" - "errors" - "log/slog" "sync" "time" @@ -16,7 +12,7 @@ import ( // session is one app's encrypted conversation with this box. // -// A session belongs to one relay connection and never outlives it. The app +// A session belongs to one Cloud tunnel connection and never outlives it. The app // tears its own Noise session down whenever its carrier drops, because the // keys are bound to one handshake and the counters to one stream, so keeping // state across a reconnect here would only be state the peer has forgotten. @@ -67,65 +63,7 @@ func (s *session) close() { } } -// pending is a handshake the box answered whose session has not yet proved -// itself. A message 1 can be replayed by whoever carries it, and the box -// cannot tell a replay from a phone until the phone's first transport -// message decrypts. Until then it takes no device's place, stamps nothing -// and spends no code. -type pending struct { - transport *appwire.Transport - appStatic []byte - deviceID string - commit func() (appenroll.Grant, error) - at time.Time -} - -// pendingKept is how many unproved handshakes a table holds, and how long -// one waits for its first transport message. -const ( - pendingKept = 8 - pendingWait = 30 * time.Second -) - -// sender encrypts one session's frames onto the shared socket. -type sender struct { - transport *appwire.Transport - write func([]byte) error -} - -func (s sender) Send(frame []byte) error { - message, err := s.transport.Encrypt(frame) - if err != nil { - return err - } - return s.write(message) -} - -// sessions is the demultiplexer. -// -// The relay broadcasts the box's frames to every stream in the room and hands -// the box every stream's frames without saying which is which — it cannot say, -// because telling them apart would mean reading them. So the box does what the -// app does in the other direction: try each live session, and let the AEAD -// decide. A message nothing authenticates is either a new handshake or noise, -// and noise is dropped. -type sessions struct { - mu sync.Mutex - live []*session - pending []*pending - build HandlerBuilder - // slots is the box's one slot per device, shared with the tunnel. Nil - // in a test that builds a table alone. - slots *slots - - enroll *appenroll.Identity - static appwire.KeyPair - write func([]byte) error - log *slog.Logger - - // testOut is where a test's write puts what the table sends. - testOut chan []byte -} +const pendingWait = 30 * time.Second // prologue binds a session to the box it was enrolled against, so a captured // handshake cannot be replayed into a different one. Both ends must spell it @@ -135,249 +73,6 @@ func prologue(boxStatic []byte) []byte { return append(tag, boxStatic...) } -// deliver routes one message from the relay. -func (s *sessions) deliver(ctx context.Context, message []byte) { - if s.route(ctx, message) { - return - } - if err := s.open(message); err != nil { - // Anyone who can reach the relay can write to this room, so a - // message that authenticates against nothing is ordinary. Tearing - // the uplink down over one would hand a stranger a way to cut the - // household off. - s.log.Debug("message matched no session and opened none", "err", err) - } -} - -// route hands the message to the session that can decrypt it, if any. A -// message that proves a pending handshake makes its session live first. -func (s *sessions) route(ctx context.Context, message []byte) bool { - s.mu.Lock() - live := append([]*session(nil), s.live...) - waiting := append([]*pending(nil), s.pending...) - s.mu.Unlock() - - for _, p := range waiting { - frame, err := p.transport.Decrypt(message) - if err != nil { - continue - } - sess, err := s.promote(p) - if err != nil { - s.log.Debug("a proved handshake was not admitted", "err", err) - return true - } - if err := sess.handler.Handle(ctx, frame); err != nil { - s.log.Warn("app session failed on a frame", "err", err) - s.drop(sess) - } - return true - } - - for _, sess := range live { - frame, err := sess.transport.Decrypt(message) - if err != nil { - continue - } - if err := sess.handler.Handle(ctx, frame); err != nil { - s.log.Warn("app session failed on a frame", "err", err) - s.drop(sess) - } - return true - } - return false -} - -// open runs the responder half of a handshake and, if the app is allowed in, -// keeps the session. -func (s *sessions) open(message []byte) error { - if len(message) < appwire.Message1Overhead { - return errors.New("appuplink: too short to be handshake message 1") - } - - handshake, err := appwire.NewResponder(appwire.HandshakeOptions{ - StaticKey: s.static, - Prologue: prologue(s.static.Public()), - }) - if err != nil { - return err - } - - payload, err := handshake.ReadMessage(message) - if err != nil { - return err - } - - // The reply is built before the caller is checked, because the app's - // static key is only authenticated once the pattern has run — but it is - // not sent until the check passes. Replying to a stranger would confirm - // that a box is on this handle and hand them a fresh ephemeral key for - // their trouble. - reply, err := handshake.WriteMessage(nil) - if err != nil { - return err - } - - noise, err := handshake.Split() - if err != nil { - return err - } - transport := appwire.NewTransport(noise) - - // The payload is the pairing code from the QR. A device that has already - // paired presents nothing and is recognised by its static key, which is - // what makes the code single-use without breaking reconnects. Checked - // now, committed when the session proves itself. - grant, commit, err := s.enroll.Check(noise.RemoteStatic, payload) - if err != nil { - transport.Close() - return err - } - - s.mu.Lock() - now := time.Now() - kept := s.pending[:0] - for _, p := range s.pending { - if now.Sub(p.at) < pendingWait { - kept = append(kept, p) - } else { - p.transport.Close() - } - } - s.pending = kept - if len(s.pending) >= pendingKept { - s.pending[0].transport.Close() - s.pending = s.pending[1:] - } - s.pending = append(s.pending, &pending{transport: transport, appStatic: noise.RemoteStatic, - deviceID: grant.DeviceID, commit: commit, at: now}) - s.mu.Unlock() - - if err := s.write(reply); err != nil { - return err - } - return nil -} - -// promote makes a proved handshake a live session: it commits the pairing or -// the reconnect stamp, builds the handler from the grant as it stands now, -// and takes the device's one slot. -func (s *sessions) promote(p *pending) (*session, error) { - s.mu.Lock() - for i, q := range s.pending { - if q == p { - s.pending = append(s.pending[:i], s.pending[i+1:]...) - break - } - } - s.mu.Unlock() - - grant, err := p.commit() - if err != nil { - p.transport.Close() - return nil, err - } - - // The grant becomes a caller here, and this is the only place it is - // built. Everything the box later decides about this phone — which - // commands it may send, which routes it may reach — is decided from this - // value, and nothing the phone sends can add to it. - caller := callerOf(grant) - handler, err := s.build( - sender{transport: p.transport, write: s.write}, - caller, - liveGrant{enroll: s.enroll, deviceID: grant.DeviceID}, - ) - if err != nil { - p.transport.Close() - return nil, err - } - - sess := &session{transport: p.transport, handler: handler, appStatic: p.appStatic} - s.admit(sess) - // The device's one slot: a session it held before, here or through FTW - // Cloud, ends now. - sess.setRelease(s.slots.claim(grant.DeviceID, func() { s.drop(sess) })) - - s.log.Info("app session open", "sessions", s.count()) - return sess, nil -} - -// admit adds a session. There is no count to evict by: each device holds one -// slot (see slots), so a phone that reconnects replaces its own session and -// never another device's. The relay lets four streams into a room. -func (s *sessions) admit(sess *session) { - s.mu.Lock() - defer s.mu.Unlock() - s.live = append(s.live, sess) -} - -// dropByAppKey terminates and forgets every session a revoked key is -// running. Termination is best-effort — the socket may already be gone — -// but the forget is not: a frame from a dropped session no longer -// authenticates against anything. -func (s *sessions) dropByAppKey(pub []byte) int { - s.mu.Lock() - defer s.mu.Unlock() - - waiting := s.pending[:0] - for _, p := range s.pending { - if bytes.Equal(p.appStatic, pub) { - p.transport.Close() - continue - } - waiting = append(waiting, p) - } - s.pending = waiting - - dropped := 0 - kept := s.live[:0] - for _, live := range s.live { - if bytes.Equal(live.appStatic, pub) { - // The reason the app already knows how to say: its terminated - // screen reads "your access was withdrawn by its owner". - _ = live.handler.Terminate(appproto.TerminateRevoked) - live.close() - dropped++ - continue - } - kept = append(kept, live) - } - s.live = kept - return dropped -} - -func (s *sessions) drop(sess *session) { - s.mu.Lock() - defer s.mu.Unlock() - - for i, live := range s.live { - if live == sess { - live.close() - s.live = append(s.live[:i], s.live[i+1:]...) - return - } - } -} - -// closeAll ends every session. Called when the relay says the last app left -// and when the socket dies: in both cases the app has already forgotten its -// half, so holding ours would only be memory nobody can decrypt into. -func (s *sessions) closeAll() { - s.mu.Lock() - live := s.live - s.live = nil - for _, p := range s.pending { - p.transport.Close() - } - s.pending = nil - s.mu.Unlock() - - for _, sess := range live { - sess.close() - } -} - // liveGrant is one session's view of its own enrolment, as it stands now. // // The device id and not the key, because that is what the settings page @@ -433,27 +128,3 @@ func callerOf(grant appenroll.Grant) apiauth.Caller { func subjectOf(deviceID string) string { return apiauth.KindApp + ":" + deviceID } - -func (s *sessions) count() int { - s.mu.Lock() - defer s.mu.Unlock() - return len(s.live) -} - -// tick sends the global one-second pulse to every live session. -// -// Each handler gates that pulse to its own subscribed 1 Hz or 0.2 Hz cadence. -// On a due pulse it sends a frame even when nothing changed, so silence within -// that cadence does not reveal whether the house was idle. -func (s *sessions) tick() { - s.mu.Lock() - live := append([]*session(nil), s.live...) - s.mu.Unlock() - - for _, sess := range live { - if err := sess.handler.Tick(); err != nil { - s.log.Warn("app session failed on a tick", "err", err) - s.drop(sess) - } - } -} diff --git a/go/internal/appuplink/site_test.go b/go/internal/appuplink/site_test.go new file mode 100644 index 000000000..5485893de --- /dev/null +++ b/go/internal/appuplink/site_test.go @@ -0,0 +1,91 @@ +package appuplink + +import ( + "context" + "log/slog" + "testing" + "time" + + "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/control" + "github.com/srcfl/ftw/go/internal/mpc" +) + +func enrolOwner(t *testing.T, enroll *appenroll.Identity) { + t.Helper() + code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + app, err := newAppClient() + if err != nil { + t.Fatal(err) + } + if _, err := enroll.Authorise(app.staticPublic(), code); err != nil { + t.Fatal(err) + } +} + +type stubSite struct{} + +func (stubSite) Snapshot() appproto.Snapshot { + return appproto.Snapshot{ + Mode: control.ModeSelfConsumption, + ControlRev: 1, + GridW: 1200, + // Negative while generating. FTW's convention is that positive watts + // flow into the site, so PV is never positive. + PVW: -3000, + BatteryW: 800, + LoadW: 1000, + BatterySoC: 0.55, + BatterySoCKnown: true, + Sources: []appproto.Source{ + {ID: "meter", Kind: "meter", Name: "P1", LastOkUptimeMs: 1000, StaleAfterMs: 5000}, + }, + } +} + +type stubInfo struct{} + +func (stubInfo) Identity() appproto.Identity { + return appproto.Identity{ID: "box", Build: "test", TZ: "Europe/Stockholm"} +} +func (stubInfo) Boot() *appproto.BootProgress { return nil } + +type stubModes struct{} + +func (stubModes) SetMode(context.Context, control.Mode) error { return nil } +func (stubModes) ObservedMode() (control.Mode, bool) { return control.ModeSelfConsumption, true } + +type stubPlans struct{} + +func (stubPlans) Latest() *mpc.Plan { return nil } +func (stubPlans) Rev() uint64 { return 0 } +func (stubPlans) CeilingW() *int64 { return nil } + +// newHandler builds a handler for a test that sends no command, so each one +// gets an empty command log of its own. A test about commands builds its +// handlers over one log, as the box does. +func newHandler( + s appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader, +) (*appproto.Handler, error) { + return appproto.New(appproto.Config{ + Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, + Site: stubSite{}, + Info: stubInfo{}, + Modes: stubModes{}, + Plans: stubPlans{}, + Codec: codec{}, + Sender: s, + Caller: caller, + Grants: grants, + Commands: appproto.NewCmdLog(), + SrcGrid: "meter", + SrcPV: "meter", + SrcBattery: "meter", + Logger: slog.New(slog.DiscardHandler), + }) +} diff --git a/go/internal/appuplink/testdata/exchange-vectors.json b/go/internal/appuplink/testdata/exchange-vectors.json deleted file mode 100644 index 8dffe046c..000000000 --- a/go/internal/appuplink/testdata/exchange-vectors.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "about": "ADR 0009 watch exchange: Noise IK with prologue ftw.exchange.v1: and the box's static key. Every frame but hello_ok is lane 0 in 512 B; every answer after the handshake is two transport messages.", - "protocolName": "Noise_IK_25519_ChaChaPoly_SHA256", - "prologue": "6674772e65786368616e67652e76313a07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c", - "boxStatic": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", - "boxStaticPublic": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c", - "boxEphemeral": "6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80", - "watchStatic": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", - "watchStaticPublic": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b", - "watchEphemeral": "4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60", - "handshakeHash": "774830fbffaa569c1fa626f5c527ad12e1085cb996a2cd60d60028344675ca8b", - "steps": [ - { - "name": "x1.request", - "from": "watch", - "frames": [ - "01000000005aa261746568656c6c6f6162a463617070a26275616777617463686f73656275696c6467766563746f727364636f646550a1a2a3a4a5a6a7a8a9aaabacadaeafb06570726f746fa2636d617801636d696e00676c6f63616c6573f60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4", - "relay": "8182838485868788898a8b8c8d8e8f9064b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4" - }, - { - "name": "x1.answer", - "from": "box", - "frames": [ - "0101000000a9a261746868656c6c6f5f6f6b6162a66372657607646d6f64656466756c6c64726f6c6565776174636865636c6f636ba366736f75726365636e747068757074696d654d7319ea606a73796e63656441744d731b00000199c82cc0006570726f746f016673636f70657384726674772e64697370617463682e77726974656f6674772e6576656e74732e726561646d6674772e6c6976652e726561646d6674772e706c616e2e72656164000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f", - "relay": "8182838485868788898a8b8c8d8e8f90244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f" - }, - { - "name": "cmd.request", - "from": "watch", - "frames": [ - "0100000000a0a2617463636d646162a5626f706e6c6f6164706f696e742e686f6c646461726773a4626964636c703166686f6c645f730067706f7765725f77191cc06a70686173655f6d6f646562337065636d644964782430313932663261302d376331652d373030302d383030302d30313233343536373839616266657870656374a2637265760766677561726473f66f6e6f7456616c696441667465724d731a00015f9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "00000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3", - "relay": "8182838485868788898a8b8c8d8e8f9000000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3" - }, - { - "name": "cmd.answer", - "from": "box", - "frames": [ - "010000000061a2617467636d642e61636b6162a365636d644964782430313932663261302d376331652d373030302d383030302d303132333435363738396162676c6561736549646d6c656173652d766563746f72736b6578706972657341744d731a000ea60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "010000000073a261746a636d642e726573756c746162a365636d644964782430313932663261302d376331652d373030302d383030302d303132333435363738396162657374617465676170706c696564686f62736572766564a36373726364636f72656576616c7565f96f3068757074696d654d7319ee4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "0000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712" - }, - { - "name": "now.request", - "from": "watch", - "frames": [ - "01000000000aa26174636e6f776162a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "0000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415" - }, - { - "name": "now.answer", - "from": "box", - "frames": [ - "0100000000e3a26174636e6f776162a6666669656c6473a761310261321904b06133390d476134190384613519026c6136190e74623130191cc067736f7572636573a36137a2656167654d73190190657374617465646c6976656138a2656167654d73190190657374617465646c6976656139a2656167654d73190190657374617465646c69766568757074696d654d7319f2306a636f6e74726f6c526576076a6c6f6164706f696e747381a4626964636c703166706f77657257191cc067706c7567676564f56b736f635065726d696c6c6519026c716469737061746368426c6f636b6564427980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "01000000001ca26174647469636b6162a2637365710068757074696d654d7319f23000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "body": "0000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4" - } - ] -} diff --git a/go/internal/appuplink/tunnel.go b/go/internal/appuplink/tunnel.go index f149a0f59..b1a8391da 100644 --- a/go/internal/appuplink/tunnel.go +++ b/go/internal/appuplink/tunnel.go @@ -18,14 +18,10 @@ import ( // The tunnel: the phones' sessions that FTW Cloud carries // (docs/protocol-cloud.md, "Tunnel"). // -// A phone's session through the service is the same ftw.session.v1 session -// it runs over the relay, byte for byte. Only the routing differs. The relay -// cannot say whose a message is, so the box lets each session's AEAD try. The -// service says which device and which session, tun {dev, sid, d}, so the box -// looks the session up. It still trusts nothing the service says: the -// handshake proves the phone's key and the box admits it by that key, exactly -// as over the relay. dev only picks the slot, and a handshake whose key is -// not the device dev names is dropped. +// The service names the device and session in tun {dev, sid, d}, so the box +// can look the session up. The Noise handshake proves the phone's key; the +// service cannot grant access. A handshake whose key does not match dev is +// dropped. // // A new session waits, pending, until its first transport message decrypts. // The service sees every message 1 and can replay one under a new sid; the @@ -34,8 +30,8 @@ import ( // its handshake on a goroutine of its own, so a slow disk under the // enrolment write never holds up routing or the stream. // -// Lane 0 goes on each finished driver poll, not on the relay's one-second -// pulse, and a beat goes when no frame has gone for the subscribed period. +// Lane 0 goes on each finished driver poll. A beat goes when no frame has +// gone for the subscribed period. // Frames keep their fixed size. What the service learns from their timing // is written in docs/protocol-cloud.md, "Lane 0 through the tunnel". @@ -148,7 +144,7 @@ type tunSession struct { } // Tunnel returns the uplink's tunnel table, for the box's link to FTW Cloud -// to deliver to. Its sessions take the same slots as the relay's. +// to deliver to. Each device holds at most one live session. func (u *Uplink) Tunnel() *Tunnel { return u.tunnel } func newTunnel(build HandlerBuilder, enroll *appenroll.Identity, slots *slots, arrivals <-chan struct{}, log *slog.Logger) *Tunnel { @@ -443,9 +439,8 @@ func (t *Tunnel) handshake(s *tunSession, message1 []byte) (*appwire.Transport, if cloudwire.DeviceID(hs.RemoteStatic()) != s.dev { return nil, nil, errTunDevice } - // Built before the caller is checked and sent only after, as over the - // relay. The check changes nothing yet; commit does, once the session - // proves itself. + // Built before the caller is checked and sent only after. The check + // changes nothing yet; commit does, once the session proves itself. reply, err := hs.WriteMessage(nil) if err != nil { return nil, nil, err @@ -507,8 +502,7 @@ func (t *Tunnel) promote(s *tunSession, transport *appwire.Transport, commit fun // The phone hears that its older session ended. t.remove(old, true) } - // The device's one slot, whichever carrier held it before. Claiming it - // ends the older session, through the relay or through here. + // Claim the device's slot and end any older session. sess.setRelease(t.slots.claim(grant.DeviceID, func() { t.remove(s, true) })) t.kicked() t.log.Info("app session open through FTW Cloud") @@ -680,8 +674,7 @@ func (t *Tunnel) each(what string, step func(*appproto.Handler) error) { } } -// dropByAppKey ends every session a revoked key runs, live or pending, as -// the relay's table does. +// dropByAppKey ends every session a revoked key runs, live or pending. func (t *Tunnel) dropByAppKey(pub []byte) int { t.mu.Lock() var gone []*tunSession @@ -711,7 +704,7 @@ func (t *Tunnel) dropByAppKey(pub []byte) int { // One slot per device // -------------------------------------------------------------------------- -// slots holds each device's one session, whichever carrier it rides. A new +// slots holds each device's one session. A new // session from a device ends its older one, so a phone that reconnects, a // watch or another phone never pushes out a different device's session. type slots struct { diff --git a/go/internal/appuplink/tunnel_test.go b/go/internal/appuplink/tunnel_test.go index 3442c1ef7..ffbc9dccd 100644 --- a/go/internal/appuplink/tunnel_test.go +++ b/go/internal/appuplink/tunnel_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "log/slog" + "net/http" "path/filepath" + "sync" "testing" "time" @@ -175,7 +177,7 @@ func setModeCmd(id string) appproto.Cmd { func TestATunnelSessionAnswersACommand(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 7, nil) r.hello(t, app, dev, 7) @@ -201,7 +203,7 @@ func TestAForgedDevGetsNothing(t *testing.T) { if err != nil { t.Fatal(err) } - r.tunnel.Deliver(cloudwire.Tun{Dev: deviceIDOf(other.staticPublic()), Sid: 1, D: m1}) + r.tunnel.Deliver(cloudwire.Tun{Dev: cloudwire.DeviceID(other.staticPublic()), Sid: 1, D: m1}) r.silent(t, 500*time.Millisecond) if r.tunnel.count() != 0 { t.Fatal("a session opened under another device's id") @@ -224,7 +226,7 @@ func TestAStrangerThroughTheTunnelGetsNothing(t *testing.T) { if err != nil { t.Fatal(err) } - r.tunnel.Deliver(cloudwire.Tun{Dev: deviceIDOf(app.staticPublic()), Sid: 1, D: m1}) + r.tunnel.Deliver(cloudwire.Tun{Dev: cloudwire.DeviceID(app.staticPublic()), Sid: 1, D: m1}) r.silent(t, 400*time.Millisecond) } if r.tunnel.count() != 0 || r.enroll.AuthorisedCount() != 0 { @@ -258,7 +260,7 @@ func TestOneSessionPerDevice(t *testing.T) { r := newTunRig(t) app := r.pair(t) other := r.pair(t) - dev, otherDev := deviceIDOf(app.staticPublic()), deviceIDOf(other.staticPublic()) + dev, otherDev := cloudwire.DeviceID(app.staticPublic()), cloudwire.DeviceID(other.staticPublic()) r.open(t, other, otherDev, 1, nil) r.hello(t, other, otherDev, 1) r.open(t, app, dev, 1, nil) @@ -291,35 +293,12 @@ func TestOneSessionPerDevice(t *testing.T) { } } -// A relay session and a tunnel session of one device share its slot: the -// newer ends the older, whichever carrier either rides. -func TestTheRelayAndTheTunnelShareADevicesSlot(t *testing.T) { - r := newTunRig(t) - app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) - streams := relayTable(r.slots, r.enroll) - relayOpen(t, streams, r.enroll, &appClient{static: app.static}, nil) - r.open(t, app, dev, 9, nil) - r.hello(t, app, dev, 9) - if streams.count() != 0 { - t.Fatal("the relay session outlived the device's tunnel session") - } - - relayOpen(t, streams, r.enroll, &appClient{static: app.static}, nil) - if end := r.next(t, 3*time.Second); end.Dev != dev || end.Sid != 9 || !end.Ended() { - t.Fatalf("the tunnel session ended with %+v", end) - } - if r.tunnel.count() != 0 || streams.count() != 1 { - t.Fatalf("%d tunnel and %d relay sessions", r.tunnel.count(), streams.count()) - } -} - // A message for a session the box does not hold gets an end, so the phone // starts again at once instead of waiting. func TestAnUnknownSessionGetsAnEnd(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.tunnel.Deliver(cloudwire.Tun{Dev: dev, Sid: 4, D: bytes.Repeat([]byte{1}, 600)}) if m := r.next(t, 3*time.Second); m.Dev != dev || m.Sid != 4 || !m.Ended() { t.Fatalf("answer %+v", m) @@ -331,7 +310,7 @@ func TestAnUnknownSessionGetsAnEnd(t *testing.T) { func TestDropEndsEverySession(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 1, nil) r.tunnel.Drop() if r.tunnel.count() != 0 { @@ -344,7 +323,7 @@ func TestDropEndsEverySession(t *testing.T) { func TestAnEmptyDEndsTheSession(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 1, nil) r.tunnel.Deliver(cloudwire.Tun{Dev: dev, Sid: 1}) deadline := time.Now().Add(3 * time.Second) @@ -361,7 +340,7 @@ func TestARevokeEndsATunnelSession(t *testing.T) { r := newTunRig(t) enrolOwner(t, r.enroll) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 1, nil) r.hello(t, app, dev, 1) removed, err := r.enroll.Revoke(dev, appenroll.AtTheBox) @@ -377,7 +356,7 @@ func TestARevokeEndsATunnelSession(t *testing.T) { func TestOneDeviceCannotStarveTheOthersOfHandshakes(t *testing.T) { r := newTunRig(t) busy := r.pair(t) - busyDev := deviceIDOf(busy.staticPublic()) + busyDev := cloudwire.DeviceID(busy.staticPublic()) for i := range tunOpensPerDevice + 5 { again := &appClient{static: busy.static} m1, _ := again.message1(r.enroll.StaticKey().Public(), nil) @@ -414,46 +393,7 @@ drain: t.Fatalf("the busy device got %d handshakes answered, want at most %d", answers, tunOpensPerDevice) } other := r.pair(t) - r.open(t, other, deviceIDOf(other.staticPublic()), 1, nil) -} - -// relayTable is a relay connection's session table whose writes a test can -// read back, one message at a time. -func relayTable(sl *slots, enroll *appenroll.Identity) *sessions { - out := make(chan []byte, 16) - return &sessions{ - slots: sl, build: newHandler, enroll: enroll, static: enroll.StaticKey(), log: slog.New(slog.DiscardHandler), - write: func(m []byte) error { - select { - case out <- append([]byte(nil), m...): - default: - } - return nil - }, - testOut: out, - } -} - -// relayOpen runs a phone's handshake on a relay table and proves it with a -// hello, so the session is live. -func relayOpen(t *testing.T, streams *sessions, enroll *appenroll.Identity, app *appClient, code []byte) { - t.Helper() - m1, err := app.message1(enroll.StaticKey().Public(), code) - if err != nil { - t.Fatal(err) - } - if err := streams.open(m1); err != nil { - t.Fatal(err) - } - if err := app.readMessage2(<-streams.testOut); err != nil { - t.Fatal(err) - } - raw, _ := appproto.Marshal(appproto.Hello{Proto: appproto.ProtoRange{Max: appproto.ProtoMax}, App: appproto.AppInfo{Build: "t", UA: "pwa"}}) - frame, _ := appwire.EncodeFrame(appwire.Frame{Lane: appwire.LaneControl, Bucket: 512, Envelope: appwire.Envelope{T: appproto.MsgHello, B: raw}}) - msg, _ := app.encrypt(frame) - streams.deliver(context.Background(), msg) - // The hello_ok. - <-streams.testOut + r.open(t, other, cloudwire.DeviceID(other.staticPublic()), 1, nil) } // A message 1 replayed under a new sid, as the service could, gets an @@ -462,7 +402,7 @@ func relayOpen(t *testing.T, streams *sessions, enroll *appenroll.Identity, app func TestAReplayedMessage1LeavesTheLiveSession(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) m1, err := app.message1(r.enroll.StaticKey().Public(), nil) if err != nil { t.Fatal(err) @@ -488,55 +428,12 @@ func TestAReplayedMessage1LeavesTheLiveSession(t *testing.T) { r.receive(t, app, appproto.MsgCmdAck) } -// The same on the relay: a replayed message 1 takes no device's place. -func TestAReplayedMessage1LeavesTheLiveRelaySession(t *testing.T) { - r := newTunRig(t) - app := r.pair(t) - streams := relayTable(r.slots, r.enroll) - m1, err := app.message1(r.enroll.StaticKey().Public(), nil) - if err != nil { - t.Fatal(err) - } - if err := streams.open(m1); err != nil { - t.Fatal(err) - } - if err := app.readMessage2(<-streams.testOut); err != nil { - t.Fatal(err) - } - raw, _ := appproto.Marshal(appproto.Hello{Proto: appproto.ProtoRange{Max: appproto.ProtoMax}, App: appproto.AppInfo{Build: "t", UA: "pwa"}}) - frame, _ := appwire.EncodeFrame(appwire.Frame{Lane: appwire.LaneControl, Bucket: 512, Envelope: appwire.Envelope{T: appproto.MsgHello, B: raw}}) - msg, _ := app.encrypt(frame) - streams.deliver(context.Background(), msg) - <-streams.testOut - if streams.count() != 1 { - t.Fatal("the phone's session is not live") - } - // Replayed, twice. - for range 2 { - if err := streams.open(m1); err != nil { - t.Fatal(err) - } - <-streams.testOut - } - if streams.count() != 1 { - t.Fatalf("%d live sessions after replays", streams.count()) - } - frame, _ = appwire.EncodeFrame(appwire.Frame{Lane: appwire.LaneControl, Bucket: 512, Envelope: appwire.Envelope{T: appproto.MsgHello, B: raw}}) - msg, _ = app.encrypt(frame) - streams.deliver(context.Background(), msg) - select { - case <-streams.testOut: - case <-time.After(3 * time.Second): - t.Fatal("the live relay session stopped answering after a replay") - } -} - // A dropped tun does not end the session: the next command still gets its // ack and its result. func TestADroppedTunDoesNotEndTheSession(t *testing.T) { r := newTunRig(t) app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 1, nil) r.hello(t, app, dev, 1) drops := &dropOnce{inner: r.out} @@ -577,7 +474,7 @@ func TestDropSessionsByAppKeyReachesTheTunnel(t *testing.T) { if err != nil { t.Fatal(err) } - u, err := New(Options{Endpoint: "ws://127.0.0.1:1", Enroll: enroll, Handler: newHandler, Logger: slog.New(slog.DiscardHandler)}) + u, err := New(Options{Enroll: enroll, Handler: newHandler, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } @@ -589,7 +486,7 @@ func TestDropSessionsByAppKeyReachesTheTunnel(t *testing.T) { enrolOwner(t, enroll) r := &tunRig{enroll: enroll, tunnel: u.Tunnel(), out: out} app := r.pair(t) - dev := deviceIDOf(app.staticPublic()) + dev := cloudwire.DeviceID(app.staticPublic()) r.open(t, app, dev, 1, nil) r.hello(t, app, dev, 1) removed, err := enroll.Revoke(dev, appenroll.AtTheBox) @@ -600,3 +497,85 @@ func TestDropSessionsByAppKeyReachesTheTunnel(t *testing.T) { t.Fatalf("dropped %d", n) } } + +type heldAPI struct { + started chan struct{} + cancelled chan struct{} + once sync.Once +} + +func (h *heldAPI) Route(*http.Request) apiauth.RouteFacts { + return apiauth.RouteFacts{Tier: apiauth.TierRead} +} + +func (h *heldAPI) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.once.Do(func() { close(h.started) }) + <-req.Context().Done() + close(h.cancelled) + w.WriteHeader(http.StatusOK) +} + +// Revoking a phone cancels its API request already running in Core. +func TestARevokedShareStopsACallAlreadyInFlight(t *testing.T) { + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + enrolOwner(t, enroll) + gateway := &heldAPI{started: make(chan struct{}), cancelled: make(chan struct{})} + build := func(sender appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader) (*appproto.Handler, error) { + return appproto.New(appproto.Config{ + Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, + Site: stubSite{}, Info: stubInfo{}, Modes: stubModes{}, Plans: stubPlans{}, Codec: codec{}, + Sender: sender, Caller: caller, Grants: grants, API: gateway, + Commands: appproto.NewCmdLog(), Caps: append(appproto.DefaultCaps(), appproto.CapApiPassthrough), + SrcGrid: "meter", SrcPV: "meter", SrcBattery: "meter", Logger: slog.New(slog.DiscardHandler), + }) + } + u, err := New(Options{Enroll: enroll, Handler: build, Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + t.Fatal(err) + } + out := &capturedTun{ch: make(chan cloudwire.Tun, 16)} + u.Tunnel().Attach(out) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = u.Run(ctx) }() + r := &tunRig{enroll: enroll, tunnel: u.Tunnel(), out: out} + app := r.pair(t) + dev := cloudwire.DeviceID(app.staticPublic()) + r.open(t, app, dev, 1, nil) + r.hello(t, app, dev, 1) + id := uint32(1) + body, err := appproto.Marshal(appproto.APIReq{Method: appproto.APIGet, Path: "/api/logs"}) + if err != nil { + t.Fatal(err) + } + frame, err := appwire.EncodeFrame(appwire.Frame{Lane: appwire.LaneBulk, Bucket: 16384, + Envelope: appwire.Envelope{T: appproto.MsgAPIReq, ID: &id, B: body}}) + if err != nil { + t.Fatal(err) + } + msg, err := app.encrypt(frame) + if err != nil { + t.Fatal(err) + } + r.tunnel.Deliver(cloudwire.Tun{Dev: dev, Sid: 1, D: msg}) + select { + case <-gateway.started: + case <-time.After(3 * time.Second): + t.Fatal("API request did not start") + } + removed, err := enroll.Revoke(dev, appenroll.AtTheBox) + if err != nil { + t.Fatal(err) + } + if n := u.DropSessionsByAppKey(removed[0].Key); n != 1 { + t.Fatalf("dropped %d sessions", n) + } + select { + case <-gateway.cancelled: + case <-time.After(3 * time.Second): + t.Fatal("revoked session left its API request running") + } +} diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go deleted file mode 100644 index ba6f1ad79..000000000 --- a/go/internal/appuplink/uplink_test.go +++ /dev/null @@ -1,813 +0,0 @@ -package appuplink - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "log/slog" - "net/http" - "net/http/httptest" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - "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/control" - "github.com/srcfl/ftw/go/internal/mpc" - "github.com/srcfl/ftw/go/protocol/appwire" -) - -// The handle derivation must agree with the app byte for byte, or the box and -// the phone sit in two different rooms and nobody's house appears on their -// screen. These are the outputs of srcfl/ftw-webapp -// src/lib/carrier/rendezvous.ts for a secret of 0x00..0x1f. -func TestHandleMatchesTheApp(t *testing.T) { - secret := make([]byte, 32) - for i := range secret { - secret[i] = byte(i) - } - - for _, want := range []struct { - epoch int64 - handle string - }{ - {0, "02ce440bd74a87c38abe28f5fe74549b"}, - {1, "4e81fbf74ecfb2db9c5c8bd63dcb3a85"}, - {481234, "0f8ae5eb475acc0e97863f504ce04027"}, - {999999999, "1ec8efa34c1aeeb9fec676ef4a6bcb5e"}, - } { - got, err := Handle(secret, want.epoch) - if err != nil { - t.Fatalf("Handle(%d): %v", want.epoch, err) - } - if got != want.handle { - t.Fatalf("epoch %d: handle = %s, want %s", want.epoch, got, want.handle) - } - } -} - -// Two epochs' handles must be unrelated strings, or the rotation buys nothing. -func TestHandlesOfNeighbouringEpochsShareNothing(t *testing.T) { - secret := make([]byte, 32) - a, err := Handle(secret, 100) - if err != nil { - t.Fatalf("Handle: %v", err) - } - b, err := Handle(secret, 101) - if err != nil { - t.Fatalf("Handle: %v", err) - } - if a == b { - t.Fatal("the handle did not change across an epoch") - } - if len(a) != HandleBytes*2 { - t.Fatalf("handle is %d characters, want %d", len(a), HandleBytes*2) - } - if _, err := hex.DecodeString(a); err != nil { - t.Fatalf("handle is not hex: %v", err) - } -} - -func TestHandleRefusesAShortSecret(t *testing.T) { - if _, err := Handle(make([]byte, 8), 1); err == nil { - t.Fatal("an eight-byte secret was accepted as a secret") - } -} - -// A Pi with a dead RTC reads 1970 or earlier. Go divides toward zero and the -// app uses Math.floor, so without the floor here the two would disagree on -// exactly the boxes that need the correction most. -func TestCurrentEpochFloors(t *testing.T) { - for _, c := range []struct{ ms, epoch int64 }{ - {0, 0}, - {EpochMs - 1, 0}, - {EpochMs, 1}, - {-1, -1}, - {-EpochMs, -1}, - {-EpochMs - 1, -2}, - } { - if got := CurrentEpoch(c.ms); got != c.epoch { - t.Fatalf("CurrentEpoch(%d) = %d, want %d", c.ms, got, c.epoch) - } - } -} - -// -------------------------------------------------------------------------- -// End to end, against a relay and a phone -// -------------------------------------------------------------------------- - -type rig struct { - relay *fakeRelay - enroll *appenroll.Identity - uplink *Uplink - cancel context.CancelFunc - done chan error -} - -func newRig(t *testing.T, epoch int64) *rig { - t.Helper() - return newRigWith(t, epoch, newHandler) -} - -// newRigWith is newRig for a test that needs its own handler — one wired to a -// gateway it can hold open, so a revoke can land while a call is running. -func newRigWith(t *testing.T, epoch int64, build HandlerBuilder) *rig { - t.Helper() - return newRigOpts(t, epoch, func(o *Options) { o.Handler = build }) -} - -// newRigOpts is newRig with the options set changes. -func newRigOpts(t *testing.T, epoch int64, set func(*Options)) *rig { - t.Helper() - - relay := newFakeRelay(epoch) - t.Cleanup(relay.close) - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - - now := time.UnixMilli(epoch * EpochMs) - opts := Options{ - Endpoint: relay.url(), - Enroll: enroll, - Logger: slog.New(slog.DiscardHandler), - Now: func() time.Time { return now }, - Random: func() float64 { return 0 }, - } - set(&opts) - uplink, err := New(opts) - if err != nil { - t.Fatalf("New: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - done := make(chan error, 1) - go func() { done <- uplink.runRelay(ctx) }() - - return &rig{relay: relay, enroll: enroll, uplink: uplink, cancel: cancel, done: done} -} - -// dialApp joins the room as a browser stream would. -func (r *rig) dialApp(t *testing.T, epoch int64) *websocket.Conn { - t.Helper() - - handle, err := Handle(r.enroll.RendezvousSecret(), epoch) - if err != nil { - t.Fatalf("Handle: %v", err) - } - conn, _, err := websocket.DefaultDialer.Dial( - r.relay.url()+"/r/"+strconv.FormatInt(epoch, 10)+"/"+handle+"/app", nil) - if err != nil { - t.Fatalf("dialling as an app: %v", err) - } - t.Cleanup(func() { _ = conn.Close() }) - - // Wait for the room to hold both of us. A frame written into an empty - // room is dropped by the relay, which would make this test flaky for a - // reason that has nothing to do with the box. - _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - for { - kind, message, err := conn.ReadMessage() - if err != nil { - t.Fatalf("waiting for the room: %v", err) - } - if kind == websocket.TextMessage && string(message) == CtrlReady { - return conn - } - } -} - -// waitForBinary reads until a binary message arrives, so the relay's control -// words do not have to be interleaved into every assertion. -func waitForBinary(t *testing.T, conn *websocket.Conn) []byte { - t.Helper() - _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - for { - kind, message, err := conn.ReadMessage() - if err != nil { - t.Fatalf("reading from the relay: %v", err) - } - if kind == websocket.BinaryMessage { - return message - } - } -} - -// A paired phone gets a session, a hello_ok and a telemetry stream. This is -// the whole product in one test. -func TestAPairedAppGetsASessionAndTelemetry(t *testing.T) { - r := newRig(t, 481234) - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - - conn := r.dialApp(t, 481234) - app, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - - message1, err := app.message1(r.enroll.StaticKey().Public(), code) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - if err := app.readMessage2(waitForBinary(t, conn)); err != nil { - t.Fatalf("readMessage2: %v", err) - } - - // hello, on the wire the box actually speaks. - send(t, conn, app, appproto.MsgHello, 512, appproto.Hello{ - Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "test", UA: "pwa"}, - }) - if got := receive(t, conn, app); got.Envelope.T != appproto.MsgHelloOK { - t.Fatalf("first reply is %q, want %q", got.Envelope.T, appproto.MsgHelloOK) - } - - send(t, conn, app, appproto.MsgSub, 512, appproto.Sub{Bucket: 512}) - snapFrame := receive(t, conn, app) - if snapFrame.Envelope.T != appproto.MsgSnap { - t.Fatalf("subscription reply is %q, want %q", snapFrame.Envelope.T, appproto.MsgSnap) - } - - // The stream itself. Lane 0 is one bucket for the life of the session, - // because a frame length that follows what happened in the house leaks - // the household's load pattern through perfect encryption. - for i := 0; i < 2; i++ { - frame := receive(t, conn, app) - if frame.Lane != appwire.LaneControl { - t.Fatalf("telemetry frame %d is on lane %d, want %d", i, frame.Lane, appwire.LaneControl) - } - if frame.Bucket != 512 { - t.Fatalf("telemetry frame %d is %d bytes, want 512", i, frame.Bucket) - } - } -} - -// The pairing code is what the box checks. Without it a stranger who can reach -// the relay would get a session on somebody else's house. -func TestAnUnpairedAppGetsNoReply(t *testing.T) { - r := newRig(t, 481234) - - conn := r.dialApp(t, 481234) - app, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - message1, err := app.message1(r.enroll.StaticKey().Public(), nil) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - - // Silence, not an error frame. Replying at all would confirm that a box - // is on this handle and hand a stranger a fresh ephemeral key. - _ = conn.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) - for { - kind, _, err := conn.ReadMessage() - if err != nil { - return // the deadline, which is the pass - } - if kind == websocket.BinaryMessage { - t.Fatal("the box replied to a handshake with no pairing code") - } - } -} - -func TestAWrongPairingCodeIsRefused(t *testing.T) { - r := newRig(t, 481234) - if _, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL); err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - - conn := r.dialApp(t, 481234) - app, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - wrong := make([]byte, appenroll.PairingCodeBytes) - message1, err := app.message1(r.enroll.StaticKey().Public(), wrong) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - - _ = conn.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) - for { - kind, _, err := conn.ReadMessage() - if err != nil { - return - } - if kind == websocket.BinaryMessage { - t.Fatal("the box replied to a handshake with the wrong pairing code") - } - } -} - -// The box joins under the handle its own secret derives, not under anything -// the relay chose. If this ever became a constant the protocol would hand the -// relay a household identifier that lasts for years. -func TestTheBoxJoinsUnderTheDerivedHandle(t *testing.T) { - r := newRig(t, 481234) - - select { - case <-r.relay.joined: - case <-time.After(5 * time.Second): - t.Fatal("the box never joined") - } - - want, err := Handle(r.enroll.RendezvousSecret(), 481234) - if err != nil { - t.Fatalf("Handle: %v", err) - } - handles := r.relay.seenHandles() - if len(handles) == 0 || handles[0] != want { - t.Fatalf("joined as %v, want %s", handles, want) - } -} - -func TestRelayPingKeepsAnIdleUplinkAlive(t *testing.T) { - const ( - heartbeat = 5 * time.Millisecond - timeout = 30 * time.Millisecond - ) - - pongs := make(chan struct{}, 8) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, req, nil) - if err != nil { - return - } - defer conn.Close() - conn.SetPongHandler(func(string) error { - select { - case pongs <- struct{}{}: - default: - } - return nil - }) - - go func() { - ticker := time.NewTicker(heartbeat) - defer ticker.Stop() - for range ticker.C { - if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil { - return - } - } - }() - for { - _, _, err := conn.ReadMessage() - if err != nil { - return - } - } - })) - defer server.Close() - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - u, err := New(Options{ - Endpoint: "ws" + strings.TrimPrefix(server.URL, "http"), - Enroll: enroll, - Handler: newHandler, - Logger: slog.New(slog.DiscardHandler), - }) - if err != nil { - t.Fatalf("New: %v", err) - } - u.readTimeout = timeout - - conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) - if err != nil { - t.Fatalf("dial: %v", err) - } - defer conn.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan error, 1) - go func() { - _, _, err := u.serve(ctx, conn) - done <- err - }() - - select { - case <-pongs: - case <-time.After(time.Second): - t.Fatal("the uplink did not answer the relay ping") - } - select { - case err := <-done: - t.Fatalf("idle uplink stopped despite relay pings: %v", err) - case <-time.After(3 * timeout): - } - - cancel() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("serve did not stop after cancellation") - } -} - -// The relay is allowed to correct our clock by an hour, and not to steer us -// onto handles of its choosing. -func TestAnImplausibleEpochCorrectionIsIgnored(t *testing.T) { - u := &Uplink{ - log: slog.New(slog.DiscardHandler), - now: func() time.Time { return time.UnixMilli(100 * EpochMs) }, - random: func() float64 { return 0 }, - } - - u.adoptEpoch("101") - if u.epochOffset != 1 { - t.Fatalf("offset = %d, want 1", u.epochOffset) - } - - u.adoptEpoch("50000") - if u.epochOffset != 1 { - t.Fatalf("a far-away epoch moved us to %d", u.epochOffset) - } - - // The empty string parses as zero in more languages than it should. A - // close with no reason must leave us where we are. - u.adoptEpoch("") - if u.epochOffset != 1 { - t.Fatalf("an empty reason moved us to %d", u.epochOffset) - } - u.adoptEpoch(" 99 x") - if u.epochOffset != 1 { - t.Fatalf("a malformed reason moved us to %d", u.epochOffset) - } -} - -// A box closed at a rotation must not come straight back. That would give the -// relay the strongest possible timing link between the old and new protocol -// handles, even though IP and connection metadata can still correlate them. -func TestRotationIsNotFollowedInstantly(t *testing.T) { - relay := newFakeRelay(481234) - defer relay.close() - - next := int64(481235) - relay.rotateTo = &next - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - - now := time.UnixMilli(481234 * EpochMs) - u, err := New(Options{ - Endpoint: relay.url(), - Enroll: enroll, - Handler: newHandler, - Logger: slog.New(slog.DiscardHandler), - Now: func() time.Time { return now }, - // The largest draw the jitter can make, so the assertion is about - // the window rather than about luck. - Random: func() float64 { return 1 }, - }) - if err != nil { - t.Fatalf("New: %v", err) - } - - delay, err := u.once(context.Background()) - if err != nil { - t.Fatalf("once: %v", err) - } - if delay <= 0 { - t.Fatal("the box would rejoin the instant the relay rotated it") - } - if delay > rotateJitter { - t.Fatalf("delay = %v, longer than the whole window %v", delay, rotateJitter) - } - if u.epochOffset != 1 { - t.Fatalf("offset = %d; the announced epoch was not adopted", u.epochOffset) - } -} - -// A box whose RTC reads 1970 must be right after one round trip, not after a -// backoff. -func TestAnEpochCorrectionRetriesImmediately(t *testing.T) { - relay := newFakeRelay(481234) - defer relay.close() - - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - - // The box guesses one epoch behind; the relay corrects it. - now := time.UnixMilli(481233 * EpochMs) - u, err := New(Options{ - Endpoint: relay.url(), - Enroll: enroll, - Handler: newHandler, - Logger: slog.New(slog.DiscardHandler), - Now: func() time.Time { return now }, - Random: func() float64 { return 1 }, - }) - if err != nil { - t.Fatalf("New: %v", err) - } - - delay, err := u.once(context.Background()) - if err != nil { - t.Fatalf("once: %v", err) - } - if delay != 0 { - t.Fatalf("delay = %v, want an immediate retry", delay) - } - if u.epochOffset != 1 { - t.Fatalf("offset = %d, want 1", u.epochOffset) - } -} - -func TestARelayOriginWithAPathIsRefused(t *testing.T) { - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) - if err != nil { - t.Fatalf("LoadOrCreate: %v", err) - } - handler := HandlerBuilder(newHandler) - - for _, endpoint := range []string{ - "wss://relay.ftw.energy/r/1/abc/box", - "https://relay.ftw.energy", - "relay.ftw.energy", - } { - if _, err := New(Options{Endpoint: endpoint, Enroll: enroll, Handler: handler}); err == nil { - t.Fatalf("%q was accepted as a relay origin", endpoint) - } - } -} - -// -------------------------------------------------------------------------- -// A box with nothing attached, so the tests above are about the uplink -// -------------------------------------------------------------------------- - -type stubSite struct{} - -func (stubSite) Snapshot() appproto.Snapshot { - return appproto.Snapshot{ - Mode: control.ModeSelfConsumption, - ControlRev: 1, - GridW: 1200, - // Negative while generating. FTW's convention is that positive watts - // flow into the site, so PV is never positive. - PVW: -3000, - BatteryW: 800, - LoadW: 1000, - BatterySoC: 0.55, - BatterySoCKnown: true, - Sources: []appproto.Source{ - {ID: "meter", Kind: "meter", Name: "P1", LastOkUptimeMs: 1000, StaleAfterMs: 5000}, - }, - } -} - -type stubInfo struct{} - -func (stubInfo) Identity() appproto.Identity { - return appproto.Identity{ID: "box", Build: "test", TZ: "Europe/Stockholm"} -} -func (stubInfo) Boot() *appproto.BootProgress { return nil } - -type stubModes struct{} - -func (stubModes) SetMode(context.Context, control.Mode) error { return nil } -func (stubModes) ObservedMode() (control.Mode, bool) { return control.ModeSelfConsumption, true } - -type stubPlans struct{} - -func (stubPlans) Latest() *mpc.Plan { return nil } -func (stubPlans) Rev() uint64 { return 0 } -func (stubPlans) CeilingW() *int64 { return nil } - -// newHandler builds a handler for a test that sends no command, so each one -// gets an empty command log of its own. A test about commands builds its -// handlers over one log, as the box does. -func newHandler( - s appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader, -) (*appproto.Handler, error) { - return appproto.New(appproto.Config{ - Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, - Site: stubSite{}, - Info: stubInfo{}, - Modes: stubModes{}, - Plans: stubPlans{}, - Codec: codec{}, - Sender: s, - Caller: caller, - Grants: grants, - Commands: appproto.NewCmdLog(), - SrcGrid: "meter", - SrcPV: "meter", - SrcBattery: "meter", - Logger: slog.New(slog.DiscardHandler), - }) -} - -func send(t *testing.T, conn *websocket.Conn, app *appClient, msgType string, bucket int, body any) { - t.Helper() - sendOn(t, conn, app, appwire.LaneControl, msgType, bucket, nil, body) -} - -// sendOn is send for a message that belongs on the bulk lane, where a request -// carries an id and a frame's length is allowed to follow its content. -func sendOn( - t *testing.T, conn *websocket.Conn, app *appClient, - lane uint8, msgType string, bucket int, id *uint32, body any, -) { - t.Helper() - - raw, err := appproto.Marshal(body) - if err != nil { - t.Fatalf("marshalling %s: %v", msgType, err) - } - frame, err := appwire.EncodeFrame(appwire.Frame{ - Lane: lane, - Bucket: bucket, - Envelope: appwire.Envelope{T: msgType, ID: id, B: raw}, - }) - if err != nil { - t.Fatalf("encoding %s: %v", msgType, err) - } - message, err := app.encrypt(frame) - if err != nil { - t.Fatalf("encrypting %s: %v", msgType, err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message); err != nil { - t.Fatalf("sending %s: %v", msgType, err) - } -} - -func receive(t *testing.T, conn *websocket.Conn, app *appClient) appwire.Frame { - t.Helper() - - plaintext, err := app.decrypt(waitForBinary(t, conn)) - if err != nil { - t.Fatalf("decrypting: %v", err) - } - frame, err := appwire.DecodeFrame(plaintext) - if err != nil { - t.Fatalf("decoding: %v", err) - } - return frame -} - -// -------------------------------------------------------------------------- -// Revocation, end to end -// -------------------------------------------------------------------------- - -// heldAPI is a gateway that answers nothing until its request is cancelled. -type heldAPI struct { - started chan struct{} - cancelled chan struct{} - once sync.Once -} - -func (h *heldAPI) Route(*http.Request) apiauth.RouteFacts { - return apiauth.RouteFacts{Tier: apiauth.TierRead} -} - -func (h *heldAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { - h.once.Do(func() { close(h.started) }) - <-r.Context().Done() - close(h.cancelled) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"late":true}`)) -} - -// enrolOwner pairs a throwaway owner phone, so a test can then share the box -// with a guest. The first enrolment on any box is an owner whatever its code -// said, and the only owner cannot be revoked — both deliberate, and both in -// the way of a test that wants a removable guest. -func enrolOwner(t *testing.T, enroll *appenroll.Identity) { - t.Helper() - code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - pub := make([]byte, 32) - if _, err := rand.Read(pub); err != nil { - t.Fatalf("rand: %v", err) - } - if _, err := enroll.Authorise(pub, code); err != nil { - t.Fatalf("enrolling the first owner: %v", err) - } -} - -// Revoking a shared phone must stop the call it is making, not only the next -// one — and it must be the same action as locking any other phone out, not a -// second gesture with its own bugs. -// -// The three layers are the session dying, the enrolment epoch and the next -// handshake failing; this is the first, over a real relay and a real Noise -// session. -func TestARevokedShareStopsACallAlreadyInFlight(t *testing.T) { - gateway := &heldAPI{started: make(chan struct{}), cancelled: make(chan struct{})} - r := newRigWith(t, 481234, func( - s appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader, - ) (*appproto.Handler, error) { - return appproto.New(appproto.Config{ - Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, - Site: stubSite{}, - Info: stubInfo{}, - Modes: stubModes{}, - Plans: stubPlans{}, - Codec: codec{}, - Sender: s, - API: gateway, - Caller: caller, - Grants: grants, - Commands: appproto.NewCmdLog(), - Caps: append(appproto.DefaultCaps(), appproto.CapApiPassthrough), - SrcGrid: "meter", - SrcPV: "meter", - SrcBattery: "meter", - Logger: slog.New(slog.DiscardHandler), - }) - }) - - // The household's own phone, so the guest below is removable. - enrolOwner(t, r.enroll) - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - conn := r.dialApp(t, 481234) - app, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - message1, err := app.message1(r.enroll.StaticKey().Public(), code) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - if err := app.readMessage2(waitForBinary(t, conn)); err != nil { - t.Fatalf("readMessage2: %v", err) - } - - // A read, which is all a guest may do and which must work until the - // moment it is taken away. - id := uint32(1) - sendOn(t, conn, app, appwire.LaneBulk, appproto.MsgAPIReq, 16384, &id, appproto.APIReq{ - Method: appproto.APIGet, Path: "/api/logs", - }) - select { - case <-gateway.started: - case <-time.After(5 * time.Second): - t.Fatal("the request never reached the box's API") - } - - // What the settings page does: forget the key, then tear down whatever - // it is running right now. - removed, err := r.enroll.Revoke(deviceIDOf(app.staticPublic()), appenroll.AtTheBox) - if err != nil { - t.Fatalf("Revoke: %v", err) - } - if dropped := r.uplink.DropSessionsByAppKey(removed[0].Key); dropped != 1 { - t.Fatalf("dropped %d sessions, want 1", dropped) - } - - select { - case <-gateway.cancelled: - case <-time.After(5 * time.Second): - t.Fatal("the revoked phone's call was still running inside the box") - } -} - -// deviceIDOf is how appenroll names a row: the first eight characters of the -// base64 key. Spelled out here so the test revokes by the id a person would -// click, not by a handle the test invented. -func deviceIDOf(pub []byte) string { - return base64.RawURLEncoding.EncodeToString(pub)[:8] -} diff --git a/go/internal/appuplink/vectors_test.go b/go/internal/appuplink/vectors_test.go deleted file mode 100644 index 9905f4070..000000000 --- a/go/internal/appuplink/vectors_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package appuplink - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "flag" - "os" - "testing" - - "github.com/srcfl/ftw/go/internal/appproto" - "github.com/srcfl/ftw/go/protocol/appwire" -) - -// The exchange's golden vectors (ADR 0009, "Vectors"). -// -// testdata/exchange-vectors.json holds the bytes of every step of one watch -// session, from fixed keys: X1 with a pairing code, a command answered by its -// ack and result, and a read answered by now and a tick. The Swift client in -// srcfl/ftw-app replays the same file, so Go and Swift agree on every byte or -// one of them fails. Regenerate with -// -// go test ./internal/appuplink -run TestExchangeVectors -update-exchange-vectors - -var updateExchangeVectors = flag.Bool("update-exchange-vectors", false, "rewrite testdata/exchange-vectors.json") - -type exchangeVectors struct { - About string `json:"about"` - ProtocolName string `json:"protocolName"` - Prologue string `json:"prologue"` - - BoxStatic string `json:"boxStatic"` - BoxStaticPublic string `json:"boxStaticPublic"` - BoxEphemeral string `json:"boxEphemeral"` - WatchStatic string `json:"watchStatic"` - WatchStaticPublic string `json:"watchStaticPublic"` - WatchEphemeral string `json:"watchEphemeral"` - HandshakeHash string `json:"handshakeHash"` - - Steps []exchangeVectorStep `json:"steps"` -} - -// exchangeVectorStep is one message across the relay. Frames are the -// plaintext lane frames inside it; Body is what follows the relay's id, the -// bytes the watch posts or the answer it receives; Relay is id ‖ Body as it -// crosses the box's exchange socket. -type exchangeVectorStep struct { - Name string `json:"name"` - From string `json:"from"` - Frames []string `json:"frames"` - Body string `json:"body"` - Relay string `json:"relay"` -} - -// fixed is n bytes counting up from first: a key nobody would use, which is -// the point. -func fixed(first byte, n int) []byte { - out := make([]byte, n) - for i := range out { - out[i] = first + byte(i) - } - return out -} - -func frameOf(t *testing.T, lane uint8, bucket int, msgType string, body any) []byte { - t.Helper() - raw, err := appproto.Marshal(body) - if err != nil { - t.Fatal(err) - } - frame, err := appwire.EncodeFrame(appwire.Frame{ - Lane: lane, Bucket: bucket, Envelope: appwire.Envelope{T: msgType, B: raw}, - }) - if err != nil { - t.Fatal(err) - } - return frame -} - -func buildExchangeVectors(t *testing.T) exchangeVectors { - t.Helper() - keyPair := func(first byte) appwire.KeyPair { - kp, err := appwire.KeyPairFromSecret(fixed(first, appwire.DHBytes)) - if err != nil { - t.Fatal(err) - } - return kp - } - boxStatic, boxEphemeral := keyPair(0x01), keyPair(0x61) - watchStatic, watchEphemeral := keyPair(0x21), keyPair(0x41) - id := fixed(0x81, ExchangeIDBytes) - prologueBytes := exchangePrologue(boxStatic.Public()) - - watch, err := appwire.NewInitiator(appwire.InitiatorOptions{ - StaticKey: watchStatic, RemoteStatic: boxStatic.Public(), Prologue: prologueBytes, Ephemeral: &watchEphemeral, - }) - if err != nil { - t.Fatal(err) - } - box, err := appwire.NewResponder(appwire.HandshakeOptions{ - StaticKey: boxStatic, Prologue: prologueBytes, Ephemeral: &boxEphemeral, - }) - if err != nil { - t.Fatal(err) - } - - v := exchangeVectors{ - About: "ADR 0009 watch exchange: Noise IK with prologue ftw.exchange.v1: and the box's static key. " + - "Every frame but hello_ok is lane 0 in 512 B; every answer after the handshake is two transport messages.", - ProtocolName: appwire.ProtocolName, - Prologue: hex.EncodeToString(prologueBytes), - BoxStatic: hex.EncodeToString(fixed(0x01, appwire.DHBytes)), - BoxStaticPublic: hex.EncodeToString(boxStatic.Public()), - BoxEphemeral: hex.EncodeToString(fixed(0x61, appwire.DHBytes)), - WatchStatic: hex.EncodeToString(fixed(0x21, appwire.DHBytes)), - WatchStaticPublic: hex.EncodeToString(watchStatic.Public()), - WatchEphemeral: hex.EncodeToString(fixed(0x41, appwire.DHBytes)), - } - step := func(name, from string, frames [][]byte, body []byte) { - s := exchangeVectorStep{Name: name, From: from, Body: hex.EncodeToString(body), - Relay: hex.EncodeToString(append(append([]byte(nil), id...), body...))} - for _, f := range frames { - s.Frames = append(s.Frames, hex.EncodeToString(f)) - } - v.Steps = append(v.Steps, s) - } - - // X1: hello with the pairing code in message 1, hello_ok in message 2. - hello := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgHello, appproto.Hello{ - Proto: appproto.ProtoRange{Min: appproto.ProtoMin, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "vectors", UA: "watchos"}, - Code: fixed(0xa1, 16), - }) - message1, err := watch.WriteMessage(hello) - if err != nil { - t.Fatal(err) - } - if got, err := box.ReadMessage(message1); err != nil || !bytes.Equal(got, hello) { - t.Fatalf("the box read message 1 as %x, %v", got, err) - } - step("x1.request", "watch", [][]byte{hello}, message1) - - helloOK := frameOf(t, appwire.LaneBulk, 1024, appproto.MsgHelloOK, appproto.ExchangeHelloOK{ - Proto: appproto.ProtoMax, - Mode: appproto.BoxModeFull, - Clock: appproto.Clock{Source: "ntp", SyncedAtMs: 1_760_000_000_000, UptimeMs: 60_000}, - Role: "watch", - Scopes: []string{"ftw.dispatch.write", "ftw.events.read", "ftw.live.read", "ftw.plan.read"}, - Rev: 7, - }) - message2, err := box.WriteMessage(helloOK) - if err != nil { - t.Fatal(err) - } - if got, err := watch.ReadMessage(message2); err != nil || !bytes.Equal(got, helloOK) { - t.Fatalf("the watch read message 2 as %x, %v", got, err) - } - step("x1.answer", "box", [][]byte{helloOK}, message2) - - watchSession, err := watch.Split() - if err != nil { - t.Fatal(err) - } - boxSession, err := box.Split() - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(watchSession.HandshakeHash, boxSession.HandshakeHash) { - t.Fatal("the two ends disagree on the handshake hash") - } - v.HandshakeHash = hex.EncodeToString(boxSession.HandshakeHash) - watchT, boxT := appwire.NewTransport(watchSession), appwire.NewTransport(boxSession) - - seal := func(tr *appwire.Transport, frames ...[]byte) []byte { - var out []byte - for _, f := range frames { - sealed, err := tr.Encrypt(f) - if err != nil { - t.Fatal(err) - } - out = append(out, sealed...) - } - return out - } - open := func(tr *appwire.Transport, body []byte, frames ...[]byte) { - for i, want := range frames { - got, err := tr.Decrypt(body[i*exchangeRequestBytes : (i+1)*exchangeRequestBytes]) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("transport message %d opened as %x, %v", i, got, err) - } - } - } - - // A command, answered by its ack and its result. - cmd := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmd, appproto.Cmd{ - CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", Op: appproto.OpLoadpointHold, - Args: map[string]any{"id": "lp1", "power_w": 7360, "hold_s": 0, "phase_mode": "3p"}, - NotValidAfterMs: 90_000, Expect: appproto.Expect{Rev: 7}, - }) - body := seal(watchT, cmd) - open(boxT, body, cmd) - step("cmd.request", "watch", [][]byte{cmd}, body) - - ack := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmdAck, appproto.CmdAck{ - CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", LeaseID: "lease-vectors", ExpiresAtMs: 960_000, - }) - result := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmdResult, appproto.CmdResult{ - CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", State: appproto.CmdApplied, - Observed: &appproto.Observed{Value: 7360, Src: appproto.ObservedSrcCore, UptimeMs: 61_000}, - }) - body = seal(boxT, ack, result) - open(watchT, body, ack, result) - step("cmd.answer", "box", [][]byte{ack, result}, body) - - // A read, answered by now and a tick. - soc := int64(620) - read := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgNow, struct{}{}) - body = seal(watchT, read) - open(boxT, body, read) - step("now.request", "watch", [][]byte{read}, body) - - now := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgNow, appproto.Now{ - UptimeMs: 62_000, ControlRev: 7, - Fields: map[string]int64{"1": 2, "2": 1200, "3": -3400, "4": 900, "5": 620, "6": 3700, "10": 7360}, - Sources: map[string]appproto.NowSource{ - "7": {State: appproto.SourceLive, AgeMs: 400}, - "8": {State: appproto.SourceLive, AgeMs: 400}, - "9": {State: appproto.SourceLive, AgeMs: 400}, - }, - DispatchBlockedBy: []string{}, - Loadpoints: []appproto.NowLoadpoint{{ID: "lp1", Plugged: true, SocPermille: &soc, PowerW: 7360}}, - }) - tick := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgTick, appproto.Tick{UptimeMs: 62_000}) - body = seal(boxT, now, tick) - open(watchT, body, now, tick) - step("now.answer", "box", [][]byte{now, tick}, body) - - return v -} - -func TestExchangeVectors(t *testing.T) { - got, err := json.MarshalIndent(buildExchangeVectors(t), "", " ") - if err != nil { - t.Fatal(err) - } - got = append(got, '\n') - const path = "testdata/exchange-vectors.json" - if *updateExchangeVectors { - if err := os.MkdirAll("testdata", 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, got, 0o644); err != nil { - t.Fatal(err) - } - } - want, err := os.ReadFile(path) - if err != nil { - t.Fatalf("%v; regenerate with -update-exchange-vectors", err) - } - if !bytes.Equal(got, want) { - t.Fatal("the exchange no longer produces the bytes in " + path + - "; if the change is meant, regenerate it and update srcfl/ftw-app's copy in the same pair of PRs") - } - - // The sizes the relay checks and the docs promise. - var v exchangeVectors - if err := json.Unmarshal(want, &v); err != nil { - t.Fatal(err) - } - sizes := map[string]int{ - "x1.request": exchangeOpenBytes, "x1.answer": appwire.Message2Overhead + 1024, - "cmd.request": exchangeRequestBytes, "cmd.answer": 2 * exchangeRequestBytes, - "now.request": exchangeRequestBytes, "now.answer": 2 * exchangeRequestBytes, - } - for _, s := range v.Steps { - if n := len(s.Body) / 2; n != sizes[s.Name] { - t.Fatalf("%s is %d bytes, want %d", s.Name, n, sizes[s.Name]) - } - } -} - -// The box's own code path reads the vectors' message 1 and answers with their -// message 2, given the same fixed ephemeral key: the vectors are what the box -// does, not a second implementation that happens to agree with itself. -func TestTheBoxAnswersTheVectorsHandshake(t *testing.T) { - v := buildExchangeVectors(t) - boxStatic, err := appwire.KeyPairFromSecret(fixed(0x01, appwire.DHBytes)) - if err != nil { - t.Fatal(err) - } - boxEphemeral, err := appwire.KeyPairFromSecret(fixed(0x61, appwire.DHBytes)) - if err != nil { - t.Fatal(err) - } - box, err := appwire.NewResponder(appwire.HandshakeOptions{ - StaticKey: boxStatic, Prologue: exchangePrologue(boxStatic.Public()), Ephemeral: &boxEphemeral, - }) - if err != nil { - t.Fatal(err) - } - message1, _ := hex.DecodeString(v.Steps[0].Body) - payload, err := box.ReadMessage(message1) - if err != nil { - t.Fatal(err) - } - if hex.EncodeToString(payload) != v.Steps[0].Frames[0] { - t.Fatal("the box read a different hello out of the vectors' message 1") - } - helloOK, _ := hex.DecodeString(v.Steps[1].Frames[0]) - message2, err := box.WriteMessage(helloOK) - if err != nil { - t.Fatal(err) - } - if hex.EncodeToString(message2) != v.Steps[1].Body { - t.Fatal("the box's message 2 differs from the vectors'") - } - if hex.EncodeToString(box.RemoteStatic()) != v.WatchStaticPublic { - t.Fatal("the box authenticated a different watch key") - } -} diff --git a/go/internal/appuplink/watch_test.go b/go/internal/appuplink/watch_test.go deleted file mode 100644 index 7303ad0f2..000000000 --- a/go/internal/appuplink/watch_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package appuplink - -import ( - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/appenroll" -) - -// A watch reaches the box through the exchange and never through the stream, -// so it can never take a phone's place. Its key on the stream -// gets the silence a stranger gets. -func TestAWatchKeyGetsNoStream(t *testing.T) { - r := newRig(t, 481234) - - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatalf("MintPairingCode: %v", err) - } - phone, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - grant, err := r.enroll.Authorise(phone.staticPublic(), code) - if err != nil { - t.Fatalf("pairing the phone: %v", err) - } - watchCode, _, err := r.enroll.MintWatchCode(grant.DeviceID, appenroll.WatchTTL) - if err != nil { - t.Fatalf("MintWatchCode: %v", err) - } - watch, err := newAppClient() - if err != nil { - t.Fatalf("newAppClient: %v", err) - } - if _, err := r.enroll.AuthoriseWatch(watch.staticPublic(), watchCode); err != nil { - t.Fatalf("adding the watch: %v", err) - } - - conn := r.dialApp(t, 481234) - message1, err := watch.message1(r.enroll.StaticKey().Public(), nil) - if err != nil { - t.Fatalf("message1: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { - t.Fatalf("sending message 1: %v", err) - } - _ = conn.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) - for { - kind, _, err := conn.ReadMessage() - if err != nil { - return // the deadline, which is the pass - } - if kind == websocket.BinaryMessage { - t.Fatal("the box opened a stream for a watch") - } - } -} diff --git a/go/internal/appuplink/watchclient_test.go b/go/internal/appuplink/watchclient_test.go deleted file mode 100644 index 0d08289b0..000000000 --- a/go/internal/appuplink/watchclient_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package appuplink - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/srcfl/ftw/go/internal/appproto" - "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/protocol/appwire" -) - -// watchClient is a watch, in Go: the initiator side of the exchange, built on -// appwire's initiator the way the Swift one is built on CryptoKit. It holds a -// static key across sessions and nothing else. -type watchClient struct { - static appwire.KeyPair - handshake *appwire.InitiatorState - transport *appwire.Transport -} - -func newWatchClient() (*watchClient, error) { - static, err := appwire.GenerateKeyPair() - if err != nil { - return nil, err - } - return &watchClient{static: static}, nil -} - -// lane0 frames one message in lane 0's 512 B bucket, as every exchange -// request is. -func lane0(msgType string, id *uint32, body any) ([]byte, error) { - raw, err := appproto.Marshal(body) - if err != nil { - return nil, err - } - return appwire.EncodeFrame(appwire.Frame{ - Lane: appwire.LaneControl, - Bucket: exchangeFrameBytes, - Envelope: appwire.Envelope{T: msgType, ID: id, B: raw}, - }) -} - -// open is X1's request: handshake message 1 carrying a hello, with the -// pairing code on the first run only. -func (w *watchClient) open(boxStatic, code []byte) ([]byte, error) { - handshake, err := appwire.NewInitiator(appwire.InitiatorOptions{ - StaticKey: w.static, - RemoteStatic: boxStatic, - Prologue: exchangePrologue(boxStatic), - }) - if err != nil { - return nil, err - } - hello, err := lane0(appproto.MsgHello, nil, appproto.Hello{ - Proto: appproto.ProtoRange{Min: appproto.ProtoMin, Max: appproto.ProtoMax}, - App: appproto.AppInfo{Build: "test", UA: "watchos"}, - Code: code, - }) - if err != nil { - return nil, err - } - w.handshake = handshake - w.transport = nil - return handshake.WriteMessage(hello) -} - -// opened reads X1's answer: handshake message 2 carrying hello_ok. -func (w *watchClient) opened(message2 []byte) (appproto.ExchangeHelloOK, appwire.Frame, error) { - var ok appproto.ExchangeHelloOK - payload, err := w.handshake.ReadMessage(message2) - if err != nil { - return ok, appwire.Frame{}, err - } - session, err := w.handshake.Split() - if err != nil { - return ok, appwire.Frame{}, err - } - w.transport = appwire.NewTransport(session) - frame, err := appwire.DecodeFrame(payload) - if err != nil { - return ok, frame, err - } - if frame.Envelope.T != appproto.MsgHelloOK { - return ok, frame, fmt.Errorf("message 2 carries %q", frame.Envelope.T) - } - return ok, frame, appproto.Unmarshal(frame.Envelope.B, &ok) -} - -// request seals one request in the session. -func (w *watchClient) request(msgType string, body any) ([]byte, error) { - frame, err := lane0(msgType, nil, body) - if err != nil { - return nil, err - } - return w.transport.Encrypt(frame) -} - -// answer opens an answer: transport messages back to back, one per frame. -func (w *watchClient) answer(reply []byte) ([]appwire.Frame, error) { - if len(reply)%exchangeRequestBytes != 0 { - return nil, fmt.Errorf("an answer of %d bytes is not whole transport messages", len(reply)) - } - var frames []appwire.Frame - for len(reply) > 0 { - plain, err := w.transport.Decrypt(reply[:exchangeRequestBytes]) - if err != nil { - return nil, err - } - frame, err := appwire.DecodeFrame(plain) - if err != nil { - return nil, err - } - frames = append(frames, frame) - reply = reply[exchangeRequestBytes:] - } - return frames, nil -} - -// -------------------------------------------------------------------------- -// A charger, so a watch has something to do -// -------------------------------------------------------------------------- - -// fakeCharger is one loadpoint, "lp1", plugged in. save is how long the EV -// save takes, which is what an applied charge waits for; slow holds the -// command itself up past what a save may take. -type fakeCharger struct { - mu sync.Mutex - holds int - hold loadpoint.ManualHold - held bool - save time.Duration - slow time.Duration -} - -func (c *fakeCharger) count() int { - c.mu.Lock() - defer c.mu.Unlock() - return c.holds -} - -func (c *fakeCharger) WaitForPersistence(ctx context.Context) error { - c.mu.Lock() - save := c.save - c.mu.Unlock() - select { - case <-time.After(save): - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (c *fakeCharger) Exists(id string) bool { return id == "lp1" } -func (c *fakeCharger) Hold(_ string, h loadpoint.ManualHold) { - c.mu.Lock() - slow := c.slow - c.holds++ - c.hold, c.held = h, true - c.mu.Unlock() - time.Sleep(slow) -} -func (c *fakeCharger) ClearHold(string) { - c.mu.Lock() - defer c.mu.Unlock() - c.held = false -} -func (c *fakeCharger) ObservedHold(string, time.Time) (loadpoint.ManualHold, bool) { - c.mu.Lock() - defer c.mu.Unlock() - return c.hold, c.held -} -func (c *fakeCharger) HoldIfOwn(id string, h loadpoint.ManualHold, now time.Time) bool { - c.Hold(id, h) - return true -} -func (c *fakeCharger) ClearHoldIfOwn(id, _ string, _ time.Time) bool { - c.ClearHold(id) - return true -} -func (c *fakeCharger) Boost(string, loadpoint.BatteryBoostLease, time.Time) error { return nil } -func (c *fakeCharger) CancelBoost(string, time.Time) {} -func (c *fakeCharger) ObservedBoost(string, time.Time) loadpoint.BatteryBoostStatus { - return loadpoint.BatteryBoostStatus{} -} -func (c *fakeCharger) SetSoC(string, float64) bool { return true } -func (c *fakeCharger) ObservedSoC(string) (float64, bool) { return 0.62, true } -func (c *fakeCharger) SetSurplusOnly(string, bool) (bool, bool) { return false, true } -func (c *fakeCharger) ObservedSurplusOnly(string) (bool, bool) { return false, true } -func (c *fakeCharger) Readings() []appproto.LoadpointReading { - return []appproto.LoadpointReading{{ID: "lp1", Plugged: true, SoC: 0.62, SoCKnown: true, PowerW: 7360}} -} - -// chargeNow is the watch's one button: hold lp1 at 7.4 kW until cleared. -func chargeNow(cmdID string) appproto.Cmd { - return appproto.Cmd{ - CmdID: cmdID, - Op: appproto.OpLoadpointHold, - Args: map[string]any{"id": "lp1", "power_w": 7360, "hold_s": 0, "phase_mode": "3p"}, - NotValidAfterMs: 10 * 60 * 1000, - Expect: appproto.Expect{Rev: 1}, - } -} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 378643868..6b3c5f876 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -213,8 +213,8 @@ func (o *OCPP) ChargerSecrets() map[string]string { // AppLink controls the outbound connection the FTW app reaches this box // through. It defaults on when the section is absent; an explicit false is the -// opt-out. One switch is enough because the relay is content-blind and fixed -// by the protocol, so there is no endpoint or transport to choose. +// opt-out. App sessions use the existing FTW Cloud tunnel, so there is no +// separate endpoint or transport to choose. type AppLink struct { Enabled bool `yaml:"enabled" json:"enabled"` } @@ -1704,8 +1704,8 @@ func applyDefaults(c *Config) { } } if c.AppLink == nil { - // The app relay carries only end-to-end encrypted frames under a handle - // that changes every hour. It still sees connection metadata. Existing + // FTW Cloud carries end-to-end encrypted app sessions. It sees + // connection metadata. Existing // sites with no app_link section join the supported remote path after // upgrading; an explicit enabled:false block remains the operator opt-out. c.AppLink = &AppLink{Enabled: true} diff --git a/go/internal/gatewayidentity/stableid.go b/go/internal/gatewayidentity/stableid.go index f1bc32d58..98f992e80 100644 --- a/go/internal/gatewayidentity/stableid.go +++ b/go/internal/gatewayidentity/stableid.go @@ -17,7 +17,7 @@ const ( // identity resolution fail on the lookup, before any route was read. // The MAC of the answering interface is the identity; the host never // is, so this change re-derives the same identity over a live name. - SoftwareIdentityUplinkHost = "relay.ftw.energy" + SoftwareIdentityUplinkHost = "cloud.ftw.energy" SoftwareIdentityUplinkPort = 443 ) diff --git a/go/internal/gatewayidentity/stableid_test.go b/go/internal/gatewayidentity/stableid_test.go index 5e12853e5..16f1d855d 100644 --- a/go/internal/gatewayidentity/stableid_test.go +++ b/go/internal/gatewayidentity/stableid_test.go @@ -13,12 +13,14 @@ import ( type fakeRouteAuthority struct { ips []net.IP lookupErr error + lookupHost string routes map[string]routeResult routeErrs map[string]error interfaces map[int]physicalInterface } -func (f *fakeRouteAuthority) LookupIP(context.Context, string) ([]net.IP, error) { +func (f *fakeRouteAuthority) LookupIP(_ context.Context, host string) ([]net.IP, error) { + f.lookupHost = host return append([]net.IP(nil), f.ips...), f.lookupErr } @@ -56,7 +58,7 @@ func mustMAC(raw string) net.HardwareAddr { } func TestStableInterfaceUsesKernelResolvedOutputForEveryAddress(t *testing.T) { - if SoftwareIdentityUplinkHost != "relay.ftw.energy" { + if SoftwareIdentityUplinkHost != "cloud.ftw.energy" { t.Fatalf("software identity uplink = %q", SoftwareIdentityUplinkHost) } authority := &fakeRouteAuthority{ @@ -79,6 +81,9 @@ func TestStableInterfaceUsesKernelResolvedOutputForEveryAddress(t *testing.T) { if err != nil { t.Fatal(err) } + if authority.lookupHost != "cloud.ftw.energy" { + t.Fatalf("route lookup used %q", authority.lookupHost) + } if got.Index != 7 || got.Name != "enp1s0" || got.PermanentMAC.String() != "00:11:22:33:44:55" { t.Fatalf("stable interface = %+v", got)