diff --git a/.changeset/watch-exchange-through-cloud.md b/.changeset/watch-exchange-through-cloud.md new file mode 100644 index 00000000..2cd168a8 --- /dev/null +++ b/.changeset/watch-exchange-through-cloud.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +An Apple Watch can charge the car without its iPhone near. Its commands reach the box through FTW Cloud as short encrypted exchanges the box answers itself, and an owner's phone adds the watch by its key, with a passkey step-up. The box lists `cmd.exchange` while FTW Cloud carries them. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 9dc51ee9..4ac24905 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -766,11 +766,11 @@ func startAppLink( // the same whichever session reads it. views := agentViews{site: &cloudSite{site: site, api: gateway, cars: carsRT}, cars: carsRT} - // handlerFor builds one handler for one session: a phone's stream, or a - // watch's exchange. They share the ports above, which are read-only - // apart from the mode and the command log, and the mode goes through - // control's own validation. - handlerFor := func() appuplink.HandlerBuilder { + // handlerFor builds one handler for one session: a phone's stream, or + // with exchange set a watch's exchange. They share the ports above, which + // are read-only apart from the mode and the command log, and the mode + // goes through control's own validation. + handlerFor := func(exchange bool) appuplink.HandlerBuilder { return func( sender appproto.Sender, caller apiauth.Caller, @@ -793,6 +793,7 @@ func startAppLink( Caller: caller, Grants: grants, Commands: cmds, + Exchange: exchange, Access: agents.access, Agents: agents.state, Audit: agents.audit, @@ -818,16 +819,17 @@ func startAppLink( } uplink, err := appuplink.New(appuplink.Options{ - Enroll: enroll, - Handler: handlerFor(), - Arrivals: arrivalsFor(ctx, tel), + Enroll: enroll, + Handler: handlerFor(false), + ExchangeHandler: handlerFor(true), + Arrivals: arrivalsFor(ctx, tel), }) if err != nil { return enroll, nil, enabled, err } agents.access.Drop = func(key []byte) { uplink.DropSessionsByAppKey(key) } agents.boxPage = func(by string) (*appproto.Handler, error) { - return handlerFor()(discardSender{}, boxPageCaller(by), boxPageGrant{}) + return handlerFor(false)(discardSender{}, boxPageCaller(by), boxPageGrant{}) } go sweepAgents(ctx, agents) @@ -940,10 +942,10 @@ func (a *appLinkAPI) MintPairingCode(role string) ([]byte, time.Time, error) { return code, expires, appLinkError(err) } -// MintWatchCode issues a watch code bound to the owner phone that asked. -func (a *appLinkAPI) MintWatchCode(parentID string) ([]byte, time.Time, error) { - code, expires, err := a.enroll.MintWatchCode(parentID, appenroll.WatchTTL) - return code, expires, appLinkError(err) +// AddWatch adds a watch by its key under the owner phone that asked. +func (a *appLinkAPI) AddWatch(parentID string, key []byte) (string, error) { + grant, err := a.enroll.AddWatch(parentID, key) + return grant.DeviceID, appLinkError(err) } func (a *appLinkAPI) MintSpokenCode(role string) (string, time.Time, error) { @@ -1068,6 +1070,8 @@ func appLinkError(err error) error { return api.ErrWatchAppRole case errors.Is(err, appenroll.ErrWatchNeedsOwner): return api.ErrWatchNeedsOwner + case errors.Is(err, appenroll.ErrWatchKey): + return api.ErrWatchAppKey default: return err } diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 8f2fedaf..128d74ff 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -397,12 +397,13 @@ func (n *cloudNotices) notify(kind string) { // enrolment, which holds the phones the roster lists; without either it // returns nil and the routes say FTW Cloud is unavailable. tunnel is the app // uplink's session table, nil when the app link is off, and then the box -// takes no tun and links only for a grant. +// takes no tun and links only for a grant. exchange is the watches' door, nil +// with the app link off; it rides beside the tunnel. func startCloudLink( ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity, tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision, siteMeterStale time.Duration, measurementOptions func() telemetry.ForecastOptions, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices, - tunnel *appuplink.Tunnel, carsRT *cars.Runtime, webPush *notifications.WebPush, + tunnel *appuplink.Tunnel, exchange *appuplink.Exchange, carsRT *cars.Runtime, webPush *notifications.WebPush, ) *cloudlink.Link { if st == nil || enroll == nil { return nil @@ -430,6 +431,9 @@ func startCloudLink( if tunnel != nil { opts.Tunnel = tunnel } + if exchange != nil { + opts.Exchange = exchange + } link, err := cloudlink.New(opts) if err != nil { slog.Warn("FTW Cloud unavailable", "err", err) @@ -438,6 +442,9 @@ func startCloudLink( if tunnel != nil { tunnel.Attach(link) } + if exchange != nil { + exchange.Attach(link) + } enroll.OnChange(link.RosterChanged) // A code shown links the box at once, so a QR that names the home can. enroll.OnPairingCode(link.RosterChanged) @@ -481,6 +488,11 @@ func (c *cloudSession) caps(caps []string) []string { if link.TakesTun() { caps = append(caps, appproto.CapCloudTun) } + // A watch reaches the box through FTW Cloud's exchange, so the phone + // offers to add one only while the link carries it. + if link.TakesX() { + caps = append(caps, appproto.CapCmdExchange) + } return caps } diff --git a/go/cmd/ftw/cloud_roster_test.go b/go/cmd/ftw/cloud_roster_test.go index 6f6d9ab7..9b3673a2 100644 --- a/go/cmd/ftw/cloud_roster_test.go +++ b/go/cmd/ftw/cloud_roster_test.go @@ -53,11 +53,7 @@ func TestTheRosterNarrowsAWatchToWhatItsPhoneHolds(t *testing.T) { } rosterPhone(t, id, apiauth.RoleOwner) phone := rosterPhone(t, id, apiauth.RoleOwner) - code, _, err := id.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) - if err != nil { - t.Fatal(err) - } - watch, err := id.AuthoriseWatch(rosterKey(t), code) + watch, err := id.AddWatch(phone.DeviceID, rosterKey(t)) if err != nil { t.Fatal(err) } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index a8a01fd0..fb707f74 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2473,11 +2473,13 @@ func main() { // signed, readable copy of the house, off until an owner turns it on. // Both use the one FTW Cloud connection. var appTunnel *appuplink.Tunnel + var appExchange *appuplink.Exchange if appUplink != nil { appTunnel = appUplink.Tunnel() + appExchange = appUplink.Exchange() } cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, - tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, liveMeasurementOptions, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush) + tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, liveMeasurementOptions, priceSvc, appAPI, cloudNotify, appTunnel, appExchange, carsRT, webPush) appCloud.bind(cloudLink) if cloudLink != nil { agents.home = cloudLink.Home diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go index 4b025e2d..c006963e 100644 --- a/go/internal/api/api_app_link.go +++ b/go/internal/api/api_app_link.go @@ -1,6 +1,7 @@ package api import ( + "encoding/base64" "errors" "net" "net/http" @@ -39,11 +40,11 @@ type AppEnroller interface { // MintSpokenCode issues a box code: eight characters somebody can read // down a phone. Same single-use, same replacement of any live code. MintSpokenCode(role string) (string, time.Time, error) - // MintWatchCode issues a code that lets one watch in, bound to the owner - // phone named by parentID. It has a slot of its own, so it leaves a - // pending phone code alone. Returns ErrWatchNeedsOwner when that phone is - // not an owner now. - MintWatchCode(parentID string) ([]byte, time.Time, error) + // AddWatch stores a watch's Noise key under the owner phone named by + // parentID, and returns the watch's device id. Returns + // ErrWatchNeedsOwner when that phone is not an owner now, and + // ErrWatchAppKey for a key that cannot be a watch's. + AddWatch(parentID string, key []byte) (string, error) // EnrollmentURL is what goes in the QR. EnrollmentURL(code []byte, lanHint string) (string, error) // AuthorisedCount is how many phones may currently connect. Watches @@ -97,9 +98,11 @@ var ( // ErrWatchAppRole is the watch role asked for where it cannot be given: // a phone's code, or a role change to or from watch. ErrWatchAppRole = errors.New("api: a watch comes only from its phone") - // ErrWatchNeedsOwner is a watch code asked for by a phone that is not an - // owner. + // ErrWatchNeedsOwner is a watch added by a phone that is not an owner. ErrWatchNeedsOwner = errors.New("api: only an owner phone can add a watch") + // ErrWatchAppKey is a watch key the box cannot add: not 32 bytes, or a + // key it knows as something else. + ErrWatchAppKey = errors.New("api: that is not a watch's key") ) type appLinkStatus struct { @@ -126,8 +129,11 @@ type appLinkPairing struct { // gets read to the wrong person. Role string `json:"role"` // ExpiresAtMs is when the code stops working, so the UI can say so rather - // than leaving a stale square on screen. + // than leaving a stale square on screen. Zero for a watch added by its + // key, which needs no code. ExpiresAtMs int64 `json:"expires_at_ms"` + // DeviceID is the watch a key added, by the id the box lists it under. + DeviceID string `json:"device_id,omitempty"` } // appLinkPairingRequest is what a page or an app asks for. @@ -142,6 +148,9 @@ type appLinkPairingRequest struct { // Kind is "qr", "spoken", or "claim" for a code entered on the LAN. Kind string `json:"kind"` Code string `json:"code,omitempty"` + // Key is a watch's Noise static key, base64url without padding. With + // role watch it adds that watch at once, with no code. + Key string `json:"key,omitempty"` } // appLinkRoleRequest changes what one paired phone may do. @@ -249,20 +258,19 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) { // appPairingKindSpoken asks for a code somebody can read aloud. const appPairingKindSpoken = "spoken" -// handleWatchPairing mints a watch code for the owner phone asking (ADR 0009, -// decision 4). +// handleWatchPairing adds a watch by its key for the owner phone asking (ADR +// 0009, decision 4, "Through FTW Cloud"). // // Three rules, each refused rather than bent. The request comes over a -// session, because the code is bound to the phone that asks and the LAN has +// session, because the watch is bound to the phone that asks and the LAN has // no phone to bind it to. The phone is an owner now, because a watch never // holds more than its phone and only an owner adds a device. And this very // request carried a passkey step-up: the grace window the passthrough keeps // for settings screens does not stand in for one, since a watch is a new key // that can charge the car from anywhere. // -// The answer is a QR payload like any other code's, with the box's static key, -// the rendezvous secret and the code. The phone hands it to its watch; nothing -// in it is new to the phone, and none of it passes a Sourceful service. +// A request without a key is refused: a watch code had no way in once the +// relay stopped, because FTW Cloud admits only what the roster lists. func (s *Server) handleWatchPairing(w http.ResponseWriter, r *http.Request, req appLinkPairingRequest) { caller, named := apiauth.FromRequest(r) if !named || caller.Kind != apiauth.KindApp { @@ -280,35 +288,41 @@ func (s *Server) handleWatchPairing(w http.ResponseWriter, r *http.Request, req "confirm with your passkey to add a watch.") return } - if req.Kind == appPairingKindSpoken { + if req.Key == "" { writeAppLinkError(w, http.StatusBadRequest, - "a watch code goes from the phone to the watch, never read aloud.") + "update the FTW app to add this watch: it is added by its key.") return } + s.addWatchByKey(w, r, req.Key) +} - code, expiresAt, err := s.deps.AppEnroll.MintWatchCode(callerDeviceID(r)) +// addWatchByKey adds the watch whose key the phone names, under the same +// three rules as a watch code (handleWatchPairing has checked them). The +// watch reaches the box through FTW Cloud, which admits only what the roster +// lists, so a code it would have to carry through the service has no place: +// the phone's own session vouches for the key, and the watch proves it holds +// the key in every handshake after. +func (s *Server) addWatchByKey(w http.ResponseWriter, r *http.Request, encoded string) { + key, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + writeAppLinkError(w, http.StatusBadRequest, "that is not a watch's key.") + return + } + id, err := s.deps.AppEnroll.AddWatch(callerDeviceID(r), key) switch { case err == nil: case errors.Is(err, ErrWatchNeedsOwner), errors.Is(err, ErrUnknownAppDevice): writeAppLinkError(w, http.StatusForbidden, "only an owner's phone can add a watch.") return - default: - writeAppLinkError(w, http.StatusInternalServerError, "could not mint a watch code") + case errors.Is(err, ErrWatchAppKey): + writeAppLinkError(w, http.StatusBadRequest, "that is not a watch's key.") return - } - // No LAN hint: the watch picks its own network and reaches the box - // through the relay's exchange. - url, err := s.deps.AppEnroll.EnrollmentURL(code, "") - if err != nil { - writeAppLinkError(w, http.StatusInternalServerError, "could not build the watch code") + default: + writeAppLinkError(w, http.StatusInternalServerError, "could not add the watch") return } - writeJSON(w, http.StatusOK, appLinkPairing{ - URL: url, - Role: apiauth.RoleWatch, - ExpiresAtMs: expiresAt.UnixMilli(), - }) + writeJSON(w, http.StatusOK, appLinkPairing{Role: apiauth.RoleWatch, DeviceID: id}) } func (s *Server) writeAppLinkMintError(w http.ResponseWriter, err error) { diff --git a/go/internal/api/api_app_link_session_test.go b/go/internal/api/api_app_link_session_test.go index 719b6a2b..37adb61a 100644 --- a/go/internal/api/api_app_link_session_test.go +++ b/go/internal/api/api_app_link_session_test.go @@ -56,9 +56,9 @@ func (e *realEnroller) MintSpokenCode(role string) (string, time.Time, error) { return code, expires, enrollError(err) } -func (e *realEnroller) MintWatchCode(parentID string) ([]byte, time.Time, error) { - code, expires, err := e.id.MintWatchCode(parentID, appenroll.WatchTTL) - return code, expires, enrollError(err) +func (e *realEnroller) AddWatch(parentID string, key []byte) (string, error) { + grant, err := e.id.AddWatch(parentID, key) + return grant.DeviceID, enrollError(err) } func (e *realEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) { @@ -101,6 +101,8 @@ func enrollError(err error) error { return ErrWatchAppRole case errors.Is(err, appenroll.ErrWatchNeedsOwner): return ErrWatchNeedsOwner + case errors.Is(err, appenroll.ErrWatchKey): + return ErrWatchAppKey default: return err } @@ -495,10 +497,10 @@ func TestNoDoorButItsPhoneHandsOutAWatch(t *testing.T) { name string serve func(http.ResponseWriter) }{ - {"a scanned watch code from the LAN", func(w http.ResponseWriter) { + {"a watch from the LAN", func(w http.ResponseWriter) { s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch"}`)) }}, - {"a spoken watch code from the LAN", func(w http.ResponseWriter) { + {"a spoken watch from the LAN", func(w http.ResponseWriter) { s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch","kind":"spoken"}`)) }}, {"the guest changed into a watch from the LAN", func(w http.ResponseWriter) { @@ -514,12 +516,11 @@ func TestNoDoorButItsPhoneHandsOutAWatch(t *testing.T) { } } + // Over a session, from a phone this box does not know as an owner. rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) - status, refusal, body := rig.call(t, 1, appproto.APIReq{ - Method: appproto.APIPost, Path: "/api/app-link/pairing", - Body: []byte(`{"role":"watch"}`), StepUp: true}) + status, refusal, body := rig.call(t, 1, watchByKey(appKey(9), true)) if status != http.StatusForbidden { - t.Fatalf("a watch code over the session answered %d %q %s, want 403", status, refusal, body) + t.Fatalf("a watch over the session answered %d %q %s, want 403", status, refusal, body) } for _, d := range enrol.Devices() { diff --git a/go/internal/api/api_app_link_test.go b/go/internal/api/api_app_link_test.go index 7e82aba6..663f7e85 100644 --- a/go/internal/api/api_app_link_test.go +++ b/go/internal/api/api_app_link_test.go @@ -28,7 +28,7 @@ type stubEnroller struct { spoken int watch int revoked int - // watchParent is the phone the last watch code was bound to. + // watchParent is the phone the last watch was added under. watchParent string mintedRole string roleSet string @@ -69,13 +69,13 @@ func (s *stubEnroller) MintSpokenCode(role string) (string, time.Time, error) { return "ABCD-EFGH", time.Now().Add(5 * time.Minute), nil } -func (s *stubEnroller) MintWatchCode(parentID string) ([]byte, time.Time, error) { +func (s *stubEnroller) AddWatch(parentID string, key []byte) (string, error) { if s.err != nil { - return nil, time.Time{}, s.err + return "", s.err } s.watch++ s.watchParent = parentID - return make([]byte, 16), time.Now().Add(10 * time.Minute), nil + return "wAtCh123", nil } func (s *stubEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) { diff --git a/go/internal/api/api_app_link_watch_test.go b/go/internal/api/api_app_link_watch_test.go index 1cbdb827..bfe3373e 100644 --- a/go/internal/api/api_app_link_watch_test.go +++ b/go/internal/api/api_app_link_watch_test.go @@ -60,37 +60,27 @@ func watchPairing(stepUp bool) appproto.APIReq { } } -func TestAnOwnerPhoneAddsAWatchThroughTheApp(t *testing.T) { +// A watch is added by its key. A request without one comes from an app that +// would carry a code, which no watch can redeem once the relay stopped, so it +// is refused and adds nothing. +func TestAWatchWithoutAKeyIsRefused(t *testing.T) { enrol := newWatchEnrolment(t) rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) - status, refusal, body := rig.call(t, 1, watchPairing(true)) - if status != http.StatusOK { - t.Fatalf("adding a watch answered %d %q %s", status, refusal, body) - } - var answer appLinkPairing - if err := json.Unmarshal(body, &answer); err != nil { - t.Fatalf("decoding the watch code: %v (%q)", err, body) + status, _, body := rig.call(t, 1, watchPairing(true)) + if status != http.StatusBadRequest { + t.Fatalf("a watch without a key answered %d %s", status, body) } - if answer.Role != apiauth.RoleWatch || answer.URL == "" || answer.Code != "" { - t.Fatalf("the answer is %+v", answer) - } - if hint := lanHintIn(t, answer.URL); hint != "" { - t.Fatalf("the watch code carries a LAN hint %q", hint) - } - - grant, err := enrol.id.AuthoriseWatch(appKey(9), pairingCodeIn(t, answer.URL)) - if err != nil { - t.Fatalf("the watch could not join with the code its phone got: %v", err) - } - if grant.Role != apiauth.RoleWatch || grant.Parent != "aBcD1234" { - t.Fatalf("the box wrote down %+v", grant) + for _, d := range enrol.Devices() { + if d.Role == apiauth.RoleWatch { + t.Fatal("a watch joined") + } } } // The grace window a settings screen opens does not stand in for a step-up: // a watch is a new key that can charge the car from anywhere. -func TestAWatchCodeNeedsAStepUpOnTheRequestItself(t *testing.T) { +func TestAWatchNeedsAStepUpOnTheRequestItself(t *testing.T) { enrol := newWatchEnrolment(t) rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) @@ -105,9 +95,9 @@ func TestAWatchCodeNeedsAStepUpOnTheRequestItself(t *testing.T) { t.Fatalf("opening the window answered %d %q %s", status, refusal, body) } - status, refusal, body := rig.call(t, 2, watchPairing(false)) + status, refusal, body := rig.call(t, 2, watchByKey(appKey(9), false)) if status != http.StatusForbidden { - t.Fatalf("a watch code without a step-up answered %d %q %s", status, refusal, body) + t.Fatalf("a watch without a step-up answered %d %q %s", status, refusal, body) } var answer map[string]string if err := json.Unmarshal(body, &answer); err != nil { @@ -129,7 +119,7 @@ func TestOnlyAnOwnersPhoneAddsAWatch(t *testing.T) { enrol := newWatchEnrolment(t) viewer := newAppSession(t, apiauth.RoleViewer, withEnrolment(enrol)) - if status, refusal, _ := viewer.call(t, 1, watchPairing(true)); status == http.StatusOK { + if status, refusal, _ := viewer.call(t, 1, watchByKey(appKey(9), true)); status == http.StatusOK { t.Fatalf("a viewer added a watch (%q)", refusal) } @@ -154,7 +144,7 @@ func TestAddingAWatchLeavesAnInviteAlive(t *testing.T) { if err := json.Unmarshal(inviteBody, &invite); err != nil { t.Fatal(err) } - if status, refusal, body := rig.call(t, 2, watchPairing(true)); status != http.StatusOK { + if status, refusal, body := rig.call(t, 2, watchByKey(appKey(9), true)); status != http.StatusOK { t.Fatalf("adding a watch answered %d %q %s", status, refusal, body) } if _, err := enrol.id.Authorise(appKey(3), pairingCodeIn(t, invite.URL)); err != nil { @@ -165,11 +155,7 @@ func TestAddingAWatchLeavesAnInviteAlive(t *testing.T) { // The list names each watch's phone, and a watch keeps its role. func TestTheListShowsAWatchUnderItsPhone(t *testing.T) { enrol := newWatchEnrolment(t) - code, _, err := enrol.id.MintWatchCode("aBcD1234", appenroll.WatchTTL) - if err != nil { - t.Fatal(err) - } - watch, err := enrol.id.AuthoriseWatch(appKey(9), code) + watch, err := enrol.id.AddWatch("aBcD1234", appKey(9)) if err != nil { t.Fatal(err) } @@ -197,3 +183,108 @@ func TestTheListShowsAWatchUnderItsPhone(t *testing.T) { t.Fatalf("changing a watch's role answered %d %s", status, body) } } + +func watchByKey(key []byte, stepUp bool) appproto.APIReq { + return appproto.APIReq{ + Method: appproto.APIPost, + Path: "/api/app-link/pairing", + Body: []byte(`{"role":"watch","key":"` + base64.RawURLEncoding.EncodeToString(key) + `"}`), + StepUp: stepUp, + } +} + +// Through FTW Cloud a watch has no code to carry: the phone names its key +// over its own session, and the box lists the watch at once under that phone. +func TestAnOwnerPhoneAddsAWatchByItsKey(t *testing.T) { + enrol := newWatchEnrolment(t) + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + + status, refusal, body := rig.call(t, 1, watchByKey(appKey(9), true)) + if status != http.StatusOK { + t.Fatalf("adding a watch by its key answered %d %q %s", status, refusal, body) + } + var answer appLinkPairing + if err := json.Unmarshal(body, &answer); err != nil { + t.Fatal(err) + } + if answer.Role != apiauth.RoleWatch || answer.URL != "" || answer.Code != "" || answer.DeviceID == "" { + t.Fatalf("the answer is %+v", answer) + } + // The watch's first handshake needs no code. + grant, _, err := enrol.id.CheckWatch(appKey(9)) + if err != nil { + t.Fatalf("the watch could not open with its key alone: %v", err) + } + if grant.DeviceID != answer.DeviceID || grant.Role != apiauth.RoleWatch || grant.Parent != "aBcD1234" { + t.Fatalf("the box wrote down %+v for %q", grant, answer.DeviceID) + } + + // Asking again is the same watch, not a second one. + if status, refusal, body := rig.call(t, 2, watchByKey(appKey(9), true)); status != http.StatusOK { + t.Fatalf("adding the same watch again answered %d %q %s", status, refusal, body) + } + watches := 0 + for _, d := range enrol.Devices() { + if d.Role == apiauth.RoleWatch { + watches++ + } + } + if watches != 1 { + t.Fatalf("the box lists %d watches", watches) + } +} + +// A key goes through the same three rules as a code: a session, an owner, a +// step-up on the request. +func TestAWatchKeyMeetsTheSameRules(t *testing.T) { + enrol := newWatchEnrolment(t) + + // Without a step-up the session refuses before the route, or the route + // does when a settings screen opened the grace window; either way the + // app hears that it must ask for the passkey. + owner := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + status, code, body := owner.call(t, 1, watchByKey(appKey(9), false)) + if status == http.StatusForbidden { + var answer map[string]string + _ = json.Unmarshal(body, &answer) + code = answer["code"] + } + if code != appproto.ErrNeedsStepUp { + t.Fatalf("a watch key without a step-up answered %d %q %s", status, code, body) + } + + viewer := newAppSession(t, apiauth.RoleViewer, withEnrolment(enrol)) + if status, refusal, _ := viewer.call(t, 1, watchByKey(appKey(9), true)); status == http.StatusOK { + t.Fatalf("a viewer added a watch by its key (%q)", refusal) + } + + s := New(&Deps{AppEnroll: enrol}) + w := httptest.NewRecorder() + req := pairingRequestRole("192.168.1.1", "127.0.0.1:1234", nil, apiauth.RoleWatch) + s.handleAppLinkPairing(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("the box's page added a watch: %d %s", w.Code, w.Body.String()) + } + + for _, d := range enrol.Devices() { + if d.Role == apiauth.RoleWatch { + t.Fatal("a watch joined") + } + } +} + +// A key that cannot be a watch's is refused: the wrong length, or the +// phone's own key. +func TestAKeyThatIsNoWatchIsRefused(t *testing.T) { + enrol := newWatchEnrolment(t) + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + // Each request on the session has its own id. + for i, c := range []struct { + name string + key []byte + }{{"short", appKey(9)[:16]}, {"the phone's own", sessionPhoneKey(t)}} { + if status, _, body := rig.call(t, uint32(i+1), watchByKey(c.key, true)); status != http.StatusBadRequest { + t.Fatalf("%s key answered %d %s", c.name, status, body) + } + } +} diff --git a/go/internal/appenroll/agents_test.go b/go/internal/appenroll/agents_test.go index aab2a835..e11762e1 100644 --- a/go/internal/appenroll/agents_test.go +++ b/go/internal/appenroll/agents_test.go @@ -259,7 +259,7 @@ func TestAnAgentComesOnlyThroughTheTunnel(t *testing.T) { if _, _, err := b.id.Check(key, nil); !errors.Is(err, ErrNoPairing) { t.Errorf("the relay admitted an agent: %v", err) } - if _, err := b.id.AuthoriseWatch(key, nil); !errors.Is(err, ErrNoPairing) { + if _, _, err := b.id.CheckWatch(key); !errors.Is(err, ErrNoPairing) { t.Errorf("the watch door admitted an agent: %v", err) } grant, commit, err := b.id.CheckTunnel(key, nil) diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 10902771..8f9fcfae 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -215,10 +215,7 @@ type Identity struct { prepareInviteHome func() (string, error) onInviteClaimed func(protocol.AgentInvite, protocol.AgentRow) pairing *pairingCode - // watchPairing is the watch code, in a slot of its own so that adding a - // watch never cancels a phone's pending code. See MintWatchCode. - watchPairing *pairingCode - now func() time.Time + now func() time.Time // randRead is the entropy source. Injectable so a test can prove the // failure path instead of only the happy one. randRead func([]byte) (int, error) @@ -298,9 +295,6 @@ type pairingCode struct { kind string // attempts counts wrong guesses against this code. See Authorise. attempts int - // parent is the full key of the phone a watch code was minted for, and - // empty on a phone's code. - parent string } // Kinds of pairing code. @@ -654,7 +648,7 @@ func knownRole(role string) error { } // phoneRole refuses a role a phone cannot hold: one the registry does not -// define, watch, which only MintWatchCode hands out, and an agent's, which +// define, watch, which only an owner's phone hands out by key (AddWatch), and an agent's, which // only an owner's signed grant hands out. func phoneRole(role string) error { if err := knownRole(role); err != nil { @@ -1143,10 +1137,6 @@ func (i *Identity) Revoke(id string, from Presence) ([]Removed, error) { for _, key := range gone { delete(i.authorised, key) } - // A watch code minted by this phone dies with it, as its watches do. - if i.watchPairing != nil && i.watchPairing.parent == fullKey { - i.watchPairing = nil - } i.mu.Unlock() err := i.save() diff --git a/go/internal/appenroll/watch.go b/go/internal/appenroll/watch.go index 2aab4e80..45e24c48 100644 --- a/go/internal/appenroll/watch.go +++ b/go/internal/appenroll/watch.go @@ -1,105 +1,103 @@ package appenroll import ( - "crypto/subtle" "encoding/base64" - "fmt" - "time" + "errors" "github.com/srcfl/ftw/go/internal/apiauth" ) -// WatchTTL is how long a watch code stays usable. The phone hands the code to -// its watch at once, so the life of an owner's code is ample. -const WatchTTL = PairingTTL - -// MintWatchCode issues a single-use code that lets one watch in, bound to the -// owner phone that asked for it (ADR 0009, decision 4). -// -// The code has its own slot. Minting it replaces a pending watch code and -// leaves a phone's pending code alone, so adding a watch never cancels an -// invite somebody is about to scan. +// CheckWatch decides whether a finished exchange handshake may become a +// watch's session, and changes nothing until commit runs. A watch the owner's +// phone added by its key (AddWatch) is admitted; anything else, a phone's key +// among them, answers as a stranger: phones have their stream. There is no +// code: a watch reaches the box through FTW Cloud, which admits only what +// the roster lists. // -// The phone must be an owner now. The API checks that too, and only over a -// session with a fresh passkey step-up; the check here keeps the rule a -// property of the box rather than of one route. -func (i *Identity) MintWatchCode(parentID string, ttl time.Duration) ([]byte, time.Time, error) { - code := make([]byte, PairingCodeBytes) - if _, err := i.randRead(code); err != nil { - return nil, time.Time{}, fmt.Errorf("appenroll: minting a watch code: %w", err) - } - expires := i.now().Add(ttl) - - i.mu.Lock() - defer i.mu.Unlock() - parentKey, parent := i.findLocked(parentID) - if parent == nil { - return nil, time.Time{}, ErrUnknownDevice - } - if parent.role != apiauth.RoleOwner { - return nil, time.Time{}, ErrWatchNeedsOwner - } - i.watchPairing = &pairingCode{ - code: code, expiresAt: expires, role: apiauth.RoleWatch, kind: PairingKindQR, parent: parentKey, - } - return append([]byte(nil), code...), expires, nil -} - -// AuthoriseWatch decides whether a finished exchange handshake may become a -// watch's session. It is Authorise for the watch's door. -// -// A watch already on the list needs no code. Anything else needs the live -// watch code, and spending it stores the watch's key with role watch and the -// phone that minted the code as its parent. A phone's key answers as a -// stranger: phones have their stream. -// -// The code is spent, timed and counted as a phone's code is. The parent must -// still be an owner when the code is spent; a phone demoted or removed since -// it asked burns the code instead. -func (i *Identity) AuthoriseWatch(watchStatic, code []byte) (Grant, error) { +// The exchange commits once the session has proved itself with its first +// transport message, so a message 1 replayed by whoever carries it stamps +// nothing and writes nothing. commit returns the grant as it stands then, and +// fails if the watch or its phone was removed meanwhile. +func (i *Identity) CheckWatch(watchStatic []byte) (Grant, func() (Grant, error), error) { key := base64.RawURLEncoding.EncodeToString(watchStatic) - i.mu.Lock() - if meta := i.authorised[key]; meta != nil { - if meta.role != apiauth.RoleWatch || i.authorised[meta.parent] == nil { + meta := i.authorised[key] + if meta == nil || meta.role != apiauth.RoleWatch || i.authorised[meta.parent] == nil { + i.mu.Unlock() + return Grant{}, nil, ErrNoPairing + } + grant := i.grantLocked(key, meta) + i.mu.Unlock() + return grant, func() (Grant, error) { + i.mu.Lock() + meta := i.authorised[key] + if meta == nil || meta.role != apiauth.RoleWatch || i.authorised[meta.parent] == nil { i.mu.Unlock() return Grant{}, ErrNoPairing } meta.lastSeenMs = i.now().UnixMilli() grant := i.grantLocked(key, meta) i.mu.Unlock() + // A stamp that could not be persisted must not block a session. _ = i.save() return grant, nil + }, nil +} + +// HasWatch reports whether a device id names a watch whose phone is still +// paired. It costs a map walk and no cryptography, so the exchange asks it +// before a handshake's Diffie-Hellman. +func (i *Identity) HasWatch(deviceID string) bool { + i.mu.Lock() + defer i.mu.Unlock() + _, meta := i.findLocked(deviceID) + return meta != nil && meta.role == apiauth.RoleWatch && i.authorised[meta.parent] != nil +} + +// WatchKeyBytes is the length of a watch's Noise static key. +const WatchKeyBytes = 32 + +// ErrWatchKey is a watch key that is not one: the wrong length, or a key the +// box already knows as a phone or an agent. +var ErrWatchKey = errors.New("appenroll: that is not a watch key this box can add") + +// AddWatch stores a watch's key with role watch and the owner phone named by +// parentID as its parent, with no code (ADR 0009, decision 4, as FTW Cloud +// carries it). The phone vouches for the key over its own session, with a +// passkey step-up the API checks; the watch proves it holds the key in every +// handshake after. No code crosses FTW Cloud, and the next roster lists the +// watch, so its first request is admitted like any other. +// +// Adding the same watch again changes nothing. The parent must be an owner +// now, as for a watch code. +func (i *Identity) AddWatch(parentID string, watchStatic []byte) (Grant, error) { + if len(watchStatic) != WatchKeyBytes { + return Grant{}, ErrWatchKey } - if len(code) == 0 { + key := base64.RawURLEncoding.EncodeToString(watchStatic) + + i.mu.Lock() + parentKey, parent := i.findLocked(parentID) + switch { + case parent == nil: i.mu.Unlock() - return Grant{}, ErrNoPairing - } - pairing := i.watchPairing - if pairing == nil || !i.now().Before(pairing.expiresAt) { + return Grant{}, ErrUnknownDevice + case parent.role != apiauth.RoleOwner: i.mu.Unlock() - return Grant{}, ErrBadPairing + return Grant{}, ErrWatchNeedsOwner } - if subtle.ConstantTimeCompare(code, pairing.code) != 1 { - pairing.attempts++ - if pairing.attempts >= MaxPairingAttempts { - i.watchPairing = nil + if meta := i.authorised[key]; meta != nil { + if meta.role != apiauth.RoleWatch || meta.parent != parentKey { + i.mu.Unlock() + return Grant{}, ErrWatchKey } + grant := i.grantLocked(key, meta) i.mu.Unlock() - return Grant{}, ErrBadPairing - } - if parent := i.authorised[pairing.parent]; parent == nil || parent.role != apiauth.RoleOwner { - i.watchPairing = nil - i.mu.Unlock() - return Grant{}, ErrBadPairing + return grant, nil } - now := i.now().UnixMilli() - meta := &appMeta{ - addedAtMs: now, lastSeenMs: now, role: apiauth.RoleWatch, epoch: firstEpoch, parent: pairing.parent, - } + meta := &appMeta{addedAtMs: now, role: apiauth.RoleWatch, epoch: firstEpoch, parent: parentKey} i.authorised[key] = meta - i.watchPairing = nil grant := i.grantLocked(key, meta) i.mu.Unlock() diff --git a/go/internal/appenroll/watch_test.go b/go/internal/appenroll/watch_test.go index 60b169f2..b72ef406 100644 --- a/go/internal/appenroll/watch_test.go +++ b/go/internal/appenroll/watch_test.go @@ -14,22 +14,27 @@ import ( "github.com/srcfl/ftw/go/internal/apiauth" ) -// addWatch walks one watch through a code its phone minted and returns the -// watch's key and the grant the box stamped on it. +// addWatch adds one watch by its key, as its phone does over a session, and +// returns the watch's key and the grant the box stamped on it. func addWatch(t *testing.T, id *Identity, phone Grant) ([]byte, Grant) { t.Helper() - code, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatalf("MintWatchCode: %v", err) - } key := appKey(t) - grant, err := id.AuthoriseWatch(key, code) + grant, err := id.AddWatch(phone.DeviceID, key) if err != nil { t.Fatalf("adding a watch: %v", err) } return key, grant } +// checkWatch is the exchange's door, decided and committed at once. +func checkWatch(id *Identity, key []byte) (Grant, error) { + _, commit, err := id.CheckWatch(key) + if err != nil { + return Grant{}, err + } + return commit() +} + func TestAnOwnerPhoneAddsAWatchThatNamesItAsParent(t *testing.T) { id, _ := newIdentity(t) phone := pair(t, id, apiauth.RoleOwner) @@ -42,8 +47,8 @@ func TestAnOwnerPhoneAddsAWatchThatNamesItAsParent(t *testing.T) { t.Fatalf("the watch's parent is %q (%q), want %q (owner)", watch.Parent, watch.ParentRole, phone.DeviceID) } - // Its next handshake needs no code, as a phone's does not. - again, err := id.AuthoriseWatch(key, nil) + // Its handshake needs no code: the box knows its key. + again, err := checkWatch(id, key) if err != nil { t.Fatalf("the watch's second handshake was refused: %v", err) } @@ -53,64 +58,6 @@ func TestAnOwnerPhoneAddsAWatchThatNamesItAsParent(t *testing.T) { } // A viewer cannot add a watch, and neither can a phone the box does not know. -func TestOnlyAnOwnerPhoneMintsAWatchCode(t *testing.T) { - id, _ := newIdentity(t) - pair(t, id, apiauth.RoleOwner) - viewer := pair(t, id, apiauth.RoleViewer) - - if _, _, err := id.MintWatchCode(viewer.DeviceID, WatchTTL); !errors.Is(err, ErrWatchNeedsOwner) { - t.Fatalf("a viewer minted a watch code: %v", err) - } - if _, _, err := id.MintWatchCode("nosuchid", WatchTTL); !errors.Is(err, ErrUnknownDevice) { - t.Fatalf("a stranger minted a watch code: %v", err) - } -} - -// The watch code has a slot of its own: adding a watch leaves an invite that -// somebody is about to scan alone, and the invite leaves the watch code alone. -func TestAWatchCodeDoesNotCancelAPhoneCode(t *testing.T) { - id, _ := newIdentity(t) - phone := pair(t, id, apiauth.RoleOwner) - - invite, _, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL) - if err != nil { - t.Fatal(err) - } - watchCode, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } - if _, err := id.Authorise(appKey(t), invite); err != nil { - t.Fatalf("the invite died when a watch code was minted: %v", err) - } - if _, err := id.AuthoriseWatch(appKey(t), watchCode); err != nil { - t.Fatalf("the watch code died when the invite was spent: %v", err) - } -} - -// A watch code is spent once, and a phone's door never spends it. -func TestAWatchCodeWorksOnceAndOnlyAtTheWatchDoor(t *testing.T) { - id, _ := newIdentity(t) - phone := pair(t, id, apiauth.RoleOwner) - code, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } - - if _, err := id.Authorise(appKey(t), code); !errors.Is(err, ErrBadPairing) { - t.Fatalf("a watch code opened a phone's stream: %v", err) - } - if _, err := id.AuthoriseWatch(appKey(t), code); err != nil { - t.Fatalf("the watch code was spent by the stream: %v", err) - } - if _, err := id.AuthoriseWatch(appKey(t), code); !errors.Is(err, ErrBadPairing) { - t.Fatalf("a watch code let a second watch in: %v", err) - } - if n := id.AuthorisedCount(); n != 2 { - t.Fatalf("%d enrolments, want the phone and one watch", n) - } -} - // Each door admits its own kind and answers the other as a stranger. func TestAWatchHasNoStreamAndAPhoneNoWatchDoor(t *testing.T) { id, _ := newIdentity(t) @@ -128,62 +75,11 @@ func TestAWatchHasNoStreamAndAPhoneNoWatchDoor(t *testing.T) { if _, err := id.Authorise(watchKey, nil); !errors.Is(err, ErrNoPairing) { t.Fatalf("a watch opened a stream: %v", err) } - if _, err := id.AuthoriseWatch(phoneKey, nil); !errors.Is(err, ErrNoPairing) { + if _, err := checkWatch(id, phoneKey); !errors.Is(err, ErrNoPairing) { t.Fatalf("a phone came through the watch's door: %v", err) } } -func TestAnExpiredWatchCodeIsRefused(t *testing.T) { - id, _ := newIdentity(t) - phone := pair(t, id, apiauth.RoleOwner) - now := time.UnixMilli(1_760_000_000_000) - id.now = func() time.Time { return now } - - code, expires, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } - now = expires.Add(time.Millisecond) - if _, err := id.AuthoriseWatch(appKey(t), code); !errors.Is(err, ErrBadPairing) { - t.Fatalf("an expired watch code was redeemed: %v", err) - } -} - -func TestAWatchCodeIsBurnedByGuessing(t *testing.T) { - id, _ := newIdentity(t) - phone := pair(t, id, apiauth.RoleOwner) - code, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } - wrong := make([]byte, PairingCodeBytes) - for i := 0; i < MaxPairingAttempts; i++ { - if _, err := id.AuthoriseWatch(appKey(t), wrong); !errors.Is(err, ErrBadPairing) { - t.Fatalf("guess %d: %v", i, err) - } - } - if _, err := id.AuthoriseWatch(appKey(t), code); !errors.Is(err, ErrBadPairing) { - t.Fatalf("the right code still worked after %d wrong guesses: %v", MaxPairingAttempts, err) - } -} - -// The phone must still be an owner when the watch spends the code. -func TestAWatchCodeDiesWithItsPhonesOwnerRole(t *testing.T) { - id, _ := newIdentity(t) - pair(t, id, apiauth.RoleOwner) - phone := pair(t, id, apiauth.RoleOwner) - code, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } - if err := id.SetRole(phone.DeviceID, apiauth.RoleViewer); err != nil { - t.Fatal(err) - } - if _, err := id.AuthoriseWatch(appKey(t), code); !errors.Is(err, ErrBadPairing) { - t.Fatalf("a watch joined under a phone that is no longer an owner: %v", err) - } -} - // Removing a phone removes its watches, and their keys come back so the // caller can end their sessions. Removing a watch touches nothing else. func TestRemovalRunsFromPhoneToWatchAndNeverBack(t *testing.T) { @@ -206,14 +102,10 @@ func TestRemovalRunsFromPhoneToWatchAndNeverBack(t *testing.T) { if _, ok := id.GrantFor(second.DeviceID); !ok { t.Fatal("removing one watch removed another") } - if _, err := id.AuthoriseWatch(firstKey, nil); !errors.Is(err, ErrNoPairing) { + if _, err := checkWatch(id, firstKey); !errors.Is(err, ErrNoPairing) { t.Fatalf("a removed watch's next message 1 was answered: %v", err) } - pending, _, err := id.MintWatchCode(phone.DeviceID, WatchTTL) - if err != nil { - t.Fatal(err) - } removed, err = id.Revoke(phone.DeviceID, OverASession) if err != nil { t.Fatal(err) @@ -224,7 +116,7 @@ func TestRemovalRunsFromPhoneToWatchAndNeverBack(t *testing.T) { if _, ok := id.GrantFor(second.DeviceID); ok { t.Fatal("a watch outlived its phone") } - if _, err := id.AuthoriseWatch(secondKey, nil); !errors.Is(err, ErrNoPairing) { + if _, err := checkWatch(id, secondKey); !errors.Is(err, ErrNoPairing) { t.Fatalf("a watch whose phone was removed was answered: %v", err) } for _, d := range id.Roster() { @@ -232,9 +124,6 @@ func TestRemovalRunsFromPhoneToWatchAndNeverBack(t *testing.T) { t.Fatal("a removed watch is still in the roster") } } - if _, err := id.AuthoriseWatch(appKey(t), pending); !errors.Is(err, ErrBadPairing) { - t.Fatalf("a watch code outlived the phone that asked for it: %v", err) - } } // A watch is a watch until it is removed; its reach follows its phone. @@ -342,7 +231,7 @@ func TestAWatchAndItsParentSurviveARestart(t *testing.T) { if err != nil { t.Fatal(err) } - grant, err := again.AuthoriseWatch(key, nil) + grant, err := checkWatch(again, key) if err != nil { t.Fatalf("the watch was forgotten across a restart: %v", err) } @@ -387,3 +276,99 @@ func TestAWatchWithoutItsPhoneIsDroppedAtLoad(t *testing.T) { t.Fatalf("%d enrolments after loading an orphan", n) } } + +// A watch added by its key is the same watch a code adds: the owner phone is +// its parent, it opens with no code, a viewer cannot add one, and it goes +// with its phone. +func TestAWatchAddedByItsKey(t *testing.T) { + id, _ := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + viewer := pair(t, id, apiauth.RoleViewer) + key := appKey(t) + + if _, err := id.AddWatch(viewer.DeviceID, key); !errors.Is(err, ErrWatchNeedsOwner) { + t.Fatalf("a viewer added a watch: %v", err) + } + if _, err := id.AddWatch("nobody00", key); !errors.Is(err, ErrUnknownDevice) { + t.Fatalf("an unknown phone added a watch: %v", err) + } + if _, err := id.AddWatch(phone.DeviceID, key[:31]); !errors.Is(err, ErrWatchKey) { + t.Fatalf("a short key was taken: %v", err) + } + + watch, err := id.AddWatch(phone.DeviceID, key) + if err != nil { + t.Fatal(err) + } + if watch.Role != apiauth.RoleWatch || watch.Parent != phone.DeviceID || watch.ParentRole != apiauth.RoleOwner { + t.Fatalf("the box wrote down %+v", watch) + } + opened, err := checkWatch(id, key) + if err != nil || opened.DeviceID != watch.DeviceID { + t.Fatalf("the watch could not open with its key alone: %+v %v", opened, err) + } + if again, err := id.AddWatch(phone.DeviceID, key); err != nil || again.DeviceID != watch.DeviceID { + t.Fatalf("adding it again gave %+v %v", again, err) + } + + // Under another phone, or as a phone's key, it is no watch to add. + other := pair(t, id, apiauth.RoleOwner) + if _, err := id.AddWatch(other.DeviceID, key); !errors.Is(err, ErrWatchKey) { + t.Fatalf("another phone took the watch: %v", err) + } + + if _, err := id.Revoke(phone.DeviceID, OverASession); err != nil { + t.Fatal(err) + } + if _, err := checkWatch(id, key); err == nil { + t.Fatal("the watch outlived its phone") + } +} + +// The exchange's check writes nothing: a message 1 that FTW Cloud replays +// stamps no visit until the session proves itself and commits. +func TestCheckingAWatchWritesNothingUntilItCommits(t *testing.T) { + id, _ := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + key, watch := addWatch(t, id, phone) + seen := func() int64 { + for _, d := range id.Devices() { + if d.ID == watch.DeviceID { + return d.LastSeenMs + } + } + t.Fatal("the watch is not listed") + return 0 + } + + for i := 0; i < 3; i++ { + if _, _, err := id.CheckWatch(key); err != nil { + t.Fatal(err) + } + } + if seen() != 0 { + t.Fatal("a check stamped the watch's visit") + } + _, commit, err := id.CheckWatch(key) + if err != nil { + t.Fatal(err) + } + if _, err := commit(); err != nil || seen() == 0 { + t.Fatalf("a commit did not stamp the visit: %v", err) + } + if !id.HasWatch(watch.DeviceID) || id.HasWatch(phone.DeviceID) || id.HasWatch("nobody00") { + t.Fatal("HasWatch names the wrong devices") + } + + // A watch removed between the check and the commit is not let in. + _, commit, err = id.CheckWatch(key) + if err != nil { + t.Fatal(err) + } + if _, err := id.Revoke(watch.DeviceID, OverASession); err != nil { + t.Fatal(err) + } + if _, err := commit(); !errors.Is(err, ErrNoPairing) { + t.Fatalf("a removed watch committed: %v", err) + } +} diff --git a/go/internal/appproto/exchange.go b/go/internal/appproto/exchange.go index b4153f96..ed43839e 100644 --- a/go/internal/appproto/exchange.go +++ b/go/internal/appproto/exchange.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "github.com/srcfl/ftw/go/protocol" + "slices" "sort" "github.com/srcfl/ftw/go/internal/units" @@ -12,17 +13,27 @@ import ( // 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. +// message that FTW Cloud carries 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 service cannot tell a read from a command or a refusal from +// a success by length. appuplink pads an answer to two frames with Pad. + +// ExchangeOps are the commands a watch's exchange takes: the charger's hold, +// which Charge now, Pause and Back to the plan send. A watch reads and +// charges and does nothing else (ADR 0009, decision B); its role's +// ftw.dispatch.write reaches further, and the exchange does not. +var ExchangeOps = []string{OpLoadpointHold} func (h *Handler) dispatchExchange(ctx context.Context, env Envelope) error { switch env.T { case MsgHello: return h.onExchangeHello(env) case MsgCmd: + var cmd Cmd + if err := Unmarshal(env.B, &cmd); err == nil && !slices.Contains(ExchangeOps, cmd.Op) { + return h.sendError(env.ID, ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": cmd.Op}}) + } return h.onCmd(ctx, env, "") case MsgNow: return h.onNow() @@ -92,8 +103,8 @@ func (h *Handler) onExchangeHello(env Envelope) error { } 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) + // watch then keeps the names its phone gave it. + h.log.Warn("exchange hello_ok left FTW Cloud out to fit the exchange's answer", "bytes", size) body.Cloud = nil } } @@ -101,9 +112,8 @@ func (h *Handler) onExchangeHello(env Envelope) error { } // 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. +// the smallest one. FTW Cloud carries an exchange message of at most 2 KiB +// (cloudwire.XDataMax), so the next bucket, 4 KiB, never fits. const ExchangeHelloMaxBucket = 1024 func encodedSize(t string, body any) (int, error) { diff --git a/go/internal/appproto/exchange_test.go b/go/internal/appproto/exchange_test.go index e483856c..7c738915 100644 --- a/go/internal/appproto/exchange_test.go +++ b/go/internal/appproto/exchange_test.go @@ -45,10 +45,10 @@ func TestAnExchangeHelloNamesTheRevAndNoCatalogue(t *testing.T) { } } -// 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) { +// FTW Cloud carries an exchange message of at most 2 KiB, so hello_ok never +// takes the 4 KiB bucket. A house too big for 1 KiB leaves FTW Cloud out, and +// the watch keeps the names its phone gave it. +func TestAnExchangeHelloTooBigForItsAnswerLeavesCloudOut(t *testing.T) { h, _, _, rec := newExchangeRig(t) sources := map[string]string{} for i := 0; i < 80; i++ { @@ -80,6 +80,22 @@ func TestAnExchangeCommandPushesNoPlan(t *testing.T) { } } +// A watch charges and does nothing else: every other op its role's scope +// would reach is refused before it runs, and the charger hears nothing. +func TestAnExchangeTakesTheChargersHoldAlone(t *testing.T) { + for _, op := range []string{OpLoadpointBoost, OpLoadpointSoCSet, OpLoadpointSurplusOnlySet, OpCarGoalSet, OpCarSoCSet, OpSessionCarSet, OpSetMode} { + h, lp, _, rec := newExchangeRig(t) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(op, "x-"+op, holdArgs(), 7, 200_000)) + e := body[ErrorBody](t, rec.only(t, MsgError)) + if e.Code != protocol.ErrOpNotGranted { + t.Fatalf("%s through the exchange answered %q", op, e.Code) + } + if lp.holdCalls+lp.boostCalls+lp.socCalls+lp.surplusCalls != 0 { + t.Fatalf("%s through the exchange reached the charger", op) + } + } +} + // 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) { diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index a4821643..922a6e18 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -130,8 +130,8 @@ type Options struct { 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. + // appproto.Config.Exchange set (ADR 0009). Nil opens no exchange: the + // box takes no x from FTW Cloud, and nothing else changes. ExchangeHandler HandlerBuilder Logger *slog.Logger @@ -190,10 +190,10 @@ type Uplink struct { 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 + // exchange is the watches' door through FTW Cloud and their table of + // sessions, nil without an ExchangeHandler. One for the box, so a // watch's session survives the hour turning under it. - exchanges *exchanges + exchange *Exchange // slots holds each device's one session, across the relay's // connections and the tunnel through FTW Cloud. @@ -231,8 +231,8 @@ func (u *Uplink) DropSessionsByAppKey(pub []byte) int { for _, l := range live { dropped += l.sessions.dropByAppKey(pub) } - if u.exchanges != nil { - dropped += u.exchanges.dropByAppKey(pub) + if u.exchange != nil { + dropped += u.exchange.table.dropByAppKey(pub) } dropped += u.tunnel.dropByAppKey(pub) return dropped @@ -296,14 +296,14 @@ func New(opts Options) (*Uplink, error) { u.slots = &slots{} u.tunnel = newTunnel(opts.Handler, opts.Enroll, u.slots, opts.Arrivals, u.log) if opts.ExchangeHandler != nil { - u.exchanges = &exchanges{ + u.exchange = newExchange(&exchanges{ build: opts.ExchangeHandler, enroll: opts.Enroll, static: opts.Enroll.StaticKey(), log: u.log, now: time.Now, cmdFloor: ExchangeCmdFloor, - } + }, u.log) } return u, nil } @@ -321,9 +321,12 @@ func productionDialer() *websocket.Dialer { } } -// Run serves phone sessions carried by FTW Cloud until the context ends. -// It opens no connection to the retired relay. +// Run serves phone sessions and watch exchanges carried by FTW Cloud until +// the context ends. It opens no connection to the retired relay. func (u *Uplink) Run(ctx context.Context) error { + if u.exchange != nil { + go u.exchange.Run(ctx) + } u.tunnel.Run(ctx) return ctx.Err() } @@ -414,11 +417,6 @@ func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64, h 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} }() diff --git a/go/internal/appuplink/cloudtunnel_test.go b/go/internal/appuplink/cloudtunnel_test.go index 3f9cb4f3..54087d70 100644 --- a/go/internal/appuplink/cloudtunnel_test.go +++ b/go/internal/appuplink/cloudtunnel_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "slices" "sync" "sync/atomic" "testing" @@ -42,8 +43,10 @@ type miniCloud struct { conn *websocket.Conn home string roster *cloudwire.Roster + caps []string got []string toApp chan cloudwire.Tun + toWatch chan cloudwire.X rostered chan struct{} wmu sync.Mutex } @@ -51,7 +54,8 @@ type miniCloud struct { func newMiniCloud(t *testing.T) *miniCloud { t.Helper() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - c := &miniCloud{t: t, key: key, toApp: make(chan cloudwire.Tun, 64), rostered: make(chan struct{}, 8)} + c := &miniCloud{t: t, key: key, toApp: make(chan cloudwire.Tun, 64), toWatch: make(chan cloudwire.X, 64), + rostered: make(chan struct{}, 8)} c.srv = httptest.NewUnstartedServer(http.HandlerFunc(c.serve)) c.srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} c.srv.StartTLS() @@ -113,7 +117,7 @@ func (c *miniCloud) serve(w http.ResponseWriter, r *http.Request) { return } c.mu.Lock() - c.conn, c.home = conn, home + c.conn, c.home, c.caps = conn, home, hello.Caps c.mu.Unlock() for { _, msg, err := conn.ReadMessage() @@ -139,6 +143,11 @@ func (c *miniCloud) serve(w http.ResponseWriter, r *http.Request) { if cloudwire.Unmarshal(env.B, &t) == nil { c.toApp <- t } + case cloudwire.MsgX: + var x cloudwire.X + if cloudwire.Unmarshal(env.B, &x) == nil { + c.toWatch <- x + } case cloudwire.MsgRoster: var ro cloudwire.Roster if cloudwire.Verify(pub, home, env) == nil && cloudwire.Unmarshal(env.B, &ro) == nil { @@ -169,6 +178,35 @@ func (c *miniCloud) fromApp(key []byte, sid uint32, d []byte) bool { return false } +// fromWatch is a watch's x, as /v1/app/once hands it on: the service finds +// the roster entry by the key its own handshake proved, needs tun on it, and +// names the device from it. Only a box that said x gets one. +func (c *miniCloud) fromWatch(key []byte, id uint32, d []byte) bool { + c.mu.Lock() + conn, home, ro, caps := c.conn, c.home, c.roster, c.caps + c.mu.Unlock() + if conn == nil || ro == nil || !slices.Contains(caps, cloudwire.CapX) { + return false + } + for _, dev := range ro.Devices { + if dev.Tun && bytes.Equal(dev.Key, key) { + return c.write(conn, nil, home, cloudwire.MsgX, cloudwire.X{Dev: dev.ID, ID: id, D: d}) == nil + } + } + return false +} + +func (c *miniCloud) toWatchNext(t *testing.T) cloudwire.X { + t.Helper() + select { + case m := <-c.toWatch: + return m + case <-time.After(5 * time.Second): + t.Fatal("no x from the box") + return cloudwire.X{} + } +} + // forward sends a tun to the box as it is, with no roster check: a service // that forwards what it should not. func (c *miniCloud) forward(dev string, sid uint32, d []byte) { @@ -412,3 +450,124 @@ wait: t.Fatalf("hello_ok.cloud %+v", ok.Cloud) } } + +// The watch's half of the one door, end to end: a watch its phone added by +// its key charges through FTW Cloud with the readable copy off. The box says +// x, the roster lists the watch with tun, and each exchange message rides one +// x each way through the real cloud link. +func TestAWatchCommandThroughFTWCloud(t *testing.T) { + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + charger := &fakeCharger{} + cmds := appproto.NewCmdLog() + uplink, err := New(Options{ + Enroll: enroll, Handler: handlerWith(false, charger, cmds), ExchangeHandler: handlerWith(true, charger, cmds), + Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) + } + cloud := newMiniCloud(t) + link, err := cloudlink.New(cloudlink.Options{ + Site: quietSite{}, Roster: CloudRoster{Enroll: enroll, Tunnel: true}, KV: &memState{m: map[string]string{}}, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), Build: "test", + Tunnel: uplink.Tunnel(), Exchange: uplink.Exchange(), + Endpoint: cloud.endpoint(), ServiceKeys: []*ecdsa.PublicKey{&cloud.key.PublicKey}, RootCAs: cloud.roots(), + Tick: 20 * time.Millisecond, Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) + } + uplink.Tunnel().Attach(link) + uplink.Exchange().Attach(link) + enroll.OnChange(link.RosterChanged) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = uplink.Run(ctx) }() + go func() { _ = link.Run(ctx) }() + + code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + app, err := newAppClient() + if err != nil { + t.Fatal(err) + } + phone, err := enroll.Authorise(app.staticPublic(), code) + if err != nil { + t.Fatal(err) + } + w, err := newWatchClient() + if err != nil { + t.Fatal(err) + } + if _, err := enroll.AddWatch(phone.DeviceID, w.static.Public()); err != nil { + t.Fatal(err) + } + link.RosterChanged() + deadline := time.After(10 * time.Second) + for { + select { + case <-cloud.rostered: + case <-deadline: + t.Fatal("the box never sent a roster with the watch") + } + cloud.mu.Lock() + listed := false + if cloud.roster != nil { + for _, d := range cloud.roster.Devices { + listed = listed || d.ID == w.dev() && d.Tun && d.Role == apiauth.RoleWatch + } + } + cloud.mu.Unlock() + if listed { + break + } + } + + message1, err := w.open(enroll.StaticKey().Public(), nil) + if err != nil { + t.Fatal(err) + } + if !cloud.fromWatch(w.static.Public(), 1, message1) { + t.Fatal("the service would not carry the watch's handshake; the box must say x") + } + reply := cloud.toWatchNext(t) + if reply.Dev != w.dev() || reply.ID != 1 { + t.Fatalf("the handshake's answer is %+v", reply) + } + if _, _, err := w.opened(reply.D); err != nil { + t.Fatal(err) + } + request, err := w.request(appproto.MsgCmd, chargeNow("through-cloud")) + if err != nil { + t.Fatal(err) + } + if !cloud.fromWatch(w.static.Public(), 2, request) { + t.Fatal("the service would not carry the command") + } + reply = cloud.toWatchNext(t) + frames, err := w.answer(reply.D) + if err != nil { + t.Fatal(err) + } + if kinds := types(frames); reply.ID != 2 || len(kinds) != 2 || kinds[1] != appproto.MsgCmdResult { + t.Fatalf("the answer %d carries %v", reply.ID, kinds) + } + if decodeAs[appproto.CmdResult](t, frames[1]).State != appproto.CmdApplied || charger.count() != 1 { + t.Fatalf("the charge did not apply: %d holds", charger.count()) + } + + // The readable copy stayed off: only the roster, the notice and the + // exchange left the box. + cloud.mu.Lock() + defer cloud.mu.Unlock() + for _, typ := range cloud.got { + if typ != cloudwire.MsgRoster && typ != cloudwire.MsgX && typ != cloudwire.MsgNotice { + t.Fatalf("the box sent %s with the copy off", typ) + } + } +} diff --git a/go/internal/appuplink/exchange.go b/go/internal/appuplink/exchange.go index c2ceb903..11214265 100644 --- a/go/internal/appuplink/exchange.go +++ b/go/internal/appuplink/exchange.go @@ -10,34 +10,35 @@ import ( "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" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // 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). +// and 2; docs/protocol-cloud.md, "Exchange"). // // 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. +// stream. It posts one Noise message to FTW Cloud inside a one-shot request, +// and the service hands it to the box on the uplink as x {dev, id, d}. The +// box answers x {dev, id, d} with the same dev and id. dev is the device the +// service's own handshake proved, which the box checks against the key its +// handshake proves; id is the service's number, which the box copies back +// and reads nothing into. // -// 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. +// Exchange sessions are a class of their own: a table of at most eight, +// apart from the phones' places, so a watch never pushes a phone out. They +// live on the box, not on the uplink, so a session survives the uplink +// reconnecting under it. Losing FTW Cloud changes nothing for 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 + // MaxExchangeSessions is how many watch sessions the box holds at once, + // and MaxExchangeSessionsPerWatch how many of them one watch may hold. + MaxExchangeSessions = 8 + MaxExchangeSessionsPerWatch = 2 // exchangeFrameBytes is the one size every frame takes but hello_ok: // lane 0's 512 B bucket. @@ -58,8 +59,16 @@ const ( exchangeLifetime = 3 * time.Minute // exchangeOpensPerMinute bounds handshakes, each of which costs the box - // up to four X25519 operations. The rest are dropped unanswered. + // up to four X25519 operations, and exchangeOpensPerWatch one watch's + // share, so one watch's replays never spend another's. The rest are + // dropped unanswered. exchangeOpensPerMinute = 60 + exchangeOpensPerWatch = 12 + + // exchangeRequestsPerMinute bounds what the box takes off its link in + // all, handshakes, requests and junk alike. Past it a request is + // dropped unanswered and runs nothing. + exchangeRequestsPerMinute = 120 // 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 @@ -72,16 +81,16 @@ const ( // 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 + // would tell FTW Cloud 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 + // exchangeInFlight bounds requests handled at once, as many as FTW Cloud + // sends a box at once. Past it a request is answered with silence. + exchangeInFlight = cloudwire.XInFlight - // exchangeReadLimit admits the largest request plus its id. - exchangeReadLimit = 1024 + // exchangeInbox is how many requests wait for a slot. + exchangeInbox = 2 * exchangeInFlight ) // exchangePrologue binds an exchange handshake to this box, and keeps it @@ -90,14 +99,25 @@ func exchangePrologue(boxStatic []byte) []byte { return append([]byte("ftw.exchange.v1:"), boxStatic...) } -var errExchangeRate = errors.New("appuplink: too many exchange handshakes this minute") +var ( + errExchangeRate = errors.New("appuplink: too many exchange handshakes this minute") + errExchangeStranger = errors.New("appuplink: FTW Cloud named a device that is no watch of this box") + errExchangeCode = errors.New("appuplink: a watch's hello carried a code; watches are added by key") +) // 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 is the watch's key as the handshake authenticated it, and + // dev the device id FTW Cloud named it by. appStatic []byte + dev string + // proved is set by the first transport message that decrypts, which + // commit then stamps. Until then the session may be a replayed message + // 1: it has written nothing, and it goes first when room is needed. + proved bool + commit func() (appenroll.Grant, error) openedAt time.Time // run lets one request at a time through the handler. A command whose @@ -173,15 +193,14 @@ func (s *exchangeSession) 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. +// request may arrive on the uplink before a reconnect or after it, 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 is the clock session ages are measured on. now func() time.Time // settle is exchangeSettle; a test shortens it. settle time.Duration @@ -189,37 +208,46 @@ type exchanges struct { // which only a test that builds the table by hand gets. cmdFloor time.Duration - mu sync.Mutex - live []*exchangeSession - opens []time.Time + mu sync.Mutex + live []*exchangeSession + opens []time.Time + devOpens map[string][]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 { +// answer takes one request for the watch FTW Cloud names dev and returns the +// reply, or nil for silence. A stranger, a removed key, a key that is not +// dev's, a spent session, a malformed body and a handshake over the rate all +// get silence. +func (x *exchanges) answer(ctx context.Context, dev string, body []byte) []byte { arrived := time.Now() x.prune() switch len(body) { case exchangeOpenBytes: - reply, err := x.open(ctx, body) + reply, err := x.open(ctx, dev, 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) + return x.route(ctx, dev, body, arrived) default: return nil } } +var errExchangeNotDev = errors.New("appuplink: the exchange handshake proved another device than FTW Cloud named") + // 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() { +func (x *exchanges) open(ctx context.Context, dev string, message1 []byte) ([]byte, error) { + // A device that is no watch of this box costs nothing: no token, no + // Diffie-Hellman. Its opens are counted apart from every other watch's. + if !x.enroll.HasWatch(dev) { + return nil, errExchangeStranger + } + if !x.allowOpen(dev) { return nil, errExchangeRate } handshake, err := appwire.NewResponder(appwire.HandshakeOptions{ @@ -245,15 +273,26 @@ func (x *exchanges) open(ctx context.Context, message1 []byte) ([]byte, error) { 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) + // FTW Cloud names the device by the key its own handshake proved. One + // that differs from the key this handshake proves is answered with + // silence, so a service cannot spend one watch's requests on another. + if cloudwire.DeviceID(handshake.RemoteStatic()) != dev { + return nil, errExchangeNotDev + } + // A watch is added by its key, so a hello with a code is no watch of + // this build's. + if len(hello.Code) > 0 { + return nil, errExchangeCode + } + // Checked before anything is written, and written only once the + // session proves itself (route). + grant, commit, err := x.enroll.CheckWatch(handshake.RemoteStatic()) if err != nil { return nil, err } now := x.now() - sess := &exchangeSession{appStatic: handshake.RemoteStatic(), openedAt: now, lastAt: now} + sess := &exchangeSession{appStatic: handshake.RemoteStatic(), dev: dev, openedAt: now, lastAt: now, commit: commit} handler, err := x.build(sess, apiauth.Caller{ Subject: subjectOf(grant.DeviceID), Kind: apiauth.KindApp, @@ -288,18 +327,24 @@ func (x *exchanges) open(ctx context.Context, message1 []byte) ([]byte, error) { 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 { +// route finds the session a transport message belongs to by letting each of +// dev's sessions' AEAD try, and runs the exchange in it. +func (x *exchanges) route(ctx context.Context, dev string, message []byte, arrived time.Time) []byte { x.mu.Lock() live := slices.Clone(x.live) x.mu.Unlock() for _, sess := range live { + if sess.dev != dev { + continue + } frame, err := sess.transport.Decrypt(message) if err != nil { continue } + if !x.prove(sess) { + return nil + } reply := x.exchange(ctx, sess, frame) if reply != nil && x.cmdFloor > 0 && isCmd(frame) { holdUntil(ctx, arrived.Add(x.cmdFloor)) @@ -318,7 +363,7 @@ func isCmd(frame []byte) bool { return err == nil && f.Envelope.T == appproto.MsgCmd } -// holdUntil waits for a moment on the real clock, or for the socket to go. +// holdUntil waits for a moment on the real clock, or for the box to stop. func holdUntil(ctx context.Context, at time.Time) { wait := time.Until(at) if wait <= 0 { @@ -380,31 +425,104 @@ func (x *exchanges) exchange(ctx context.Context, sess *exchangeSession, frame [ return reply } -// allowOpen counts a handshake against the minute's budget. -func (x *exchanges) allowOpen() bool { +// prove marks a session whose first transport message decrypted, and stamps +// the watch then. False when the watch was removed meanwhile; the session +// goes. +func (x *exchanges) prove(sess *exchangeSession) bool { + sess.mu.Lock() + proved := sess.proved + sess.proved = true + sess.mu.Unlock() + if proved || sess.commit == nil { + return true + } + if _, err := sess.commit(); err != nil { + x.drop(sess) + return false + } + return true +} + +// allowOpen counts a handshake against the minute's budget and the watch's +// own share of it. +func (x *exchanges) allowOpen(dev string) 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 { + recent := func(at time.Time) bool { return now.Sub(at) >= time.Minute } + x.opens = slices.DeleteFunc(x.opens, recent) + if x.devOpens == nil { + x.devOpens = map[string][]time.Time{} + } + mine := slices.DeleteFunc(x.devOpens[dev], recent) + if len(x.opens) >= exchangeOpensPerMinute || len(mine) >= exchangeOpensPerWatch { + x.devOpens[dev] = mine return false } x.opens = append(x.opens, now) + x.devOpens[dev] = append(mine, now) + for d, at := range x.devOpens { + if len(slices.DeleteFunc(at, recent)) == 0 { + delete(x.devOpens, d) + } + } return true } -// admit keeps a new session, ending the oldest when the table is full. The -// newcomer is the watch somebody is holding now. +// admit keeps a new session. A watch holds at most two, and the table eight. +// Past either, the oldest session goes that has not proved itself, then the +// oldest; never the newcomer, which is the watch somebody holds now. A +// replayed message 1 opens a session that never proves itself, so replays +// end only one another, never a working session or another watch's. func (x *exchanges) admit(sess *exchangeSession) { x.mu.Lock() defer x.mu.Unlock() x.live = append(x.live, sess) + mine := func(s *exchangeSession) bool { return s.dev == sess.dev } + for count(x.live, mine) > MaxExchangeSessionsPerWatch { + x.evictLocked(sess, mine) + } for len(x.live) > MaxExchangeSessions { - x.live[0].close() - x.live = x.live[1:] + x.evictLocked(sess, func(*exchangeSession) bool { return true }) } } +// evictLocked ends the oldest session among those that match, an unproved +// one before a proved one, and never keep. The caller holds x.mu. +func (x *exchanges) evictLocked(keep *exchangeSession, match func(*exchangeSession) bool) { + victim := -1 + for i, s := range x.live { + if s == keep || !match(s) { + continue + } + s.mu.Lock() + proved := s.proved + s.mu.Unlock() + if !proved { + victim = i + break + } + if victim < 0 { + victim = i + } + } + if victim < 0 { + return + } + x.live[victim].close() + x.live = slices.Delete(x.live, victim, victim+1) +} + +func count(live []*exchangeSession, match func(*exchangeSession) bool) int { + n := 0 + for _, s := range live { + if match(s) { + n++ + } + } + return n +} + // prune ends every session that has run its course. func (x *exchanges) prune() { now := x.now() @@ -432,7 +550,7 @@ func (x *exchanges) drop(sess *exchangeSession) { } // dropByAppKey ends every session a removed key holds. Its next message 1 -// meets AuthoriseWatch and gets silence. +// meets HasWatch and gets silence. func (x *exchanges) dropByAppKey(pub []byte) int { x.mu.Lock() defer x.mu.Unlock() @@ -455,103 +573,118 @@ func (x *exchanges) count() int { } // -------------------------------------------------------------------------- -// The socket +// The carrier // -------------------------------------------------------------------------- -// 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) +// XSender sends one x up the box's link to FTW Cloud. cloudlink.Link is one. +// SendX never blocks; an error means the answer did not leave, and the watch +// asks again. XAllowed says whether an answer may leave now: the link keeps +// x to a share of the tunnel's budget, and the phones' last share for +// themselves. +type XSender interface { + SendX(m cloudwire.X) error + XAllowed() bool +} - ceiling := min(backoffBase<= time.Minute }) + if len(e.taken) >= exchangeRequestsPerMinute { + return false + } + e.taken = append(e.taken, now) + return true +} + +// reply sends the answer under the request's dev and id. Silence is an +// empty d, so the service answers the watch at once rather than at its +// deadline. +func (e *Exchange) reply(m cloudwire.X, d []byte) { + e.mu.Lock() + s := e.sender + e.mu.Unlock() + if s == nil { + return + } + if err := s.SendX(cloudwire.X{Dev: m.Dev, ID: m.ID, D: d}); err != nil { + e.log.Debug("an exchange answer did not leave", "err", err) } } diff --git a/go/internal/appuplink/exchange_test.go b/go/internal/appuplink/exchange_test.go index f4dd4fd2..13c63864 100644 --- a/go/internal/appuplink/exchange_test.go +++ b/go/internal/appuplink/exchange_test.go @@ -9,11 +9,12 @@ 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/protocol" "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // handlerWith builds every session's handler over one box with a charger: @@ -60,6 +61,12 @@ func newXRig(t *testing.T) *xrig { if err != nil { t.Fatal(err) } + return newXRigOn(t, enroll) +} + +// newXRigOn is the rig on a box that exists already. +func newXRigOn(t *testing.T, enroll *appenroll.Identity) *xrig { + t.Helper() code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) if err != nil { t.Fatal(err) @@ -95,19 +102,25 @@ func (r *xrig) advance(d time.Duration) { r.now = r.now.Add(d) } -// addWatch pairs a watch with a code its phone minted and opens its first +// addWatch adds a watch by its key, as its phone does, and opens its first // session. func (r *xrig) addWatch(t *testing.T) (*watchClient, appproto.ExchangeHelloOK) { t.Helper() - code, _, err := r.enroll.MintWatchCode(r.phone.DeviceID, appenroll.WatchTTL) + w := r.newWatch(t) + return w, r.open(t, w, nil) +} + +// newWatch adds a watch by its key and opens nothing. +func (r *xrig) newWatch(t *testing.T) *watchClient { + t.Helper() + w, err := newWatchClient() if err != nil { t.Fatal(err) } - w, err := newWatchClient() - if err != nil { + if _, err := r.enroll.AddWatch(r.phone.DeviceID, w.static.Public()); err != nil { t.Fatal(err) } - return w, r.open(t, w, code) + return w } // open runs X1 for a watch and fails unless the box answered. @@ -120,7 +133,7 @@ func (r *xrig) open(t *testing.T, w *watchClient, code []byte) appproto.Exchange if len(message1) != exchangeOpenBytes { t.Fatalf("message 1 is %d bytes, want %d", len(message1), exchangeOpenBytes) } - message2 := r.x.answer(context.Background(), message1) + message2 := r.x.answer(context.Background(), w.dev(), message1) if message2 == nil { t.Fatal("the box did not answer the watch's message 1") } @@ -141,7 +154,7 @@ func (r *xrig) ask(t *testing.T, w *watchClient, msgType string, body any) []app if len(request) != exchangeRequestBytes { t.Fatalf("a %s request is %d bytes, want %d", msgType, len(request), exchangeRequestBytes) } - reply := r.x.answer(context.Background(), request) + reply := r.x.answer(context.Background(), w.dev(), request) if reply == nil { return nil } @@ -244,7 +257,7 @@ func TestTheExchangeTakesCmdAndNowAndNothingElse(t *testing.T) { if err != nil { t.Fatal(err) } - frames, err := w.answer(r.x.answer(context.Background(), request)) + frames, err := w.answer(r.x.answer(context.Background(), w.dev(), request)) if err != nil { t.Fatalf("%s: %v", c.t, err) } @@ -261,14 +274,15 @@ func TestTheExchangeTakesCmdAndNowAndNothingElse(t *testing.T) { CmdID: "watch-mode", Op: appproto.OpSetMode, Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 10 * 60 * 1000, Expect: appproto.Expect{Rev: 1}, }) - res := decodeAs[appproto.CmdResult](t, frames[0]) - if res.State != appproto.CmdRejected || res.Error == nil || res.Error.Code != appproto.ErrScopeDenied { - t.Fatalf("site.mode.set from a watch = %+v", res) + // The exchange takes the charger's hold alone (ADR 0009, decision B): + // the mode is refused before the command runs. + if e := decodeAs[appproto.ErrorBody](t, frames[0]); frames[0].Envelope.T != appproto.MsgError || e.Code != protocol.ErrOpNotGranted { + t.Fatalf("site.mode.set from a watch answered %v %+v", types(frames), e) } } // Shape: every request is one length and every answer another, whatever the -// op and the outcome, so the relay cannot tell a read from a command or a +// op and the outcome, so FTW Cloud cannot tell a read from a command or a // refusal from a success. func TestEveryExchangeHasOneLength(t *testing.T) { r := newXRig(t) @@ -279,7 +293,7 @@ func TestEveryExchangeHasOneLength(t *testing.T) { }{ {"applied", appproto.MsgCmd, chargeNow("shape-1")}, {"asked again", appproto.MsgCmd, chargeNow("shape-1")}, - {"scope denied", appproto.MsgCmd, appproto.Cmd{CmdID: "shape-2", Op: appproto.OpSetMode, + {"not granted", appproto.MsgCmd, appproto.Cmd{CmdID: "shape-2", Op: appproto.OpSetMode, Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}}, {"expired", appproto.MsgCmd, appproto.Cmd{CmdID: "shape-3", Op: appproto.OpLoadpointHold, Args: map[string]any{"id": "lp1"}, NotValidAfterMs: 1, Expect: appproto.Expect{Rev: 1}}}, @@ -295,7 +309,7 @@ func TestEveryExchangeHasOneLength(t *testing.T) { if err != nil { t.Fatal(err) } - reply := r.x.answer(context.Background(), request) + reply := r.x.answer(context.Background(), w.dev(), request) if len(request) != exchangeRequestBytes || len(reply) != want { t.Fatalf("%s: request %d B, answer %d B; want %d and %d", c.name, len(request), len(reply), exchangeRequestBytes, want) @@ -338,24 +352,17 @@ func TestAnUnsettledResultIsAskedForAgain(t *testing.T) { // outcome. func TestReplayGetsNothingNew(t *testing.T) { r := newXRig(t) - code, _, err := r.enroll.MintWatchCode(r.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(), code) + w := r.newWatch(t) + message1, err := w.open(r.enroll.StaticKey().Public(), nil) if err != nil { t.Fatal(err) } - first := r.x.answer(context.Background(), message1) + first := r.x.answer(context.Background(), w.dev(), message1) if _, _, err := w.opened(first); err != nil { t.Fatal(err) } - again := r.x.answer(context.Background(), message1) + again := r.x.answer(context.Background(), w.dev(), message1) if again == nil || bytes.Equal(first[:appwire.DHBytes], again[:appwire.DHBytes]) { t.Fatal("a replayed message 1 drew the same box ephemeral key") } @@ -377,11 +384,11 @@ func TestReplayGetsNothingNew(t *testing.T) { if err != nil { t.Fatal(err) } - firstAnswer, err := w.answer(r.x.answer(context.Background(), request)) + firstAnswer, err := w.answer(r.x.answer(context.Background(), w.dev(), request)) if err != nil { t.Fatal(err) } - if reply := r.x.answer(context.Background(), request); reply != nil { + if reply := r.x.answer(context.Background(), w.dev(), request); reply != nil { t.Fatal("a recorded command was answered in its own session") } @@ -476,7 +483,7 @@ func TestOnlyAPairedWatchIsAnswered(t *testing.T) { if err != nil { t.Fatal(err) } - if r.x.answer(context.Background(), message1) != nil { + if r.x.answer(context.Background(), stranger.dev(), message1) != nil { t.Fatal("a stranger was answered") } @@ -499,7 +506,7 @@ func TestOnlyAPairedWatchIsAnswered(t *testing.T) { if err != nil { t.Fatal(err) } - if r.x.answer(context.Background(), streamOpen) != nil { + if r.x.answer(context.Background(), w.dev(), streamOpen) != nil { t.Fatal("a stream handshake opened an exchange session") } @@ -519,7 +526,7 @@ func TestOnlyAPairedWatchIsAnswered(t *testing.T) { if err != nil { t.Fatal(err) } - if r.x.answer(context.Background(), message1) != nil { + if r.x.answer(context.Background(), phone.dev(), message1) != nil { t.Fatal("a phone opened an exchange session") } } @@ -530,7 +537,7 @@ func TestRemovalEndsAWatchsSessions(t *testing.T) { for _, removePhone := range []bool{false, true} { r := newXRig(t) w, _ := r.addWatch(t) - target := deviceIDOf(w.static.Public()) + target := w.dev() if removePhone { target = r.phone.DeviceID } @@ -553,7 +560,7 @@ func TestRemovalEndsAWatchsSessions(t *testing.T) { if err != nil { t.Fatal(err) } - if r.x.answer(context.Background(), message1) != nil { + if r.x.answer(context.Background(), w.dev(), message1) != nil { t.Fatal("a removed watch's message 1 was answered") } } @@ -591,151 +598,281 @@ func TestASessionIsShort(t *testing.T) { } } -// At most sixty handshakes a minute; the rest are dropped unanswered. +// At most twelve handshakes a minute from one watch and sixty in all; the +// rest are dropped unanswered. A device FTW Cloud names that is no watch of +// this box spends nothing, not even a Diffie-Hellman, and one watch's opens +// never spend another's. func TestHandshakesAreRationed(t *testing.T) { r := newXRig(t) junk := make([]byte, exchangeOpenBytes) - for i := 0; i < exchangeOpensPerMinute; i++ { + for i := 0; i < 2*exchangeOpensPerMinute; i++ { junk[0] = byte(i) - r.x.answer(context.Background(), junk) + r.x.answer(context.Background(), "nobody00", junk) } - code, _, err := r.enroll.MintWatchCode(r.phone.DeviceID, appenroll.WatchTTL) + + w := r.newWatch(t) + message1, err := w.open(r.enroll.StaticKey().Public(), nil) if err != nil { t.Fatal(err) } - w, err := newWatchClient() - if err != nil { - t.Fatal(err) + for i := 0; i < exchangeOpensPerWatch; i++ { + if r.x.answer(context.Background(), w.dev(), message1) == nil { + t.Fatalf("open %d of a watch's twelve was not answered after a stranger's junk", i+1) + } } - message1, err := w.open(r.enroll.StaticKey().Public(), code) + if r.x.answer(context.Background(), w.dev(), message1) != nil { + t.Fatal("a watch's thirteenth handshake in a minute was answered") + } + // Another watch has a share of its own. + r.addWatch(t) + + // Sixty in all: four more watches spend their twelve each. + for k := 0; k < 3; k++ { + other := r.newWatch(t) + m1, err := other.open(r.enroll.StaticKey().Public(), nil) + if err != nil { + t.Fatal(err) + } + for i := 0; i < exchangeOpensPerWatch; i++ { + r.x.answer(context.Background(), other.dev(), m1) + } + } + late := r.newWatch(t) + m1, err := late.open(r.enroll.StaticKey().Public(), nil) if err != nil { t.Fatal(err) } - if r.x.answer(context.Background(), message1) != nil { - t.Fatal("the sixty-first handshake in a minute was answered") + for i := 0; i < exchangeOpensPerWatch; i++ { + r.x.answer(context.Background(), late.dev(), m1) + } + if r.x.answer(context.Background(), late.dev(), m1) != nil { + t.Fatal("a handshake past sixty in a minute was answered") } r.advance(time.Minute) - if r.x.answer(context.Background(), message1) == nil { + if r.x.answer(context.Background(), w.dev(), message1) == nil { t.Fatal("a handshake a minute later was not answered") } } -// Isolation: eight watch sessions leave four phone sessions running, and a +// A replayed message 1 opens a session that never proves itself: it stamps +// no visit, and replays end only one another, never the watch's working +// session and never another watch's. +func TestReplaysEndNoWorkingSession(t *testing.T) { + r := newXRig(t) + other, _ := r.addWatch(t) + if r.ask(t, other, appproto.MsgNow, struct{}{}) == nil { + t.Fatal("the other watch was not answered") + } + + w := r.newWatch(t) + message1, err := w.open(r.enroll.StaticKey().Public(), nil) + if err != nil { + t.Fatal(err) + } + message2 := r.x.answer(context.Background(), w.dev(), message1) + if _, _, err := w.opened(message2); err != nil { + t.Fatal(err) + } + if r.ask(t, w, appproto.MsgNow, struct{}{}) == nil { + t.Fatal("the watch's own session was not answered") + } + seen := func(dev string) int64 { + for _, d := range r.enroll.Devices() { + if d.ID == dev { + return d.LastSeenMs + } + } + return -1 + } + stamped := seen(w.dev()) + + r.advance(time.Second) + for i := 0; i < MaxExchangeSessions; i++ { + if r.x.answer(context.Background(), w.dev(), message1) == nil { + t.Fatalf("replay %d drew no answer", i+1) + } + } + if got := seen(w.dev()); got != stamped { + t.Fatalf("replays stamped the watch's visit: %d, was %d", got, stamped) + } + if n := count(r.x.live, func(s *exchangeSession) bool { return s.dev == w.dev() }); n != MaxExchangeSessionsPerWatch { + t.Fatalf("the watch holds %d sessions, want %d", n, MaxExchangeSessionsPerWatch) + } + if r.ask(t, w, appproto.MsgNow, struct{}{}) == nil { + t.Fatal("replays ended the watch's working session") + } + if r.ask(t, other, appproto.MsgNow, struct{}{}) == nil { + t.Fatal("one watch's replays ended another watch's session") + } +} + +// Removed mid-session, a watch's next command is refused and reaches no +// charger, even before anything ends its session. +func TestARevokeMidSessionRefusesTheNextCommand(t *testing.T) { + r := newXRig(t) + w, _ := r.addWatch(t) + if _, err := r.enroll.Revoke(w.dev(), appenroll.AtTheBox); err != nil { + t.Fatal(err) + } + frames := r.ask(t, w, appproto.MsgCmd, chargeNow("after-revoke")) + if frames != nil { + for _, f := range frames { + if f.Envelope.T == appproto.MsgCmdResult && decodeAs[appproto.CmdResult](t, f).State == appproto.CmdApplied { + t.Fatal("a removed watch's command was applied") + } + } + } + if r.charger.count() != 0 { + t.Fatalf("a removed watch's command reached the charger %d times", r.charger.count()) + } +} + +// A box that restarted has forgotten the commands it answered (protocol-cloud +// .md, "Exchange"). Its hello_ok says so: the uptime starts over, so the watch +// can tell a retry would reach a box that would act again. +func TestARestartedBoxSaysSoInItsClock(t *testing.T) { + r := newXRig(t) + _, before := r.addWatch(t) + restarted := newXRigOn(t, r.enroll) + restarted.x.build = func(sn 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{}, Loadpoints: restarted.charger, Codec: codec{}, Sender: sn, + Caller: caller, Grants: grants, Commands: appproto.NewCmdLog(), Exchange: true, + SrcGrid: "meter", SrcPV: "meter", SrcBattery: "meter", Logger: slog.New(slog.DiscardHandler), + }) + } + _, after := restarted.addWatch(t) + if after.Clock.UptimeMs >= before.Clock.UptimeMs { + t.Fatalf("a restarted box's hello_ok says uptime %d, the first said %d", after.Clock.UptimeMs, before.Clock.UptimeMs) + } +} + +// Isolation: eight watch sessions leave the phones' sessions running, and a // ninth watch ends the oldest watch's session, never a phone's. func TestWatchesNeverTakeAPhonesPlace(t *testing.T) { - // The relay lets four streams into a room. const phones = 4 - r := newXRig(t) - streams := relayTable(&slots{}, r.enroll) + tr := newTunRig(t) + r := newXRigOn(t, tr.enroll) for i := 0; i < phones; i++ { - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) - if err != nil { - t.Fatal(err) - } - app, err := newAppClient() - if err != nil { - t.Fatal(err) - } - relayOpen(t, streams, r.enroll, app, code) + app := tr.pair(t) + dev := cloudwire.DeviceID(app.staticPublic()) + tr.open(t, app, dev, uint32(i+1), nil) + tr.hello(t, app, dev, uint32(i+1)) + } + if tr.tunnel.count() != phones { + t.Fatalf("%d phone sessions, want %d", tr.tunnel.count(), phones) } first, _ := r.addWatch(t) for i := 1; i < MaxExchangeSessions; i++ { r.addWatch(t) } - if streams.count() != phones || r.x.count() != MaxExchangeSessions { + if tr.tunnel.count() != phones || r.x.count() != MaxExchangeSessions { t.Fatalf("%d phone and %d watch sessions, want %d and %d", - streams.count(), r.x.count(), phones, MaxExchangeSessions) + tr.tunnel.count(), r.x.count(), phones, MaxExchangeSessions) } r.addWatch(t) - if streams.count() != phones { - t.Fatalf("a ninth watch left %d phone sessions", streams.count()) + if tr.tunnel.count() != phones { + t.Fatalf("a ninth watch left %d phone sessions", tr.tunnel.count()) } if r.ask(t, first, appproto.MsgNow, struct{}{}) != nil { t.Fatal("the oldest watch session outlived a ninth") } } -// -------------------------------------------------------------------------- -// The socket, against a relay -// -------------------------------------------------------------------------- +// A request FTW Cloud names under another device gets silence, whether it +// opens a session or rides one: a service cannot spend one watch's requests +// on another. +func TestAnotherDevicesNameGetsSilence(t *testing.T) { + r := newXRig(t) + w, _ := r.addWatch(t) + other, _ := r.addWatch(t) -// newExchangeRig is a box whose uplink also keeps the exchange socket. -func newExchangeRig(t *testing.T, charger *fakeCharger) *rig { - t.Helper() - relay := newFakeRelay(481234) - t.Cleanup(relay.close) - enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + message1, err := w.open(r.enroll.StaticKey().Public(), nil) if err != nil { t.Fatal(err) } - cmds := appproto.NewCmdLog() - now := time.UnixMilli(481234 * EpochMs) - uplink, err := New(Options{ - Endpoint: relay.url(), - Enroll: enroll, - Handler: handlerWith(false, charger, cmds), - ExchangeHandler: handlerWith(true, charger, cmds), - Logger: slog.New(slog.DiscardHandler), - Now: func() time.Time { return now }, - Random: func() float64 { return 0 }, - }) + if r.x.answer(context.Background(), other.dev(), message1) != nil { + t.Fatal("a handshake was answered under another watch's id") + } + + r.open(t, w, nil) + request, err := w.request(appproto.MsgNow, struct{}{}) if err != nil { t.Fatal(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} + if r.x.answer(context.Background(), other.dev(), request) != nil { + t.Fatal("a request was answered under another watch's id") + } + if frames := r.ask(t, w, appproto.MsgNow, struct{}{}); frames == nil { + t.Fatal("the watch's own session died of a request under another id") + } } -func waitForExchange(t *testing.T, relay *fakeRelay) { - t.Helper() +// -------------------------------------------------------------------------- +// The carrier, as FTW Cloud drives it +// -------------------------------------------------------------------------- + +// capturedX is the box's link to FTW Cloud, as the exchange sees it. +// closed says the link's share for x is spent. +type capturedX struct { + ch chan cloudwire.X + closed bool +} + +func (c *capturedX) XAllowed() bool { return !c.closed } + +func (c *capturedX) SendX(m cloudwire.X) error { select { - case <-relay.exchangeJoined: - case <-time.After(5 * time.Second): - t.Fatal("the box never opened its exchange socket") + case c.ch <- m: + return nil + default: + return errTunGone } } -// The box opens /r///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) - // These sockets start beside one another. Exchange-ready alone does - // not mean the uplink's handler has recorded its join yet. - deadline := time.NewTimer(5 * time.Second) - defer deadline.Stop() - joined, exchangeReady := 0, false - for joined < 2 || !exchangeReady { - select { - case <-r.relay.joined: - joined++ - case <-r.relay.exchangeJoined: - exchangeReady = true - case <-deadline.C: - t.Fatal("the box did not open both its uplink and exchange sockets") - } +func (c *capturedX) next(t *testing.T) cloudwire.X { + t.Helper() + select { + case m := <-c.ch: + return m + case <-time.After(5 * time.Second): + t.Fatal("the box sent no x") + return cloudwire.X{} } +} - handle, err := Handle(r.enroll.RendezvousSecret(), 481234) +// A watch its phone added by its key charges through the uplink's exchange: +// each request comes as x {dev, id, d} and each answer goes back as x with +// the same dev and id. +func TestAWatchChargesThroughFTWCloud(t *testing.T) { + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) if err != nil { t.Fatal(err) } - joinedUnder := 0 - for _, h := range r.relay.seenHandles() { - if h == handle { - joinedUnder++ - } + charger := &fakeCharger{} + cmds := appproto.NewCmdLog() + uplink, err := New(Options{ + Enroll: enroll, + Handler: handlerWith(false, charger, cmds), + ExchangeHandler: handlerWith(true, charger, cmds), + Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) } - if joinedUnder < 2 { - t.Fatalf("the box joined its handle %d times; the uplink and the exchange share it", joinedUnder) + if uplink.exchange.table.cmdFloor != ExchangeCmdFloor { + t.Fatalf("the uplink holds commands for %s, want %s", uplink.exchange.table.cmdFloor, ExchangeCmdFloor) } + out := &capturedX{ch: make(chan cloudwire.X, 16)} + uplink.Exchange().Attach(out) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = uplink.Run(ctx) }() - code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) if err != nil { t.Fatal(err) } @@ -743,85 +880,82 @@ func TestTheBoxOpensAnExchangeBesideItsUplink(t *testing.T) { if err != nil { t.Fatal(err) } - phone, err := r.enroll.Authorise(phoneKey.Public(), code) + phone, err := enroll.Authorise(phoneKey.Public(), code) if err != nil { t.Fatal(err) } - watchCode, _, err := r.enroll.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) + w, err := newWatchClient() if err != nil { t.Fatal(err) } - w, err := newWatchClient() - if err != nil { + if _, err := enroll.AddWatch(phone.DeviceID, w.static.Public()); err != nil { t.Fatal(err) } - message1, err := w.open(r.enroll.StaticKey().Public(), watchCode) + + message1, err := w.open(enroll.StaticKey().Public(), nil) 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") + uplink.Exchange().Deliver(cloudwire.X{Dev: w.dev(), ID: 7, D: message1}) + got := out.next(t) + if got.Dev != w.dev() || got.ID != 7 || len(got.D) == 0 { + t.Fatalf("the handshake's answer is %+v", got) } - if _, _, err := w.opened(message2); err != nil { + if _, _, err := w.opened(got.D); err != nil { t.Fatal(err) } - request, err := w.request(appproto.MsgCmd, chargeNow("socket-1")) + + request, err := w.request(appproto.MsgCmd, chargeNow("cloud-1")) if err != nil { t.Fatal(err) } - frames, err := w.answer(r.relay.post(2, request, 5*time.Second)) + uplink.Exchange().Deliver(cloudwire.X{Dev: w.dev(), ID: 8, D: request}) + got = out.next(t) + if got.ID != 8 { + t.Fatalf("the command's answer came as %+v", got) + } + frames, err := w.answer(got.D) 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 kinds := types(frames); len(kinds) != 2 || kinds[0] != appproto.MsgCmdAck || kinds[1] != appproto.MsgCmdResult { + t.Fatalf("the answer carries %v", kinds) } 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) + // Silence is an empty d under the request's own number, so the service + // answers the watch at once. + again, err := w.request(appproto.MsgNow, struct{}{}) 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) + uplink.Exchange().Deliver(cloudwire.X{Dev: "someone", ID: 9, D: again}) + if got = out.next(t); got.ID != 9 || got.Dev != "someone" || len(got.D) != 0 { + t.Fatalf("a request under another id got %+v", got) } - r.relay.dropExchange() - waitForExchange(t, r.relay) + // Removing the watch through the uplink ends its exchange session too. + if dropped := uplink.DropSessionsByAppKey(w.static.Public()); dropped != 1 { + t.Fatalf("DropSessionsByAppKey dropped %d sessions, want the watch's one", dropped) + } +} - 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) +// A box with more requests than it answers at once answers the rest with +// silence at once, rather than late. +func TestABusyExchangeAnswersSilenceAtOnce(t *testing.T) { + r := newXRig(t) + e := newExchange(r.x, slog.New(slog.DiscardHandler)) + out := &capturedX{ch: make(chan cloudwire.X, 2*exchangeInbox)} + e.Attach(out) + // Nothing runs the inbox, so it fills, and the one past it is answered. + for i := 0; i < exchangeInbox; i++ { + e.Deliver(cloudwire.X{Dev: "w", ID: uint32(i), D: []byte{1}}) + } + e.Deliver(cloudwire.X{Dev: "w", ID: 99, D: []byte{1}}) + if got := out.next(t); got.ID != 99 || len(got.D) != 0 { + t.Fatalf("the request past the inbox got %+v", got) } } @@ -837,7 +971,7 @@ func (r *xrig) timed(t *testing.T, w *watchClient, msgType string, body any) ([] t.Fatal(err) } started := time.Now() - reply := r.x.answer(context.Background(), request) + reply := r.x.answer(context.Background(), w.dev(), request) took := time.Since(started) frames, err := w.answer(reply) if err != nil { @@ -883,10 +1017,49 @@ func TestACommandAnswersNoSoonerThanTheFloor(t *testing.T) { } } -// 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) +// With the link's share for x spent, a request runs nothing: no answer +// could tell the watch what happened, so the command does not happen. +func TestNoAnswerMeansNoCommand(t *testing.T) { + r := newXRig(t) + w, _ := r.addWatch(t) + e := newExchange(r.x, slog.New(slog.DiscardHandler)) + out := &capturedX{ch: make(chan cloudwire.X, 4), closed: true} + e.Attach(out) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go e.Run(ctx) + + request, err := w.request(appproto.MsgCmd, chargeNow("budget-spent")) + if err != nil { + t.Fatal(err) + } + e.Deliver(cloudwire.X{Dev: w.dev(), ID: 1, D: request}) + select { + case got := <-out.ch: + t.Fatalf("the box answered %+v with its share spent", got) + case <-time.After(1500 * time.Millisecond): + } + if r.charger.count() != 0 { + t.Fatal("a command ran whose answer could not leave") + } +} + +// Past the minute's requests the box drops the rest unanswered and runs +// none of them. +func TestTheExchangeTakesAMinutesRequestsAndNoMore(t *testing.T) { + r := newXRig(t) + e := newExchange(r.x, slog.New(slog.DiscardHandler)) + e.Attach(&capturedX{ch: make(chan cloudwire.X, 1)}) + for i := 0; i < exchangeRequestsPerMinute; i++ { + if !e.take() { + t.Fatalf("request %d of the minute was refused", i+1) + } + } + if e.take() { + t.Fatal("a request past the minute's was taken") + } + r.advance(time.Minute) + if !e.take() { + t.Fatal("a request a minute later was refused") } } diff --git a/go/internal/appuplink/exchange_timing_test.go b/go/internal/appuplink/exchange_timing_test.go new file mode 100644 index 00000000..1e967783 --- /dev/null +++ b/go/internal/appuplink/exchange_timing_test.go @@ -0,0 +1,51 @@ +package appuplink + +import ( + "fmt" + "os" + "slices" + "strconv" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/appproto" +) + +// TestExchangeTimingAtTheBox times an applied and a refused command at the +// box, the gap decision F's floor hides from whoever carries the exchange. +// 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/realrelay_test.go b/go/internal/appuplink/realrelay_test.go deleted file mode 100644 index 080373f7..00000000 --- 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/roster.go b/go/internal/appuplink/roster.go index bd6db82b..d1a2302f 100644 --- a/go/internal/appuplink/roster.go +++ b/go/internal/appuplink/roster.go @@ -4,7 +4,6 @@ import ( "slices" "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/cloudlink" @@ -16,7 +15,8 @@ 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, and a watch may reach the box's exchange through it: the box +// answers a watch's key on the exchange alone, never on a stream. type CloudRoster struct { Enroll *appenroll.Identity Tunnel bool @@ -31,7 +31,7 @@ func (r CloudRoster) devices(entries []appenroll.RosterEntry) []cloudlink.Roster var out []cloudlink.RosterDevice for _, e := range entries { d := cloudlink.RosterDevice{ID: e.ID, Key: e.Key, Role: e.Role, Epoch: e.Epoch, - Scopes: appproto.GrantScopes(e.Role, e.ParentRole), Tun: r.Tunnel && e.Role != apiauth.RoleWatch} + Scopes: appproto.GrantScopes(e.Role, e.ParentRole), Tun: r.Tunnel} if a := e.Agent; a != nil { // An agent row carries its grant, at its approver's scopes now, // so FTW Cloud can hold to it as well (docs/agents.md). It has diff --git a/go/internal/appuplink/testdata/exchange-vectors.json b/go/internal/appuplink/testdata/exchange-vectors.json index 8dffe046..ff06a327 100644 --- a/go/internal/appuplink/testdata/exchange-vectors.json +++ b/go/internal/appuplink/testdata/exchange-vectors.json @@ -8,16 +8,16 @@ "watchStatic": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", "watchStaticPublic": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b", "watchEphemeral": "4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60", - "handshakeHash": "774830fbffaa569c1fa626f5c527ad12e1085cb996a2cd60d60028344675ca8b", + "handshakeHash": "d936deb10439e4db202de8480d5e83caf3b57fa132408499c2f95d3a1517f364", "steps": [ { "name": "x1.request", "from": "watch", "frames": [ - "01000000005aa261746568656c6c6f6162a463617070a26275616777617463686f73656275696c6467766563746f727364636f646550a1a2a3a4a5a6a7a8a9aaabacadaeafb06570726f746fa2636d617801636d696e00676c6f63616c6573f60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "010000000044a261746568656c6c6f6162a363617070a26275616777617463686f73656275696c6467766563746f72736570726f746fa2636d617801636d696e00676c6f63616c6573f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], - "body": "64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4", - "relay": "8182838485868788898a8b8c8d8e8f9064b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4" + "body": "64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3e5ccf7a61a1f6de53d0f6bb0a7e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9306229e29123c0527153b5cdd263e3e71e76b85e9a65453a972acd7b9db0285b2806739798b33e66ade4c7b9d2c988e29c2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a381e0e1c0b3c8a375ffb7ef031e983de01", + "uplink": "010100000286a36162590277a3616459026064b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3e5ccf7a61a1f6de53d0f6bb0a7e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9306229e29123c0527153b5cdd263e3e71e76b85e9a65453a972acd7b9db0285b2806739798b33e66ade4c7b9d2c988e29c2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a381e0e1c0b3c8a375ffb7ef031e983de0162696401636465766857476d7639464255617461786373696740" }, { "name": "x1.answer", @@ -25,8 +25,8 @@ "frames": [ "0101000000a9a261746868656c6c6f5f6f6b6162a66372657607646d6f64656466756c6c64726f6c6565776174636865636c6f636ba366736f75726365636e747068757074696d654d7319ea606a73796e63656441744d731b00000199c82cc0006570726f746f016673636f70657384726674772e64697370617463682e77726974656f6674772e6576656e74732e726561646d6674772e6c6976652e726561646d6674772e706c616e2e72656164000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], - "body": "244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f", - "relay": "8182838485868788898a8b8c8d8e8f90244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f" + "body": "244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d2a53684fed13eac565ac5c65f9642ed9", + "uplink": "010100000456a36162590447a36164590430244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d2a53684fed13eac565ac5c65f9642ed962696401636465766857476d7639464255617461786373696740" }, { "name": "cmd.request", @@ -35,7 +35,7 @@ "0100000000a0a2617463636d646162a5626f706e6c6f6164706f696e742e686f6c646461726773a4626964636c703166686f6c645f730067706f7765725f77191cc06a70686173655f6d6f646562337065636d644964782430313932663261302d376331652d373030302d383030302d30313233343536373839616266657870656374a2637265760766677561726473f66f6e6f7456616c696441667465724d731a00015f9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], "body": "00000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3", - "relay": "8182838485868788898a8b8c8d8e8f9000000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3" + "uplink": "01010000023ea3616259022fa3616459021800000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a362696402636465766857476d7639464255617461786373696740" }, { "name": "cmd.answer", @@ -45,7 +45,7 @@ "010000000073a261746a636d642e726573756c746162a365636d644964782430313932663261302d376331652d373030302d383030302d303132333435363738396162657374617465676170706c696564686f62736572766564a36373726364636f72656576616c7565f96f3068757074696d654d7319ee4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], "body": "0000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712" + "uplink": "010100000456a36162590447a361645904300000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a7471262696402636465766857476d7639464255617461786373696740" }, { "name": "now.request", @@ -54,7 +54,7 @@ "01000000000aa26174636e6f776162a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], "body": "0000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415" + "uplink": "01010000023ea3616259022fa361645902180000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d541562696403636465766857476d7639464255617461786373696740" }, { "name": "now.answer", @@ -64,7 +64,7 @@ "01000000001ca26174647469636b6162a2637365710068757074696d654d7319f23000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ], "body": "0000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4", - "relay": "8182838485868788898a8b8c8d8e8f900000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4" + "uplink": "010100000456a36162590447a361645904300000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d462696403636465766857476d7639464255617461786373696740" } ] } diff --git a/go/internal/appuplink/vectors_test.go b/go/internal/appuplink/vectors_test.go index 9905f407..d93bc53c 100644 --- a/go/internal/appuplink/vectors_test.go +++ b/go/internal/appuplink/vectors_test.go @@ -10,15 +10,17 @@ import ( "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // 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 +// session, from fixed keys: X1 for a watch its phone added by its key, a +// command answered by its ack and result, and a read answered by now and a +// tick, each also as the x that carries it on the uplink to FTW Cloud. 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 @@ -40,16 +42,16 @@ type exchangeVectors struct { 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. +// exchangeVectorStep is one message of the exchange. Frames are the +// plaintext lane frames inside it; Body is the exchange message itself, the d +// of the watch's x {d} in its one-shot request or of the answer it receives; +// Uplink is the frame x {dev, id, d} as it crosses the box's uplink. type exchangeVectorStep struct { Name string `json:"name"` From string `json:"from"` Frames []string `json:"frames"` Body string `json:"body"` - Relay string `json:"relay"` + Uplink string `json:"uplink"` } // fixed is n bytes counting up from first: a key nobody would use, which is @@ -88,7 +90,7 @@ func buildExchangeVectors(t *testing.T) exchangeVectors { } boxStatic, boxEphemeral := keyPair(0x01), keyPair(0x61) watchStatic, watchEphemeral := keyPair(0x21), keyPair(0x41) - id := fixed(0x81, ExchangeIDBytes) + dev := cloudwire.DeviceID(watchStatic.Public()) prologueBytes := exchangePrologue(boxStatic.Public()) watch, err := appwire.NewInitiator(appwire.InitiatorOptions{ @@ -117,19 +119,31 @@ func buildExchangeVectors(t *testing.T) exchangeVectors { 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...))} + // A request and its answer share the service's number. + x, err := cloudwire.Marshal(cloudwire.X{Dev: dev, ID: uint32(len(v.Steps)/2 + 1), D: body}) + if err != nil { + t.Fatal(err) + } + raw, err := cloudwire.Envelope{T: cloudwire.MsgX, B: x}.Encode() + if err != nil { + t.Fatal(err) + } + uplink, err := cloudwire.EncodeFrame(raw) + if err != nil { + t.Fatal(err) + } + s := exchangeVectorStep{Name: name, From: from, Body: hex.EncodeToString(body), Uplink: hex.EncodeToString(uplink)} 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. + // X1: hello in message 1, hello_ok in message 2. The phone added the + // watch by its key, so the hello carries no code. 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 { @@ -262,7 +276,7 @@ func TestExchangeVectors(t *testing.T) { "; 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. + // The sizes the docs promise, each inside one x on the uplink. var v exchangeVectors if err := json.Unmarshal(want, &v); err != nil { t.Fatal(err) @@ -276,6 +290,9 @@ func TestExchangeVectors(t *testing.T) { if n := len(s.Body) / 2; n != sizes[s.Name] { t.Fatalf("%s is %d bytes, want %d", s.Name, n, sizes[s.Name]) } + if n := len(s.Uplink) / 2; n > cloudwire.MaxXFrame { + t.Fatalf("%s's x is %d bytes, over %d", s.Name, n, cloudwire.MaxXFrame) + } } } diff --git a/go/internal/appuplink/watch_test.go b/go/internal/appuplink/watch_test.go index 7303ad0f..d9a0c1f5 100644 --- a/go/internal/appuplink/watch_test.go +++ b/go/internal/appuplink/watch_test.go @@ -4,57 +4,35 @@ 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/protocol/cloudwire" ) // 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. +// so it can never take a phone's place. The roster gives it tun, because its +// exchange rides FTW Cloud, but its key in the tunnel 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) + r := newTunRig(t) + phone := r.pair(t) + grant, ok := r.enroll.GrantFor(cloudwire.DeviceID(phone.staticPublic())) + if !ok { + t.Fatal("the phone did not pair") } watch, err := newAppClient() if err != nil { - t.Fatalf("newAppClient: %v", err) + t.Fatal(err) } - if _, err := r.enroll.AuthoriseWatch(watch.staticPublic(), watchCode); err != nil { + if _, err := r.enroll.AddWatch(grant.DeviceID, watch.staticPublic()); err != nil { t.Fatalf("adding the watch: %v", err) } - conn := r.dialApp(t, 481234) - message1, err := watch.message1(r.enroll.StaticKey().Public(), nil) + m1, 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) + t.Fatal(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") - } + r.tunnel.Deliver(cloudwire.Tun{Dev: cloudwire.DeviceID(watch.staticPublic()), Sid: 1, D: m1}) + r.silent(t, 750*time.Millisecond) + if r.tunnel.count() != 0 { + 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 index 0d08289b..038af9c5 100644 --- a/go/internal/appuplink/watchclient_test.go +++ b/go/internal/appuplink/watchclient_test.go @@ -9,6 +9,7 @@ import ( "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // watchClient is a watch, in Go: the initiator side of the exchange, built on @@ -20,6 +21,9 @@ type watchClient struct { transport *appwire.Transport } +// dev is the device id FTW Cloud names this watch by. +func (w *watchClient) dev() string { return cloudwire.DeviceID(w.static.Public()) } + func newWatchClient() (*watchClient, error) { static, err := appwire.GenerateKeyPair() if err != nil { diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 3e240d65..d581371c 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -413,10 +413,11 @@ func parseDownlink(kind int, msg []byte) (cloudwire.Envelope, error) { } // read is the downlink. After the handshake the service may send watch, -// resync, purged and tun; challenge and hello_ok a second time are refused +// resync, purged, tun and x; challenge and hello_ok a second time are refused // like any stranger. watch and resync go to the sender, which owns the // cadence. A purge receipt is taken here, before the service hangs up, so the -// box never misses one it was sent. A tun goes to the Tunnel unread. +// box never misses one it was sent. A tun goes to the Tunnel and an x to the +// Exchange, unread. func (l *Link) read(conn uplinkConn, snap grantSnapshot, events chan<- cloudwire.Envelope) error { _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) conn.SetPingHandler(func(data string) error { @@ -442,6 +443,14 @@ func (l *Link) read(conn uplinkConn, snap grantSnapshot, events chan<- cloudwire if l.TakesTun() { l.opts.Tunnel.Deliver(t) } + case cloudwire.MsgX: + var x cloudwire.X + if len(env.Sig) != 0 || cloudwire.Unmarshal(env.B, &x) != nil || len(x.D) > cloudwire.XDataMax || x.Dev == "" { + return fmt.Errorf("%w: an x the box cannot read", errService) + } + if l.opts.Exchange != nil && l.opts.Tunnel != nil { + l.opts.Exchange.Deliver(x) + } case cloudwire.MsgWatch, cloudwire.MsgResync: events <- env default: @@ -542,6 +551,64 @@ func (l *Link) SendTun(t cloudwire.Tun, bulk bool) error { } } +// SendX queues a watch's exchange answer for the uplink. It is small and goes +// with the tun lane 0 frames, first and never held, under the tun budget and +// a share of its own (xAllowedLocked). It never blocks. nil means the message +// is queued. +func (l *Link) SendX(x cloudwire.X) error { + frame, err := xFrame(x) + l.mu.Lock() + defer l.mu.Unlock() + switch { + case l.tunOut == nil: + l.tunDropped++ + return ErrNoUplink + case err != nil || !l.xAllowedLocked(): + l.tunDropped++ + return ErrTunDropped + } + select { + case l.tunOut <- frame: + l.xBudget.add(l.uptimeMs(), int64(len(frame))) + return nil + default: + l.tunDropped++ + return ErrTunDropped + } +} + +// XAllowed reports whether a watch's exchange answer may leave now. The +// exchange asks before it runs a request, so a command never acts when its +// answer could not go. +func (l *Link) XAllowed() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.tunOut != nil && l.xAllowedLocked() +} + +// xAllowedLocked keeps x to xBudgetBytes over a rolling 24 hours, and stops +// it once the tunnel has spent xStopsAt of its own budget, so the phones keep +// the rest for their handshakes, commands and ends. The caller holds l.mu. +func (l *Link) xAllowedLocked() bool { + up := l.uptimeMs() + return l.xBudget.total(up) < xBudgetBytes && l.tunBudget.total(up) < int64(xStopsAt*tunBudgetBytes) +} + +func xFrame(x cloudwire.X) ([]byte, error) { + if len(x.D) > cloudwire.XDataMax { + return nil, ErrTunDropped + } + body, err := cloudwire.Marshal(x) + if err != nil { + return nil, err + } + raw, err := cloudwire.Envelope{T: cloudwire.MsgX, B: body}.Encode() + if err != nil { + return nil, err + } + return cloudwire.EncodeFrame(raw) +} + func tunFrame(t cloudwire.Tun) ([]byte, error) { if len(t.D) > cloudwire.TunDataMax { return nil, ErrTunDropped diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 9593a259..5d5cf0c0 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -6,11 +6,12 @@ // // FTW Cloud never chooses an action. This package holds that line in its // build: it reaches the box only through the read-only ports below and the -// Tunnel port, and may import nothing from the box (CI checks it with go list -// -deps). What the service may send back is a closed set of six messages. -// Five ask the box for nothing but to send sooner, send again or forget a -// key. The sixth, tun, carries a phone's Noise session, which this package -// hands to the Tunnel unread. +// Tunnel and Exchange ports, and may import nothing from the box (CI checks +// it with go list -deps). What the service may send back is a closed set of +// seven messages. Five ask the box for nothing but to send sooner, send again +// or forget a key. tun carries a phone's Noise session, which this package +// hands to the Tunnel unread, and x a watch's exchange with the box, which it +// hands to the Exchange unread. // // The box links when it first shows a pairing code, has a phone that may // open a tunnel, or an owner grants the readable copy. A link record or a @@ -210,6 +211,15 @@ type Tunnel interface { Drop() } +// Exchange answers the watches' requests that FTW Cloud carries (ADR 0009). +// The box's exchange table implements it; the link hands it each x from the +// service unread. +type Exchange interface { + // Deliver takes one x from the service. It must not block: the link + // reads the service on the same goroutine. + Deliver(x cloudwire.X) +} + // WebPushSource renders and encrypts the notices the service may send when the box is away. type WebPushSource interface { WebPushRows() ([]cloudwire.WebPushRow, error) @@ -234,6 +244,9 @@ type Options struct { // Tunnel serves the phones' sessions. Nil takes no tun: the box does not // list tun in hello.caps, and the service sends it none. Tunnel Tunnel + // Exchange answers the watches. Nil, or no Tunnel, takes no x: the box + // does not list x in hello.caps, and the service sends it none. + Exchange Exchange // Arrivals hears each finished driver poll (telemetry.Store.Arrivals). // While a phone watches, live goes when a poll lands rather than on the // next check. Nil leaves live on its checks alone. @@ -296,9 +309,12 @@ type Link struct { // tunOut and tunBulk carry framed tun messages, lane 0 and bulk, to // the connection's tun writer. Nil while no uplink is up. tunBudget counts what left, apart from // the readable copy's budget, and tunDropped what never did. - tunOut chan []byte - tunBulk chan []byte - tunBudget budget + tunOut chan []byte + tunBulk chan []byte + tunBudget budget + // xBudget counts the watches' exchange answers apart, as well as in + // tunBudget once they leave. + xBudget budget tunDropped int64 sent map[string]sentFrame srcRev uint64 @@ -1037,6 +1053,11 @@ func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil && !l.disabled && !l.grant.statisticsOnly() } +// TakesX reports whether the link carries the watches' exchange as x, which +// it does only beside the tunnel: a watch's roster entry opens its door +// through tun. +func (l *Link) TakesX() bool { return l.TakesTun() && l.opts.Exchange != nil } + // SessionInfo is what a phone needs, over its Noise session with the box, to // reach this home through FTW Cloud: the home id, the key that signs the // readable copy, the boot that anchors its times and names for the opaque @@ -1205,6 +1226,9 @@ func (l *Link) caps(snap grantSnapshot) []string { out = append(out, cloudwire.MsgRoster, cloudwire.MsgNotice) if l.opts.Tunnel != nil { out = append(out, cloudwire.CapTun) + if l.opts.Exchange != nil { + out = append(out, cloudwire.CapX) + } } return append(out, cloudwire.CapShard) } diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index e56080d5..1fc3d294 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -368,10 +368,10 @@ func (s *fakeService) serve(conn uplinkConn, ekm []byte) { case err != nil: s.t.Errorf("box frame: %v", err) return - case env.T == cloudwire.MsgTun: - // d is Noise ciphertext; the box signs no tun. + case env.T == cloudwire.MsgTun, env.T == cloudwire.MsgX: + // d is Noise ciphertext; the box signs no tun and no x. if len(env.Sig) != 0 { - s.t.Errorf("the box signed a tun") + s.t.Errorf("the box signed a %s", env.T) } case cloudwire.Verify(pub, home, env) != nil: s.t.Errorf("box envelope %q does not verify", env.T) diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 9c4f3212..03aa4725 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -34,6 +34,12 @@ const ( // holding a session open all day at 1 Hz sends about 50 MB; this is five // of them, and stops a runaway before it eats a household's uplink. tunBudgetBytes = 250_000_000 + // xBudgetBytes is the watches' share of it: their exchange answers, a + // tenth. An answer is about a kilobyte, so this is some twenty thousand + // a day. xStopsAt is the share of the tunnel's budget past which x + // stops, so the phones keep the last of it. + xBudgetBytes = tunBudgetBytes / 10 + xStopsAt = 0.9 ) // sentFrame is the last frame of a class that left the house, exactly. diff --git a/go/internal/cloudlink/tunnel_test.go b/go/internal/cloudlink/tunnel_test.go index 9b44b13c..7b1400ce 100644 --- a/go/internal/cloudlink/tunnel_test.go +++ b/go/internal/cloudlink/tunnel_test.go @@ -627,3 +627,101 @@ func TestANoticeGoesPastTheBudget(t *testing.T) { } svc.waitFor(cloudwire.MsgNotice, 3*time.Second) } + +// fakeExchange is the box's exchange, as the link sees it. +type fakeExchange struct{ ch chan cloudwire.X } + +func (f *fakeExchange) Deliver(x cloudwire.X) { f.ch <- x } + +// x goes both ways unsigned and reaches the Exchange unread, and the box says +// x in hello.caps only when it has an Exchange beside its Tunnel. +func TestXGoesBothWaysAndIsSaidOnlyWithAnExchange(t *testing.T) { + svc := newFakeService(t) + svc.afterHello = func(conn uplinkConn, home string) { + svc.write(conn, nil, home, cloudwire.MsgX, cloudwire.X{Dev: "wAtCh123", ID: 4, D: []byte{1, 2, 3}}) + } + exchange := &fakeExchange{ch: make(chan cloudwire.X, 4)} + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.Exchange = exchange }) + h.setRoster(func(r *fakeRosterState) { r.tun = true }) + + select { + case got := <-exchange.ch: + if got.Dev != "wAtCh123" || got.ID != 4 || !bytes.Equal(got.D, []byte{1, 2, 3}) { + t.Fatalf("the Exchange got %+v", got) + } + case <-time.After(5 * time.Second): + t.Fatal("no x reached the Exchange") + } + if !h.link.TakesX() { + t.Fatal("a link with an Exchange beside its Tunnel does not take x") + } + var hello cloudwire.Hello + svc.mu.Lock() + payload, _ := cloudwire.DecodeFrame(svc.hello) + svc.mu.Unlock() + env, _ := cloudwire.DecodeEnvelope(payload) + if err := cloudwire.Unmarshal(env.B, &hello); err != nil { + t.Fatal(err) + } + if !slices.Contains(hello.Caps, cloudwire.CapX) { + t.Fatalf("hello caps %v name no x", hello.Caps) + } + + if err := h.link.SendX(cloudwire.X{Dev: "wAtCh123", ID: 4, D: []byte{4, 5}}); err != nil { + t.Fatalf("SendX refused with the uplink up: %v", err) + } + up := svc.waitFor(cloudwire.MsgX, 3*time.Second) + var x cloudwire.X + if err := cloudwire.Unmarshal(up.B, &x); err != nil || x.ID != 4 || x.Dev != "wAtCh123" || !bytes.Equal(x.D, []byte{4, 5}) || len(up.Sig) != 0 { + t.Fatalf("the service got %+v: %v", x, err) + } + + // Without an Exchange the box says no x. + plain := newFakeService(t) + h2 := newHarness(t, plain) + h2.setRoster(func(r *fakeRosterState) { r.tun = true }) + h2.nextRoster(t) + plain.mu.Lock() + payload, _ = cloudwire.DecodeFrame(plain.hello) + plain.mu.Unlock() + env, _ = cloudwire.DecodeEnvelope(payload) + if err := cloudwire.Unmarshal(env.B, &hello); err != nil { + t.Fatal(err) + } + if slices.Contains(hello.Caps, cloudwire.CapX) || h2.link.TakesX() { + t.Fatalf("a box with no Exchange says x: %v", hello.Caps) + } +} + +// x keeps to its share: a tenth of the tunnel's budget, and nothing once the +// tunnel has spent nine tenths, so the phones keep the last of it. Past +// either, an answer may not leave, and the exchange runs nothing. +func TestXKeepsToItsShareOfTheTunnelBudget(t *testing.T) { + svc := newFakeService(t) + exchange := &fakeExchange{ch: make(chan cloudwire.X, 4)} + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.Exchange = exchange }) + h.setRoster(func(r *fakeRosterState) { r.tun = true }) + h.nextRoster(t) + if !h.link.XAllowed() || h.link.SendX(cloudwire.X{Dev: "w", ID: 1}) != nil { + t.Fatal("x refused with its share unspent") + } + + h.link.mu.Lock() + h.link.xBudget.add(h.link.uptimeMs(), xBudgetBytes) + h.link.mu.Unlock() + if h.link.XAllowed() || h.link.SendX(cloudwire.X{Dev: "w", ID: 2}) == nil { + t.Fatal("x left past its own share") + } + + h.link.mu.Lock() + h.link.xBudget = budget{} + h.link.tunBudget.add(h.link.uptimeMs(), int64(xStopsAt*tunBudgetBytes)) + h.link.mu.Unlock() + if h.link.XAllowed() { + t.Fatal("x left with the phones' last share of the tunnel") + } + // The phones still have theirs. + if h.link.SendTun(cloudwire.Tun{Dev: "p", Sid: 1, D: []byte{1}}, false) != nil { + t.Fatal("a phone's tun was refused at nine tenths") + } +}