From 9a8199d50688de1b316eb8fd89fb098a3396196e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 10:32:13 +0200 Subject: [PATCH] feat(applink): an owner phone adds a watch as a device of its own ADR 0009 milestone 3, decisions 4, 5 and 9. POST /api/app-link/pairing with role watch answers only over a session, only to an owner, and only when that request carried stepUp; the passthrough's grace window does not stand in. The code binds to the calling phone and has its own slot, so it leaves a pending phone code alone. appenroll.AuthoriseWatch stores the watch with role watch and the phone as parent. A watch key gets no stream, and a phone key does not come through the watch's door. GrantReader now returns an Enrolment with the parent's current role, and GrantScopes keeps the watch role's scopes that the parent's role also carries, so demoting the phone narrows the watch at its next command. A watch spends its phone's command ids. Revoke returns every device it removed, a phone and then its watches, and the API drops each one's sessions. A watch keeps its role until it is removed. The device list, the box's page and FTW Cloud's roster carry each watch under its phone. cmd.exchange stays unsaid until the exchange socket lands in step 5. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/watch-role-enrolment.md | 5 + docs/adr/0009-watch-exchange.md | 5 +- go/cmd/ftw/app_link.go | 73 ++-- go/cmd/ftw/app_link_zone_test.go | 4 +- go/cmd/ftw/cloud_link.go | 6 +- go/cmd/ftw/cloud_roster_test.go | 78 ++++ go/internal/api/api_app_link.go | 101 ++++- go/internal/api/api_app_link_session_test.go | 27 +- go/internal/api/api_app_link_test.go | 24 +- go/internal/api/api_app_link_watch_test.go | 199 ++++++++++ go/internal/api/api_passthrough_test.go | 4 +- go/internal/appenroll/enroll.go | 205 ++++++++-- go/internal/appenroll/enroll_test.go | 6 +- go/internal/appenroll/watch.go | 109 ++++++ go/internal/appenroll/watch_test.go | 389 +++++++++++++++++++ go/internal/appproto/handler.go | 40 +- go/internal/appproto/harness_test.go | 8 +- go/internal/appproto/passthrough.go | 27 +- go/internal/appproto/protocol.go | 1 + go/internal/appproto/watch_test.go | 164 ++++++++ go/internal/appuplink/session.go | 26 +- go/internal/appuplink/uplink_test.go | 4 +- go/internal/appuplink/watch_test.go | 60 +++ web/app-link-sharing.test.mjs | 42 ++ web/settings/tabs/app.js | 36 +- web/style.css | 4 + 26 files changed, 1532 insertions(+), 115 deletions(-) create mode 100644 .changeset/watch-role-enrolment.md create mode 100644 go/cmd/ftw/cloud_roster_test.go create mode 100644 go/internal/api/api_app_link_watch_test.go create mode 100644 go/internal/appenroll/watch.go create mode 100644 go/internal/appenroll/watch_test.go create mode 100644 go/internal/appproto/watch_test.go create mode 100644 go/internal/appuplink/watch_test.go diff --git a/.changeset/watch-role-enrolment.md b/.changeset/watch-role-enrolment.md new file mode 100644 index 000000000..854f30b83 --- /dev/null +++ b/.changeset/watch-role-enrolment.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +An owner's phone can add an Apple Watch as a device of its own (ADR 0009). The phone asks for a watch code over its session, with a passkey step-up on that request; the code binds to the phone and leaves a pending phone code alone. The box stores the watch with role `watch` and the phone as its parent. A watch holds the watch role's scopes that its phone holds at that moment, so demoting the phone narrows the watch at once. Removing a phone removes its watches; removing a watch touches nothing else. A watch never gets a stream session. The box's page and `GET /api/app-link/devices` list each watch under its phone, and FTW Cloud's roster carries it with the scopes the box derives. The box does not offer the watch path yet: `cmd.exchange` stays unsaid until the exchange socket lands. diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index d105d87b8..b00c6a7cd 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -1,7 +1,8 @@ # ADR 0009: An Apple Watch works without the phone -- Status: accepted. Milestone 1, the command log on the box, is built. - Nothing else described here has shipped. +- Status: accepted. Milestones 1 to 3 are built: the command log on the box, + the registry names, and the watch role with its enrolment. The box does not + say `cmd.exchange` yet. Nothing else described here has shipped. - Date: 2026-10-14 - Decided by: Fredrik on 14 October 2026. Before the design: a watch controls the box directly as well as through the phone; an unlocked watch on the diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 0b7fbad47..c5b2cdba6 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -730,6 +730,12 @@ 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) +} + func (a *appLinkAPI) MintSpokenCode(role string) (string, time.Time, error) { code, expires, err := a.enroll.MintSpokenCode(role) return code, expires, appLinkError(err) @@ -739,7 +745,17 @@ func (a *appLinkAPI) EnrollmentURL(code []byte, lanHint string) (string, error) return a.enroll.EnrollmentURL(code, lanHint) } -func (a *appLinkAPI) AuthorisedCount() int { return a.enroll.AuthorisedCount() } +// AuthorisedCount is how many phones are paired. A watch rides on its phone +// and is not counted, so the page's "2 phones paired" stays true. +func (a *appLinkAPI) AuthorisedCount() int { + phones := 0 + for _, d := range a.enroll.Devices() { + if d.Parent == "" { + phones++ + } + } + return phones +} func (a *appLinkAPI) Devices() []api.AppDevice { infos := a.enroll.Devices() @@ -747,7 +763,7 @@ func (a *appLinkAPI) Devices() []api.AppDevice { for _, d := range infos { out = append(out, api.AppDevice{ ID: d.ID, AddedAtMs: d.AddedAtMs, LastSeenMs: d.LastSeenMs, - Role: d.Role, LastOwner: d.LastOwner, + Role: d.Role, LastOwner: d.LastOwner, Parent: d.Parent, }) } return out @@ -765,35 +781,40 @@ func (a *appLinkAPI) SetDeviceRole(id, role string) error { return appLinkError(a.enroll.SetRole(id, role)) } -// RevokeDevice locks one phone out. atTheBox is the API's word for the door -// the request came through, and it is the only thing that decides whether the -// last owner may go; Presence is enrollment's word for the same fact. +// RevokeDevice locks one device out, and a phone's watches with it. atTheBox +// is the API's word for the door the request came through, and it is the only +// thing that decides whether the last owner may go; Presence is enrollment's +// word for the same fact. func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error { - key, err := a.enroll.Revoke(id, appenroll.Presence(atTheBox)) + removed, err := a.enroll.Revoke(id, appenroll.Presence(atTheBox)) if err != nil { return appLinkError(err) } - // Forgetting the key locks the next handshake out; dropping the live - // sessions locks out the one running now. Both, or "remove" quietly - // means "remove within the hour". - if a.uplink != nil && key != nil { - a.uplink.DropSessionsByAppKey(key) - } - // A phone that may no longer read the house may no longer be told - // about it: its push subscriptions go with the grant, and the relay's - // pre-encrypted farewell rows for them go on the resync. - if a.st != nil { - n, err := a.st.DeletePushSubscriptionsByDevice(id) - switch { - case err != nil: - slog.Warn("could not sweep a revoked phone's push subscriptions", "device", id, "err", err) - case n > 0: - slog.Info("swept a revoked phone's push subscriptions", "device", id, "rows", n) - if a.uplink != nil { - go a.uplink.ResyncDeadman() + var swept int64 + for _, gone := range removed { + // Forgetting the key locks the next handshake out; dropping the + // live sessions locks out the one running now. Both, or "remove" + // quietly means "remove within the hour". + if a.uplink != nil { + a.uplink.DropSessionsByAppKey(gone.Key) + } + // A device that may no longer read the house may no longer be told + // about it: its push subscriptions go with the grant, and the + // relay's pre-encrypted farewell rows for them go on the resync. + if a.st != nil { + n, err := a.st.DeletePushSubscriptionsByDevice(gone.ID) + switch { + case err != nil: + slog.Warn("could not sweep a revoked device's push subscriptions", "device", gone.ID, "err", err) + case n > 0: + slog.Info("swept a revoked device's push subscriptions", "device", gone.ID, "rows", n) + swept += n } } } + if swept > 0 && a.uplink != nil { + go a.uplink.ResyncDeadman() + } return nil } @@ -812,6 +833,10 @@ func appLinkError(err error) error { return api.ErrLastAppOwnerProtected case errors.Is(err, appenroll.ErrUnknownRole): return api.ErrUnknownAppRole + case errors.Is(err, appenroll.ErrWatchRole): + return api.ErrWatchAppRole + case errors.Is(err, appenroll.ErrWatchNeedsOwner): + return api.ErrWatchNeedsOwner default: return err } diff --git a/go/cmd/ftw/app_link_zone_test.go b/go/cmd/ftw/app_link_zone_test.go index 895d45085..a72f1073c 100644 --- a/go/cmd/ftw/app_link_zone_test.go +++ b/go/cmd/ftw/app_link_zone_test.go @@ -128,4 +128,6 @@ func (s *sentFrames) all() [][]byte { type ownerGrant struct{} -func (ownerGrant) Grant() (string, uint64, bool) { return apiauth.RoleOwner, 1, true } +func (ownerGrant) Grant() (appproto.Enrolment, bool) { + return appproto.Enrolment{Role: apiauth.RoleOwner, Epoch: 1}, true +} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index fbb5eb03a..db7716870 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -14,7 +14,6 @@ import ( "time" "github.com/srcfl/ftw/go/internal/api" - "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/cloudlink" @@ -270,7 +269,8 @@ func roundW(v float64) int64 { func ptr[T any](v T) *T { return &v } -// cloudRoster lists paired phones with the scopes their roles carry. +// cloudRoster lists paired devices with the scopes the box derives for each: +// a phone's role, or for a watch what its role and its phone's role share. type cloudRoster struct { enroll *appenroll.Identity } @@ -279,7 +279,7 @@ func (r cloudRoster) Devices() []cloudlink.RosterDevice { var out []cloudlink.RosterDevice for _, e := range r.enroll.Roster() { out = append(out, cloudlink.RosterDevice{ID: e.ID, Key: e.Key, Role: e.Role, Epoch: e.Epoch, - Scopes: apiauth.RoleScopes[e.Role]}) + Scopes: appproto.GrantScopes(e.Role, e.ParentRole)}) } return out } diff --git a/go/cmd/ftw/cloud_roster_test.go b/go/cmd/ftw/cloud_roster_test.go new file mode 100644 index 000000000..000d3c5a0 --- /dev/null +++ b/go/cmd/ftw/cloud_roster_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "crypto/rand" + "path/filepath" + "slices" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" +) + +func rosterKey(t *testing.T) []byte { + t.Helper() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + return key +} + +func rosterPhone(t *testing.T, id *appenroll.Identity, role string) appenroll.Grant { + t.Helper() + code, _, err := id.MintPairingCode(role, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + grant, err := id.Authorise(rosterKey(t), code) + if err != nil { + t.Fatal(err) + } + return grant +} + +func rosterScopes(t *testing.T, roster cloudRoster, deviceID string) []string { + t.Helper() + for _, d := range roster.Devices() { + if d.ID == deviceID { + return d.Scopes + } + } + t.Fatalf("%s is not in the roster", deviceID) + return nil +} + +// FTW Cloud holds no role table, so the roster carries the scopes the box +// derives. A watch's are its role's, cut to what its phone holds now. +func TestTheRosterNarrowsAWatchToWhatItsPhoneHolds(t *testing.T) { + id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + 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) + if err != nil { + t.Fatal(err) + } + roster := cloudRoster{enroll: id} + + if got := rosterScopes(t, roster, watch.DeviceID); !slices.Equal(got, apiauth.RoleScopes[apiauth.RoleWatch]) { + t.Fatalf("under an owner the roster gives the watch %v", got) + } + if got := rosterScopes(t, roster, phone.DeviceID); !slices.Equal(got, apiauth.RoleScopes[apiauth.RoleOwner]) { + t.Fatalf("the owner phone's scopes changed to %v", got) + } + + if err := id.SetRole(phone.DeviceID, apiauth.RoleViewer); err != nil { + t.Fatal(err) + } + if got := rosterScopes(t, roster, watch.DeviceID); !slices.Equal(got, apiauth.RoleScopes[apiauth.RoleViewer]) { + t.Fatalf("under a viewer the roster gives the watch %v", got) + } +} diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go index de346212f..1f9006277 100644 --- a/go/internal/api/api_app_link.go +++ b/go/internal/api/api_app_link.go @@ -38,9 +38,15 @@ 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) // EnrollmentURL is what goes in the QR. EnrollmentURL(code []byte, lanHint string) (string, error) - // AuthorisedCount is how many devices may currently connect. + // AuthorisedCount is how many phones may currently connect. Watches + // ride on their phones and are not counted. AuthorisedCount() int // Devices lists the paired phones, most recently seen first. Devices() []AppDevice @@ -73,6 +79,10 @@ type AppDevice struct { // screen can say what it is before somebody presses a button. The box's // own page may remove it, and warns instead of refusing. LastOwner bool `json:"last_owner,omitempty"` + // Parent is the id of the phone that added this watch, and absent for a + // phone. The list puts each watch right after its phone, and removing + // the phone removes its watches. + Parent string `json:"parent,omitempty"` } var ( @@ -83,6 +93,12 @@ var ( ErrLastAppOwnerProtected = errors.New("api: that is the only owner") // ErrUnknownAppRole is a role that is not in contract/registry.yaml. ErrUnknownAppRole = errors.New("api: no such role") + // 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 = errors.New("api: only an owner phone can add a watch") ) type appLinkStatus struct { @@ -161,6 +177,10 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) { // below rather than being filled in for. var req appLinkPairingRequest _ = readJSON(r, &req) + if req.Role == apiauth.RoleWatch { + s.handleWatchPairing(w, r, req) + return + } if !s.appLinkRoleAllowed(w, r, req.Role) { return } @@ -225,11 +245,77 @@ 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). +// +// 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 +// 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. +func (s *Server) handleWatchPairing(w http.ResponseWriter, r *http.Request, req appLinkPairingRequest) { + caller, named := apiauth.FromRequest(r) + if !named || caller.Kind != apiauth.KindApp { + writeAppLinkError(w, http.StatusForbidden, + "a watch is added from the phone it belongs to.") + return + } + if caller.Role != apiauth.RoleOwner { + writeAppLinkError(w, http.StatusForbidden, + "only an owner's phone can add a watch.") + return + } + if !caller.StepUp { + writeAppLinkRefusal(w, http.StatusForbidden, appproto.ErrNeedsStepUp, + "confirm with your passkey to add a watch.") + return + } + if req.Kind == appPairingKindSpoken { + writeAppLinkError(w, http.StatusBadRequest, + "a watch code goes from the phone to the watch, never read aloud.") + return + } + + code, expiresAt, err := s.deps.AppEnroll.MintWatchCode(callerDeviceID(r)) + 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") + 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") + return + } + writeJSON(w, http.StatusOK, appLinkPairing{ + URL: url, + Role: apiauth.RoleWatch, + ExpiresAtMs: expiresAt.UnixMilli(), + }) +} + func (s *Server) writeAppLinkMintError(w http.ResponseWriter, err error) { if errors.Is(err, ErrUnknownAppRole) { writeAppLinkError(w, http.StatusBadRequest, "that is not a kind of access this box grants") return } + if errors.Is(err, ErrWatchAppRole) { + writeAppLinkError(w, http.StatusBadRequest, "a watch is added from the phone it belongs to.") + return + } writeAppLinkError(w, http.StatusInternalServerError, "could not mint a pairing code") } @@ -320,6 +406,9 @@ func (s *Server) handleAppLinkDeviceRole(w http.ResponseWriter, r *http.Request) writeAppLinkError(w, http.StatusNotFound, "that phone is no longer paired") case errors.Is(err, ErrUnknownAppRole): writeAppLinkError(w, http.StatusBadRequest, "that is not a kind of access this box grants") + case errors.Is(err, ErrWatchAppRole): + writeAppLinkError(w, http.StatusConflict, + "a watch keeps its role. Remove it, or change what its phone may do.") case errors.Is(err, ErrLastAppOwnerProtected): writeAppLinkRefusal(w, http.StatusConflict, appproto.ErrLastOwnerProtected, "that is the only phone that can change anything here. "+ @@ -447,13 +536,13 @@ func (s *Server) appLinkRoleAllowed(w http.ResponseWriter, r *http.Request, aske "say which kind of access this is for") return false } - // A watch belongs to the owner phone that adds it (ADR 0009). This box - // cannot bind a watch to a phone yet, so neither door hands one out: a - // watch code from the LAN, or a phone changed into a watch, would be a - // watch with no phone above it. + // A watch belongs to the owner phone that adds it (ADR 0009), and the + // pairing route sends a watch code to handleWatchPairing before it gets + // here. What reaches this line is a phone being changed into a watch, + // which would be a watch with no phone above it. if asked == apiauth.RoleWatch { writeAppLinkError(w, http.StatusForbidden, - "a watch is added from its owner's phone, and this box cannot do that yet") + "a watch is added from its owner's phone, never by changing a phone's role.") return false } if appLinkOverSession(r) && asked != apiauth.RoleViewer { diff --git a/go/internal/api/api_app_link_session_test.go b/go/internal/api/api_app_link_session_test.go index 4d33238b0..719b6a2b1 100644 --- a/go/internal/api/api_app_link_session_test.go +++ b/go/internal/api/api_app_link_session_test.go @@ -56,6 +56,11 @@ 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) EnrollmentURL(code []byte, lanHint string) (string, error) { return e.id.EnrollmentURL(code, lanHint) } @@ -67,7 +72,7 @@ func (e *realEnroller) Devices() []AppDevice { for _, d := range e.id.Devices() { out = append(out, AppDevice{ ID: d.ID, AddedAtMs: d.AddedAtMs, LastSeenMs: d.LastSeenMs, - Role: d.Role, LastOwner: d.LastOwner, + Role: d.Role, LastOwner: d.LastOwner, Parent: d.Parent, }) } return out @@ -92,6 +97,10 @@ func enrollError(err error) error { return ErrLastAppOwnerProtected case errors.Is(err, appenroll.ErrUnknownRole): return ErrUnknownAppRole + case errors.Is(err, appenroll.ErrWatchRole): + return ErrWatchAppRole + case errors.Is(err, appenroll.ErrWatchNeedsOwner): + return ErrWatchNeedsOwner default: return err } @@ -452,14 +461,16 @@ func TestTheAppCannotMakeAnotherOwner(t *testing.T) { } } -// No door hands out a watch yet. +// A watch comes only from its owner phone, over that phone's session. // -// The registry names the role, so the box's own enrolment would accept it. A -// watch belongs to the owner phone that adds it (ADR 0009), and until the box -// can bind one to its phone the API refuses the role at both doors. This runs -// against the real enrolment because a stub that knows only owner and viewer -// would refuse for its own reasons. -func TestNoDoorHandsOutAWatchYet(t *testing.T) { +// The registry names the role, so the box's own enrolment knows it. A watch +// belongs to the owner phone that adds it (ADR 0009): the box's page has no +// phone to bind one to, and changing a phone into a watch would leave a watch +// with no phone above it. A session that is not the enrolled owner's phone +// gets nothing either. The rule's success path is in +// api_app_link_watch_test.go. This runs against the real enrolment because a +// stub that knows only owner and viewer would refuse for its own reasons. +func TestNoDoorButItsPhoneHandsOutAWatch(t *testing.T) { enrol := newEnrolment(t) code, _, err := enrol.id.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL) diff --git a/go/internal/api/api_app_link_test.go b/go/internal/api/api_app_link_test.go index 0b9689514..7e82aba61 100644 --- a/go/internal/api/api_app_link_test.go +++ b/go/internal/api/api_app_link_test.go @@ -24,12 +24,15 @@ func lanAuthOnDeps(enroll *stubEnroller) *Deps { // A stub enroller. Pairing is the one surface that hands out a credential, so // what matters here is who is allowed to ask, not what comes back. type stubEnroller struct { - minted int - spoken int - revoked int - mintedRole string - roleSet string - err error + minted int + spoken int + watch int + revoked int + // watchParent is the phone the last watch code was bound to. + watchParent string + mintedRole string + roleSet string + err error // lastOwner makes the stub refuse to demote its one row, and to remove it // for anyone who is not standing at the box — the way appenroll does when // it is the only owner left. @@ -66,6 +69,15 @@ 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) { + if s.err != nil { + return nil, time.Time{}, s.err + } + s.watch++ + s.watchParent = parentID + return make([]byte, 16), time.Now().Add(10 * time.Minute), nil +} + func (s *stubEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) { return "https://app.ftw.energy/p#v2.aaa.bbb.ccc.ddd", nil } diff --git a/go/internal/api/api_app_link_watch_test.go b/go/internal/api/api_app_link_watch_test.go new file mode 100644 index 000000000..1cbdb8275 --- /dev/null +++ b/go/internal/api/api_app_link_watch_test.go @@ -0,0 +1,199 @@ +package api + +// Adding a watch, through the door it takes: an owner's phone, over its +// session, with a passkey step-up on the request itself (ADR 0009, +// decision 4). + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appproto" +) + +// sessionPhoneKey is a Noise key whose device id is aBcD1234, the subject +// newAppSession's caller carries. The watch code binds to that phone, so the +// box must know it by the same id. +func sessionPhoneKey(t *testing.T) []byte { + t.Helper() + prefix, err := base64.RawURLEncoding.DecodeString("aBcD1234") + if err != nil { + t.Fatal(err) + } + return append(prefix, make([]byte, 32-len(prefix))...) +} + +// newWatchEnrolment is a box whose owner is the session's own phone. +func newWatchEnrolment(t *testing.T) *realEnroller { + t.Helper() + id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "app.key")) + if err != nil { + t.Fatal(err) + } + code, _, err := id.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + phone, err := id.Authorise(sessionPhoneKey(t), code) + if err != nil { + t.Fatal(err) + } + if phone.DeviceID != "aBcD1234" { + t.Fatalf("the session's phone enrolled as %q", phone.DeviceID) + } + return &realEnroller{id: id} +} + +func watchPairing(stepUp bool) appproto.APIReq { + return appproto.APIReq{ + Method: appproto.APIPost, + Path: "/api/app-link/pairing", + Body: []byte(`{"role":"watch"}`), + StepUp: stepUp, + } +} + +func TestAnOwnerPhoneAddsAWatchThroughTheApp(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) + } + 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) + } +} + +// 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) { + enrol := newWatchEnrolment(t) + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + + // A genuine ceremony on another configure route opens the window. The + // window counts box uptime and treats zero as never, and this session's + // clock started a moment ago. + time.Sleep(5 * time.Millisecond) + if status, refusal, body := rig.call(t, 1, appproto.APIReq{ + Method: appproto.APIPost, Path: "/api/app-link/pairing", + Body: []byte(`{"role":"viewer"}`), StepUp: true, + }); status != http.StatusOK { + t.Fatalf("opening the window answered %d %q %s", status, refusal, body) + } + + status, refusal, body := rig.call(t, 2, watchPairing(false)) + if status != http.StatusForbidden { + t.Fatalf("a watch code without a step-up answered %d %q %s", status, refusal, body) + } + var answer map[string]string + if err := json.Unmarshal(body, &answer); err != nil { + t.Fatal(err) + } + if answer["code"] != appproto.ErrNeedsStepUp { + t.Fatalf("the refusal names %q, want %q so the app can ask for the passkey", answer["code"], appproto.ErrNeedsStepUp) + } + for _, d := range enrol.Devices() { + if d.Role == apiauth.RoleWatch { + t.Fatal("a watch joined") + } + } +} + +// A viewer's phone cannot add a watch, and the box's own page cannot either: +// the code binds to the phone that asks, and the LAN has no phone. +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 { + t.Fatalf("a viewer added a watch (%q)", refusal) + } + + s := New(&Deps{AppEnroll: enrol}) + w := httptest.NewRecorder() + s.handleAppLinkPairing(w, pairingRequestRole("192.168.1.1", "127.0.0.1:1234", nil, apiauth.RoleWatch)) + if w.Code != http.StatusForbidden { + t.Fatalf("the box's page minted a watch code: %d %s", w.Code, w.Body.String()) + } +} + +// Adding a watch leaves an invite somebody is about to scan alone. +func TestAddingAWatchLeavesAnInviteAlive(t *testing.T) { + enrol := newWatchEnrolment(t) + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + + _, _, inviteBody := rig.call(t, 1, appproto.APIReq{ + Method: appproto.APIPost, Path: "/api/app-link/pairing", + Body: []byte(`{"role":"viewer"}`), StepUp: true, + }) + var invite appLinkPairing + if err := json.Unmarshal(inviteBody, &invite); err != nil { + t.Fatal(err) + } + if status, refusal, body := rig.call(t, 2, watchPairing(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 { + t.Fatalf("the invite died when the watch code was minted: %v", err) + } +} + +// 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) + if err != nil { + t.Fatal(err) + } + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + + status, _, body := rig.call(t, 1, appproto.APIReq{Method: appproto.APIGet, Path: "/api/app-link/devices"}) + if status != http.StatusOK { + t.Fatalf("the list answered %d", status) + } + var list struct { + Devices []AppDevice `json:"devices"` + } + if err := json.Unmarshal(body, &list); err != nil { + t.Fatal(err) + } + if len(list.Devices) != 2 || list.Devices[1].ID != watch.DeviceID || list.Devices[1].Parent != "aBcD1234" { + t.Fatalf("the list is %+v", list.Devices) + } + + status, _, body = rig.call(t, 2, appproto.APIReq{ + Method: appproto.APIPatch, Path: "/api/app-link/devices/" + watch.DeviceID, + Body: []byte(`{"role":"viewer"}`), StepUp: true, + }) + if status != http.StatusConflict { + t.Fatalf("changing a watch's role answered %d %s", status, body) + } +} diff --git a/go/internal/api/api_passthrough_test.go b/go/internal/api/api_passthrough_test.go index d461ad879..37651f330 100644 --- a/go/internal/api/api_passthrough_test.go +++ b/go/internal/api/api_passthrough_test.go @@ -127,7 +127,9 @@ func decode[T any](t *testing.T, env appproto.Envelope) T { // these tests. Revocation and demotion have their own tests in appproto. type stillEnrolled struct{ role string } -func (s stillEnrolled) Grant() (string, uint64, bool) { return s.role, 1, true } +func (s stillEnrolled) Grant() (appproto.Enrolment, bool) { + return appproto.Enrolment{Role: s.role, Epoch: 1}, true +} // newAppSession builds a real API server and one app session onto it. // diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 9763331d3..a97ec97c9 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -95,6 +95,14 @@ var ( // ErrLastOwnerProtected is an attempt to demote the only owner, or to // remove it from somewhere other than the box itself. ErrLastOwnerProtected = errors.New("appenroll: that is the only owner") + // ErrWatchRole is the watch role asked for where it cannot be given: on + // a phone's code, or as a role change to or from watch. A watch comes + // only from an owner phone's watch code and stays a watch until removed. + ErrWatchRole = errors.New("appenroll: a watch comes only from an owner phone's watch code") + // ErrWatchNeedsOwner is a watch code asked for by a phone that is not an + // owner. A watch never holds more than its phone, and only an owner may + // add a device. + ErrWatchNeedsOwner = errors.New("appenroll: only an owner phone can add a watch") ) // stored is the on-disk shape. Base64 rather than hex only because the app's @@ -127,6 +135,9 @@ type storedApp struct { // GrantFor, which every privileged request calls. Absent, or zero, reads // as the first epoch. Epoch uint64 `json:"epoch,omitempty"` + // Parent is the full key of the phone that added this watch. Empty for a + // phone. A watch without its phone is removed with it, never left behind. + Parent string `json:"parent,omitempty"` } // UnmarshalJSON accepts the legacy bare-string form beside the current one. @@ -162,6 +173,9 @@ type DeviceInfo struct { // session — so a screen can say what it is before somebody presses a // button rather than after. LastOwner bool + // Parent is the device id of the phone that added this watch, and empty + // for a phone. The list puts each watch right after its phone. + Parent string } // deviceID is the row name: the first eight characters of the base64 key. @@ -188,6 +202,9 @@ type Identity struct { rendezvous []byte authorised map[string]*appMeta 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 // randRead is the entropy source. Injectable so a test can prove the // failure path instead of only the happy one. @@ -203,6 +220,9 @@ type appMeta struct { lastSeenMs int64 role string epoch uint64 + // parent is the full key of the phone that added a watch, and empty for + // a phone. + parent string } // firstEpoch is where a fresh enrolment starts. @@ -217,6 +237,12 @@ type Grant struct { DeviceID string Role string Epoch uint64 + // Parent and ParentRole name the phone that added a watch and the role + // that phone holds now. Both are empty for a phone. A watch holds only + // the scopes its parent's role also carries, so a parent demoted to + // viewer narrows its watch at once. + Parent string + ParentRole string } type pairingCode struct { @@ -233,6 +259,9 @@ 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. @@ -319,6 +348,15 @@ func (i *Identity) load(raw []byte) error { lastSeenMs: app.LastSeenMs, role: role, epoch: epoch, + parent: app.Parent, + } + } + // A watch whose phone is missing from the file was left behind by a + // write that never finished. It holds nothing its phone could vouch for, + // so it goes rather than lingering as a device nobody can narrow. + for key, meta := range i.authorised { + if meta.role == apiauth.RoleWatch && i.authorised[meta.parent] == nil { + delete(i.authorised, key) } } return nil @@ -360,7 +398,7 @@ func (i *Identity) save() error { for key, meta := range i.authorised { s.AuthorisedApps = append(s.AuthorisedApps, storedApp{ Key: key, AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs, - Role: meta.role, Epoch: meta.epoch, + Role: meta.role, Epoch: meta.epoch, Parent: meta.parent, }) } // Deterministic on disk, so two saves of the same state are the same @@ -446,7 +484,7 @@ func (i *Identity) RotateRendezvousSecret() ([]byte, error) { // the initiator's ephemeral nor its static private key and so cannot // derive the session keys the box answers with. func (i *Identity) MintPairingCode(role string, ttl time.Duration) ([]byte, time.Time, error) { - if err := knownRole(role); err != nil { + if err := phoneRole(role); err != nil { return nil, time.Time{}, err } code := make([]byte, PairingCodeBytes) @@ -472,7 +510,7 @@ func (i *Identity) MintPairingCode(role string, ttl time.Duration) ([]byte, time // Returned grouped as XXXX-XXXX, ready to be shown and read. The bytes are // what travels; the characters are for the person. func (i *Identity) MintSpokenCode(role string) (string, time.Time, error) { - if err := knownRole(role); err != nil { + if err := phoneRole(role); err != nil { return "", time.Time{}, err } code := make([]byte, SpokenCodeBytes) @@ -501,6 +539,18 @@ func knownRole(role string) error { return nil } +// phoneRole refuses a role a phone cannot hold: one the registry does not +// define, and watch, which only MintWatchCode hands out. +func phoneRole(role string) error { + if err := knownRole(role); err != nil { + return err + } + if role == apiauth.RoleWatch { + return ErrWatchRole + } + return nil +} + // Authorise decides whether a finished handshake may become a session, and // says what the session may do. // @@ -520,11 +570,18 @@ func (i *Identity) Authorise(appStatic, payload []byte) (Grant, error) { i.mu.Lock() if meta := i.authorised[key]; meta != nil { + if meta.role == apiauth.RoleWatch { + // A watch has no stream. It reaches the box through the + // exchange, which never takes one of a phone's four places, and + // it answers here as a stranger would. + i.mu.Unlock() + return Grant{}, ErrNoPairing + } // A reconnect. The stamp is what lets the settings page tell a // phone in daily use from a key that paired once and vanished — // which is exactly the row someone wants to revoke. meta.lastSeenMs = i.now().UnixMilli() - grant := Grant{DeviceID: deviceID(key), Role: meta.role, Epoch: meta.epoch} + grant := i.grantLocked(key, meta) i.mu.Unlock() // A stamp that could not be persisted must not block a session. _ = i.save() @@ -653,7 +710,22 @@ func (i *Identity) GrantFor(id string) (Grant, bool) { if meta == nil { return Grant{}, false } - return Grant{DeviceID: deviceID(key), Role: meta.role, Epoch: meta.epoch}, true + if meta.role == apiauth.RoleWatch && i.authorised[meta.parent] == nil { + // Revoke removes a phone's watches with it, so this is only a + // guard: a watch never outlives the phone that vouches for it. + return Grant{}, false + } + return i.grantLocked(key, meta), true +} + +// grantLocked is one enrolment as a Grant. The caller holds the mutex. +func (i *Identity) grantLocked(key string, meta *appMeta) Grant { + grant := Grant{DeviceID: deviceID(key), Role: meta.role, Epoch: meta.epoch} + if parent := i.authorised[meta.parent]; meta.parent != "" && parent != nil { + grant.Parent = deviceID(meta.parent) + grant.ParentRole = parent.role + } + return grant } // SetRole changes what one paired phone may do. @@ -674,16 +746,22 @@ func (i *Identity) GrantFor(id string) (Grant, bool) { // leaves that same box carrying a list of phones that can only look, which is // the shape of the problem rather than a way out of it. func (i *Identity) SetRole(id, role string) error { - if err := knownRole(role); err != nil { + if err := phoneRole(role); err != nil { return err } i.mu.Lock() - _, meta := i.findLocked(id) + key, meta := i.findLocked(id) if meta == nil { i.mu.Unlock() return ErrUnknownDevice } + if meta.role == apiauth.RoleWatch { + // A watch is a watch until it is removed. Its reach follows its + // phone's role, not a role of its own. + i.mu.Unlock() + return ErrWatchRole + } if meta.role == role { // Nothing changed, so nothing is bumped. An epoch that moved for a // write that changed nothing would make every live session re-read a @@ -697,6 +775,14 @@ func (i *Identity) SetRole(id, role string) error { } meta.role = role meta.epoch++ + // The phone's watches hold what it holds, so their grants changed too. + // Their epochs move with it, and the next roster carries the narrower + // scopes. + for _, watch := range i.authorised { + if watch.parent == key { + watch.epoch++ + } + } i.mu.Unlock() err := i.save() @@ -704,26 +790,43 @@ func (i *Identity) SetRole(id, role string) error { return err } -// Devices lists every paired phone, most recently seen first. +// Devices lists every paired phone, most recently seen first, each followed by +// its watches. func (i *Identity) Devices() []DeviceInfo { i.mu.Lock() lastOwner := i.ownerCountLocked() == 1 - out := make([]DeviceInfo, 0, len(i.authorised)) + var phones []DeviceInfo + watches := map[string][]DeviceInfo{} for key, meta := range i.authorised { - out = append(out, DeviceInfo{ + d := DeviceInfo{ ID: deviceID(key), AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs, Role: meta.role, LastOwner: lastOwner && meta.role == apiauth.RoleOwner, - }) + } + if meta.parent != "" { + d.Parent = deviceID(meta.parent) + watches[d.Parent] = append(watches[d.Parent], d) + continue + } + phones = append(phones, d) } i.mu.Unlock() - sort.Slice(out, func(a, b int) bool { - if out[a].LastSeenMs != out[b].LastSeenMs { - return out[a].LastSeenMs > out[b].LastSeenMs - } - return out[a].ID < out[b].ID - }) + recentFirst := func(rows []DeviceInfo) { + sort.Slice(rows, func(a, b int) bool { + if rows[a].LastSeenMs != rows[b].LastSeenMs { + return rows[a].LastSeenMs > rows[b].LastSeenMs + } + return rows[a].ID < rows[b].ID + }) + } + recentFirst(phones) + out := make([]DeviceInfo, 0, len(phones)) + for _, phone := range phones { + out = append(out, phone) + recentFirst(watches[phone.ID]) + out = append(out, watches[phone.ID]...) + } return out } @@ -745,9 +848,22 @@ const ( OverASession Presence = false ) -// Revoke forgets a phone by its device id and returns the full key, so the -// caller can also tear down any session that key is running right now. The -// next handshake from it meets ErrNoPairing like any stranger's. +// Removed is one enrolment a revoke took away: its device id and its full key, +// so the caller can end the sessions the key is running and sweep what the +// device left behind. +type Removed struct { + ID string + Key []byte +} + +// Revoke forgets a device by its id and returns what it removed: the device +// first, then, for a phone, every watch it added. The caller tears down the +// sessions each key is running right now. The next handshake from any of them +// meets ErrNoPairing like any stranger's. +// +// Removal runs from phone to watch and never back. A watch holds what its phone +// vouches for, so it goes with the phone; removing a watch touches nothing +// else. // // Removing a guest and locking out a phone are the same action, deliberately. // Sharing does not get its own gesture with its own bugs: a shared phone is a @@ -766,7 +882,7 @@ const ( // The decision stays here rather than in the API layer. What is new is that // the box is told which door the request came through; which screen drew the // button still decides nothing. -func (i *Identity) Revoke(id string, from Presence) ([]byte, error) { +func (i *Identity) Revoke(id string, from Presence) ([]Removed, error) { i.mu.Lock() fullKey, meta := i.findLocked(id) if meta == nil { @@ -777,7 +893,20 @@ func (i *Identity) Revoke(id string, from Presence) ([]byte, error) { i.mu.Unlock() return nil, ErrLastOwnerProtected } - delete(i.authorised, fullKey) + gone := []string{fullKey} + for key, watch := range i.authorised { + if watch.parent == fullKey { + gone = append(gone, key) + } + } + sort.Strings(gone[1:]) + 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() @@ -785,26 +914,33 @@ func (i *Identity) Revoke(id string, from Presence) ([]byte, error) { if err != nil { return nil, err } - raw, err := base64.RawURLEncoding.DecodeString(fullKey) - if err != nil { - // The key was minted by EncodeToString; this cannot happen outside a - // corrupted file, and the revoke itself already stuck. - return nil, nil + out := make([]Removed, 0, len(gone)) + for _, key := range gone { + raw, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + // The key was minted by EncodeToString; this cannot happen + // outside a corrupted file, and the revoke itself already stuck. + continue + } + out = append(out, Removed{ID: deviceID(key), Key: raw}) } - return raw, nil + return out, nil } -// RosterEntry is one paired phone with its whole key, for the roster the box +// RosterEntry is one paired device with its whole key, for the roster the box // signs for FTW Cloud. The service learns devices from nothing else, and it -// needs the key to recognise the phone's Noise handshake. +// needs the key to recognise the device's Noise handshake. type RosterEntry struct { ID string Key []byte Role string Epoch uint64 + // ParentRole is the role a watch's phone holds now, empty for a phone. + // The scopes the roster carries for a watch are those both roles share. + ParentRole string } -// Roster lists every paired phone, sorted by id. +// Roster lists every paired device, watches included, sorted by id. func (i *Identity) Roster() []RosterEntry { i.mu.Lock() out := make([]RosterEntry, 0, len(i.authorised)) @@ -813,7 +949,12 @@ func (i *Identity) Roster() []RosterEntry { if err != nil { continue } - out = append(out, RosterEntry{ID: deviceID(key), Key: raw, Role: meta.role, Epoch: meta.epoch}) + grant := i.grantLocked(key, meta) + if meta.role == apiauth.RoleWatch && grant.Parent == "" { + continue + } + out = append(out, RosterEntry{ID: grant.DeviceID, Key: raw, Role: meta.role, Epoch: meta.epoch, + ParentRole: grant.ParentRole}) } i.mu.Unlock() sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) diff --git a/go/internal/appenroll/enroll_test.go b/go/internal/appenroll/enroll_test.go index 11576dee7..540d10073 100644 --- a/go/internal/appenroll/enroll_test.go +++ b/go/internal/appenroll/enroll_test.go @@ -374,12 +374,12 @@ func TestDeviceListAndRevoke(t *testing.T) { // Revoke by row id: the key comes back so live sessions can be dropped, // and the next handshake meets ErrNoPairing like any stranger's. - key, err := id.Revoke(devices[1].ID, OverASession) + removed, err := id.Revoke(devices[1].ID, OverASession) if err != nil { t.Fatal(err) } - if string(key) != string(second) { - t.Fatal("revoke returned a different key than it removed") + if len(removed) != 1 || string(removed[0].Key) != string(second) || removed[0].ID != devices[1].ID { + t.Fatalf("revoke returned %+v, not the one row it removed", removed) } if _, err := id.Authorise(second, nil); !errors.Is(err, ErrNoPairing) { t.Fatalf("a revoked key reconnected: %v", err) diff --git a/go/internal/appenroll/watch.go b/go/internal/appenroll/watch.go new file mode 100644 index 000000000..2aab4e80c --- /dev/null +++ b/go/internal/appenroll/watch.go @@ -0,0 +1,109 @@ +package appenroll + +import ( + "crypto/subtle" + "encoding/base64" + "fmt" + "time" + + "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. +// +// 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) { + 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 { + i.mu.Unlock() + return Grant{}, ErrNoPairing + } + meta.lastSeenMs = i.now().UnixMilli() + grant := i.grantLocked(key, meta) + i.mu.Unlock() + _ = i.save() + return grant, nil + } + if len(code) == 0 { + i.mu.Unlock() + return Grant{}, ErrNoPairing + } + pairing := i.watchPairing + if pairing == nil || !i.now().Before(pairing.expiresAt) { + i.mu.Unlock() + return Grant{}, ErrBadPairing + } + if subtle.ConstantTimeCompare(code, pairing.code) != 1 { + pairing.attempts++ + if pairing.attempts >= MaxPairingAttempts { + i.watchPairing = nil + } + 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 + } + + now := i.now().UnixMilli() + meta := &appMeta{ + addedAtMs: now, lastSeenMs: now, role: apiauth.RoleWatch, epoch: firstEpoch, parent: pairing.parent, + } + i.authorised[key] = meta + i.watchPairing = nil + grant := i.grantLocked(key, meta) + i.mu.Unlock() + + err := i.save() + i.changed() + return grant, err +} diff --git a/go/internal/appenroll/watch_test.go b/go/internal/appenroll/watch_test.go new file mode 100644 index 000000000..60b169f28 --- /dev/null +++ b/go/internal/appenroll/watch_test.go @@ -0,0 +1,389 @@ +package appenroll + +// A watch, at the layer that decides what it is (ADR 0009, decisions 4, 5 +// and 9). + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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. +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) + if err != nil { + t.Fatalf("adding a watch: %v", err) + } + return key, grant +} + +func TestAnOwnerPhoneAddsAWatchThatNamesItAsParent(t *testing.T) { + id, _ := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + + key, watch := addWatch(t, id, phone) + if watch.Role != apiauth.RoleWatch { + t.Fatalf("the watch is a %q", watch.Role) + } + if watch.Parent != phone.DeviceID || watch.ParentRole != apiauth.RoleOwner { + 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) + if err != nil { + t.Fatalf("the watch's second handshake was refused: %v", err) + } + if again.DeviceID != watch.DeviceID || again.Parent != phone.DeviceID { + t.Fatalf("the second handshake found %+v", again) + } +} + +// 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) + phoneKey := appKey(t) + code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL) + if err != nil { + t.Fatal(err) + } + phone, err := id.Authorise(phoneKey, code) + if err != nil { + t.Fatal(err) + } + watchKey, _ := addWatch(t, id, phone) + + 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) { + 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) { + id, _ := newIdentity(t) + pair(t, id, apiauth.RoleOwner) + phone := pair(t, id, apiauth.RoleOwner) + firstKey, first := addWatch(t, id, phone) + secondKey, second := addWatch(t, id, phone) + + removed, err := id.Revoke(first.DeviceID, OverASession) + if err != nil { + t.Fatal(err) + } + if len(removed) != 1 || string(removed[0].Key) != string(firstKey) { + t.Fatalf("removing a watch removed %+v", removed) + } + if _, ok := id.GrantFor(phone.DeviceID); !ok { + t.Fatal("removing a watch removed its phone") + } + if _, ok := id.GrantFor(second.DeviceID); !ok { + t.Fatal("removing one watch removed another") + } + if _, err := id.AuthoriseWatch(firstKey, nil); !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) + } + if len(removed) != 2 || removed[0].ID != phone.DeviceID || removed[1].ID != second.DeviceID { + t.Fatalf("removing the phone removed %+v, want the phone then its watch", removed) + } + if _, ok := id.GrantFor(second.DeviceID); ok { + t.Fatal("a watch outlived its phone") + } + if _, err := id.AuthoriseWatch(secondKey, nil); !errors.Is(err, ErrNoPairing) { + t.Fatalf("a watch whose phone was removed was answered: %v", err) + } + for _, d := range id.Roster() { + if d.ID == second.DeviceID { + 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. +func TestTheWatchRoleCannotBeGivenOrTakenByARoleChange(t *testing.T) { + id, _ := newIdentity(t) + pair(t, id, apiauth.RoleOwner) + phone := pair(t, id, apiauth.RoleOwner) + _, watch := addWatch(t, id, phone) + + if err := id.SetRole(watch.DeviceID, apiauth.RoleOwner); !errors.Is(err, ErrWatchRole) { + t.Fatalf("a watch was made an owner: %v", err) + } + if err := id.SetRole(phone.DeviceID, apiauth.RoleWatch); !errors.Is(err, ErrWatchRole) { + t.Fatalf("a phone was made a watch: %v", err) + } + if _, _, err := id.MintPairingCode(apiauth.RoleWatch, PairingTTL); !errors.Is(err, ErrWatchRole) { + t.Fatalf("a phone's code was minted for a watch: %v", err) + } + if _, _, err := id.MintSpokenCode(apiauth.RoleWatch); !errors.Is(err, ErrWatchRole) { + t.Fatalf("a box code was minted for a watch: %v", err) + } +} + +// Demoting the phone narrows its watch: the grant names the parent's new role +// and moves its epoch, so the next roster carries the narrower scopes. +func TestDemotingAPhoneMovesItsWatchsGrant(t *testing.T) { + id, _ := newIdentity(t) + pair(t, id, apiauth.RoleOwner) + phone := pair(t, id, apiauth.RoleOwner) + _, watch := addWatch(t, id, phone) + + if err := id.SetRole(phone.DeviceID, apiauth.RoleViewer); err != nil { + t.Fatal(err) + } + after, ok := id.GrantFor(watch.DeviceID) + if !ok { + t.Fatal("demoting the phone removed its watch") + } + if after.ParentRole != apiauth.RoleViewer { + t.Fatalf("the watch's parent role is %q after the demotion", after.ParentRole) + } + if after.Epoch <= watch.Epoch { + t.Fatalf("the watch's epoch stayed at %d", after.Epoch) + } + for _, d := range id.Roster() { + if d.ID == watch.DeviceID && (d.ParentRole != apiauth.RoleViewer || d.Epoch != after.Epoch) { + t.Fatalf("the roster has %+v for the watch", d) + } + } +} + +// The list puts each watch right after its phone. +func TestTheDeviceListPutsAWatchUnderItsPhone(t *testing.T) { + id, _ := newIdentity(t) + now := time.UnixMilli(1_760_000_000_000) + id.now = func() time.Time { return now } + + first := pair(t, id, apiauth.RoleOwner) + now = now.Add(time.Minute) + second := pair(t, id, apiauth.RoleOwner) + now = now.Add(time.Minute) + _, watch := addWatch(t, id, first) + + rows := id.Devices() + if len(rows) != 3 { + t.Fatalf("%d rows", len(rows)) + } + got := []string{rows[0].ID, rows[1].ID, rows[2].ID} + want := []string{second.DeviceID, first.DeviceID, watch.DeviceID} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("rows in order %v, want %v", got, want) + } + if rows[2].Parent != first.DeviceID || rows[2].Role != apiauth.RoleWatch || rows[2].LastOwner { + t.Fatalf("the watch's row is %+v", rows[2]) + } + if rows[0].Parent != "" || rows[1].Parent != "" { + t.Fatal("a phone's row names a parent") + } +} + +// A watch is a device in the roster, with its role and its phone's role. +func TestAWatchIsInTheRoster(t *testing.T) { + id, _ := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + key, watch := addWatch(t, id, phone) + + for _, d := range id.Roster() { + if d.ID != watch.DeviceID { + continue + } + if d.Role != apiauth.RoleWatch || d.ParentRole != apiauth.RoleOwner || string(d.Key) != string(key) { + t.Fatalf("the watch's roster entry is %+v", d) + } + return + } + t.Fatal("the watch is not in the roster") +} + +func TestAWatchAndItsParentSurviveARestart(t *testing.T) { + id, keyPath := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + key, watch := addWatch(t, id, phone) + + again, err := LoadOrCreate(keyPath) + if err != nil { + t.Fatal(err) + } + grant, err := again.AuthoriseWatch(key, nil) + if err != nil { + t.Fatalf("the watch was forgotten across a restart: %v", err) + } + if grant.DeviceID != watch.DeviceID || grant.Parent != phone.DeviceID { + t.Fatalf("after a restart the watch is %+v", grant) + } +} + +// A watch whose phone is missing from the file goes at load rather than +// lingering as a device nobody can narrow. +func TestAWatchWithoutItsPhoneIsDroppedAtLoad(t *testing.T) { + id, keyPath := newIdentity(t) + phone := pair(t, id, apiauth.RoleOwner) + _, watch := addWatch(t, id, phone) + + // Write the file as a crash between the two halves of a removal would + // have left it: the phone gone, its watch still there. + path := filepath.Join(filepath.Dir(keyPath), FileName) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + id.mu.Lock() + phoneKey, _ := id.findLocked(phone.DeviceID) + delete(id.authorised, phoneKey) + id.mu.Unlock() + if err := id.save(); err != nil { + t.Fatal(err) + } + if after, _ := os.ReadFile(path); string(after) == string(raw) { + t.Fatal("the test did not change the file") + } + + again, err := LoadOrCreate(keyPath) + if err != nil { + t.Fatal(err) + } + if _, ok := again.GrantFor(watch.DeviceID); ok { + t.Fatal("an orphaned watch loaded") + } + if n := again.AuthorisedCount(); n != 0 { + t.Fatalf("%d enrolments after loading an orphan", n) + } +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index d714e1197..502d3d82d 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -265,6 +265,32 @@ func ScopesForRole(role string) apiauth.ScopeSet { return apiauth.NewScopeSet(apiauth.RoleScopes[role]...) } +// GrantScopes lists what an enrolment carries now, in the registry's order. +// +// A phone carries its role's scopes. A watch carries those of its role's +// scopes that its parent's role carries too, so a watch never does more than +// its phone can do this second (ADR 0009, decision 4). A watch with no parent +// carries nothing. +func GrantScopes(role, parentRole string) []string { + own := apiauth.RoleScopes[role] + if role != apiauth.RoleWatch { + return own + } + parent := ScopesForRole(parentRole) + out := make([]string, 0, len(own)) + for _, scope := range own { + if parent.Has(scope) { + out = append(out, scope) + } + } + return out +} + +// ScopesForGrant is GrantScopes as a set. +func ScopesForGrant(role, parentRole string) apiauth.ScopeSet { + return apiauth.NewScopeSet(GrantScopes(role, parentRole)...) +} + // liveCaller is whose session this is, as the box's records stand this second. // // Not the snapshot the handshake took. A socket outlives a revoke, and it @@ -280,14 +306,14 @@ func ScopesForRole(role string) apiauth.ScopeSet { // that is the right way round — the app drawing a button is presentation, and // the box refusing it is the enforcement. func (h *Handler) liveCaller() (apiauth.Caller, bool) { - role, epoch, ok := h.cfg.Grants.Grant() + grant, ok := h.cfg.Grants.Grant() if !ok { return apiauth.Caller{}, false } caller := h.cfg.Caller - caller.Role = role - caller.Epoch = epoch - caller.Scopes = ScopesForRole(role) + caller.Role = grant.Role + caller.Epoch = grant.Epoch + caller.Scopes = ScopesForGrant(grant.Role, grant.ParentRole) return caller, true } @@ -898,8 +924,12 @@ func (h *Handler) onCmd(ctx context.Context, env Envelope) error { } // cmdRoot is the device whose command ids this session spends: the enrolled -// device itself. Its subject is stable across sessions and names nobody else. +// device itself, or for a watch the phone that added it. A subject is stable +// across sessions and names nobody else. func (h *Handler) cmdRoot() string { + if grant, ok := h.cfg.Grants.Grant(); ok && grant.Root != "" { + return grant.Root + } return h.cfg.Caller.Subject } diff --git a/go/internal/appproto/harness_test.go b/go/internal/appproto/harness_test.go index 5b4ebb1f4..24ac371c1 100644 --- a/go/internal/appproto/harness_test.go +++ b/go/internal/appproto/harness_test.go @@ -245,16 +245,20 @@ type fakeGrants struct { role string epoch uint64 gone bool + // parentRole and root make the grant a watch's: the role its phone holds + // now, and that phone's subject. + parentRole string + root string } func newGrants() *fakeGrants { return &fakeGrants{role: apiauth.RoleOwner, epoch: 1} } func newViewerGrants() *fakeGrants { return &fakeGrants{role: apiauth.RoleViewer, epoch: 1} } -func (g *fakeGrants) Grant() (string, uint64, bool) { +func (g *fakeGrants) Grant() (Enrolment, bool) { g.mu.Lock() defer g.mu.Unlock() - return g.role, g.epoch, !g.gone + return Enrolment{Role: g.role, Epoch: g.epoch, ParentRole: g.parentRole, Root: g.root}, !g.gone } // revoke is what appenroll.Revoke does to a row: it stops existing. diff --git a/go/internal/appproto/passthrough.go b/go/internal/appproto/passthrough.go index 138e92f8e..7bff58812 100644 --- a/go/internal/appproto/passthrough.go +++ b/go/internal/appproto/passthrough.go @@ -86,13 +86,28 @@ type APIGateway interface { // GrantReader is this session's enrolment, as it stands right now. // // Read on every privileged request rather than at the handshake, because a -// socket outlives a revoke and it outlives a role change too. One string and -// one integer behind a mutex that is already there. +// socket outlives a revoke and it outlives a role change too. A few strings +// and one integer behind a mutex that is already there. type GrantReader interface { - // Grant is what the enrolment currently carries: the role it holds and - // the epoch that answer was written under. ok is false when the device is - // no longer enrolled at all — a revoke is an absence, not a stale number. - Grant() (role string, epoch uint64, ok bool) + // Grant is what the enrolment currently carries. ok is false when the + // device is no longer enrolled at all — a revoke is an absence, not a + // stale number. + Grant() (Enrolment, bool) +} + +// Enrolment is one device's grant as the box holds it this second. +type Enrolment struct { + // Role is the registry role the device holds. + Role string + // Epoch is the version of the grant this answer was written under. + Epoch uint64 + // ParentRole is the role held now by the phone that added a watch, and + // empty for a phone. A watch carries only what both roles carry. + ParentRole string + // Root is the subject whose command ids this device spends: empty for a + // phone, which spends its own, and the parent phone's for a watch. One + // intent sent by the watch and by its phone then acts once. + Root string } // -------------------------------------------------------------------------- diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 317ea3e14..89886c784 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -215,6 +215,7 @@ const ( CapPriceSpot = protocol.CapPriceSpot CapApiPassthrough = protocol.CapApiPassthrough CapCloudLink = protocol.CapCloudLink + CapCmdExchange = protocol.CapCmdExchange ScopeLiveRead = protocol.ScopeLiveRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopePlanRead = protocol.ScopePlanRead diff --git a/go/internal/appproto/watch_test.go b/go/internal/appproto/watch_test.go new file mode 100644 index 000000000..1615ef25c --- /dev/null +++ b/go/internal/appproto/watch_test.go @@ -0,0 +1,164 @@ +package appproto + +import ( + "slices" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/control" +) + +// The phone that added the watch in these tests, and the watch itself. +const ( + watchParentSubject = apiauth.KindApp + ":aBcD1234" + watchSubject = apiauth.KindApp + ":wAtCh123" +) + +// watchCaller is the caller a watch's session is built with, as the uplink +// builds it: the role's scopes that its owner phone also holds. +func watchCaller() apiauth.Caller { + return apiauth.Caller{ + Subject: watchSubject, + Kind: apiauth.KindApp, + Role: apiauth.RoleWatch, + Scopes: ScopesForGrant(apiauth.RoleWatch, apiauth.RoleOwner), + Epoch: 1, + } +} + +func newWatchGrants() *fakeGrants { + return &fakeGrants{ + role: apiauth.RoleWatch, epoch: 1, parentRole: apiauth.RoleOwner, root: watchParentSubject, + } +} + +// A watch reads, sees the plan and alerts, and charges. Nothing else, and +// never more than its phone holds now. +func TestAWatchHoldsOnlyWhatItsPhoneHolds(t *testing.T) { + underOwner := GrantScopes(apiauth.RoleWatch, apiauth.RoleOwner) + if !slices.Equal(underOwner, apiauth.RoleScopes[apiauth.RoleWatch]) { + t.Fatalf("under an owner a watch holds %v, want its role's %v", underOwner, apiauth.RoleScopes[apiauth.RoleWatch]) + } + for _, never := range []string{ScopeModeWrite, ScopeMembersRead, ScopeMembersWrite, ScopeHistoryRead} { + if slices.Contains(underOwner, never) { + t.Fatalf("a watch holds %s", never) + } + } + + underViewer := GrantScopes(apiauth.RoleWatch, apiauth.RoleViewer) + if !slices.Equal(underViewer, []string{ScopeLiveRead}) { + t.Fatalf("under a viewer a watch holds %v, want only %s", underViewer, ScopeLiveRead) + } + if got := GrantScopes(apiauth.RoleWatch, ""); len(got) != 0 { + t.Fatalf("a watch with no phone holds %v", got) + } + if got := GrantScopes(apiauth.RoleOwner, ""); !slices.Equal(got, apiauth.RoleScopes[apiauth.RoleOwner]) { + t.Fatalf("an owner phone holds %v", got) + } +} + +// A watch may charge and may not change the site mode. +func TestAWatchChargesButCannotSetTheMode(t *testing.T) { + h, lp, box, rec, _ := newEVRig(t) + h.cfg.Caller = watchCaller() + h.cfg.Grants = newWatchGrants() + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdSetMode(string(control.ModeCharge), 7, 200_000, nil)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrScopeDenied { + t.Fatalf("site.mode.set from a watch = %+v, want rejected/%s", res, ErrScopeDenied) + } + if box.setModeCalls != 0 { + t.Fatal("a watch changed the site mode") + } + rec.reset() + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointHold, "cmd-watch-hold", holdArgs(), 7, 200_000)) + if res := body[CmdResult](t, rec.only(t, MsgCmdResult)); res.State != CmdApplied { + t.Fatalf("a watch's charge ended %q (%+v)", res.State, res.Error) + } + if lp.holdCalls != 1 { + t.Fatalf("the charger saw %d holds", lp.holdCalls) + } +} + +// Demoting the phone narrows its watch at the next command, with no new +// session: the watch never does more than its phone can do now. +func TestDemotingThePhoneNarrowsItsWatchAtOnce(t *testing.T) { + h, lp, _, rec, _ := newEVRig(t) + grants := newWatchGrants() + h.cfg.Caller = watchCaller() + h.cfg.Grants = grants + subscribe(t, h, rec) + + grants.mu.Lock() + grants.parentRole = apiauth.RoleViewer + grants.epoch++ + grants.mu.Unlock() + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointHold, "cmd-after-demotion", holdArgs(), 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrScopeDenied { + t.Fatalf("result = %+v, want rejected/%s once the phone is a viewer", res, ErrScopeDenied) + } + if lp.controllerCalls() != 0 { + t.Fatal("a watch charged after its phone lost the right to") + } +} + +// A watch spends its phone's command ids. One intent sent by the watch and +// again by its phone acts once. +func TestAWatchAndItsPhoneShareCommandIds(t *testing.T) { + h, lp, box, rec, clock := newEVRig(t) + h.cfg.Caller = watchCaller() + h.cfg.Grants = newWatchGrants() + subscribe(t, h, rec) + + cmd := cmdLoadpoint(OpLoadpointHold, "cmd-both-paths", holdArgs(), 7, 200_000) + deliver(t, h, MsgCmd, nil, cmd) + first := body[CmdAck](t, rec.only(t, MsgCmdAck)) + + phone := ownerCaller() + phone.Subject = watchParentSubject + next, nextRec := sessionOf(t, h, phone, box, clock) + deliver(t, next, MsgCmd, nil, cmd) + + if lp.holdCalls != 1 { + t.Fatalf("the charger saw %d holds for one intent", lp.holdCalls) + } + if got := body[CmdAck](t, nextRec.only(t, MsgCmdAck)); got.LeaseID != first.LeaseID { + t.Fatalf("the phone got lease %q, the watch %q", got.LeaseID, first.LeaseID) + } + + // Another phone's same id is still its own intent. + stranger := ownerCaller() + stranger.Subject = apiauth.KindApp + ":qRsT5678" + other, otherRec := sessionOf(t, h, stranger, box, clock) + deliver(t, other, MsgCmd, nil, cmd) + if res := body[CmdResult](t, otherRec.only(t, MsgCmdResult)); res.State != CmdApplied || lp.holdCalls != 2 { + t.Fatalf("another phone's command ended %q after %d holds", res.State, lp.holdCalls) + } +} + +// hello_ok tells the watch what it holds, so it draws no button its phone has +// lost. +func TestAWatchsHelloNamesItsRoleAndScopes(t *testing.T) { + h, _, rec, _ := newRig(t) + h.cfg.Caller = watchCaller() + h.cfg.Grants = newWatchGrants() + + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) + ok := body[HelloOK](t, rec.only(t, MsgHelloOK)) + if ok.Role != apiauth.RoleWatch { + t.Fatalf("hello_ok role = %q", ok.Role) + } + want := append([]string(nil), apiauth.RoleScopes[apiauth.RoleWatch]...) + slices.Sort(want) + if !slices.Equal(ok.Scopes, want) { + t.Fatalf("hello_ok scopes = %v, want %v", ok.Scopes, want) + } + if slices.Contains(ok.Caps, CapCmdExchange) { + t.Fatal("hello_ok says cmd.exchange before the box opens the exchange") + } +} diff --git a/go/internal/appuplink/session.go b/go/internal/appuplink/session.go index f36ee6c66..5f9c6c544 100644 --- a/go/internal/appuplink/session.go +++ b/go/internal/appuplink/session.go @@ -171,10 +171,10 @@ func (s *sessions) open(message []byte) error { // commands it may send, which routes it may reach — is decided from this // value, and nothing the phone sends can add to it. caller := apiauth.Caller{ - Subject: apiauth.KindApp + ":" + grant.DeviceID, + Subject: subjectOf(grant.DeviceID), Kind: apiauth.KindApp, Role: grant.Role, - Scopes: appproto.ScopesForRole(grant.Role), + Scopes: appproto.ScopesForGrant(grant.Role, grant.ParentRole), Epoch: grant.Epoch, } @@ -280,12 +280,28 @@ type liveGrant struct { deviceID string } -func (g liveGrant) Grant() (string, uint64, bool) { +func (g liveGrant) Grant() (appproto.Enrolment, bool) { grant, ok := g.enroll.GrantFor(g.deviceID) if !ok { - return "", 0, false + return appproto.Enrolment{}, false } - return grant.Role, grant.Epoch, true + return enrolmentOf(grant), true +} + +// enrolmentOf is a grant as the message layer reads it. A watch spends its +// parent phone's command ids, so one intent sent by both acts once. +func enrolmentOf(grant appenroll.Grant) appproto.Enrolment { + e := appproto.Enrolment{Role: grant.Role, Epoch: grant.Epoch, ParentRole: grant.ParentRole} + if grant.Parent != "" { + e.Root = subjectOf(grant.Parent) + } + return e +} + +// subjectOf is the caller subject for a device id. Built here and nowhere +// else, so the command log and the audit line name a device the same way. +func subjectOf(deviceID string) string { + return apiauth.KindApp + ":" + deviceID } func (s *sessions) count() int { diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go index 535ee727e..69ee9bf6a 100644 --- a/go/internal/appuplink/uplink_test.go +++ b/go/internal/appuplink/uplink_test.go @@ -783,11 +783,11 @@ func TestARevokedShareStopsACallAlreadyInFlight(t *testing.T) { // What the settings page does: forget the key, then tear down whatever // it is running right now. - key, err := r.enroll.Revoke(deviceIDOf(app.staticPublic()), appenroll.AtTheBox) + removed, err := r.enroll.Revoke(deviceIDOf(app.staticPublic()), appenroll.AtTheBox) if err != nil { t.Fatalf("Revoke: %v", err) } - if dropped := r.uplink.DropSessionsByAppKey(key); dropped != 1 { + if dropped := r.uplink.DropSessionsByAppKey(removed[0].Key); dropped != 1 { t.Fatalf("dropped %d sessions, want 1", dropped) } diff --git a/go/internal/appuplink/watch_test.go b/go/internal/appuplink/watch_test.go new file mode 100644 index 000000000..de6ae5dd8 --- /dev/null +++ b/go/internal/appuplink/watch_test.go @@ -0,0 +1,60 @@ +package appuplink + +import ( + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" +) + +// A watch reaches the box through the exchange and never through the stream, +// so it can never take one of a phone's four places. Its key on the stream +// gets the silence a stranger gets. +func TestAWatchKeyGetsNoStream(t *testing.T) { + r := newRig(t, 481234) + + code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatalf("MintPairingCode: %v", err) + } + phone, err := newAppClient() + if err != nil { + t.Fatalf("newAppClient: %v", err) + } + grant, err := r.enroll.Authorise(phone.staticPublic(), code) + if err != nil { + t.Fatalf("pairing the phone: %v", err) + } + watchCode, _, err := r.enroll.MintWatchCode(grant.DeviceID, appenroll.WatchTTL) + if err != nil { + t.Fatalf("MintWatchCode: %v", err) + } + watch, err := newAppClient() + if err != nil { + t.Fatalf("newAppClient: %v", err) + } + if _, err := r.enroll.AuthoriseWatch(watch.staticPublic(), watchCode); err != nil { + t.Fatalf("adding the watch: %v", err) + } + + conn := r.dialApp(t, 481234) + message1, err := watch.message1(r.enroll.StaticKey().Public(), nil) + if err != nil { + t.Fatalf("message1: %v", err) + } + if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { + t.Fatalf("sending message 1: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + for { + kind, _, err := conn.ReadMessage() + if err != nil { + return // the deadline, which is the pass + } + if kind == websocket.BinaryMessage { + t.Fatal("the box opened a stream for a watch") + } + } +} diff --git a/web/app-link-sharing.test.mjs b/web/app-link-sharing.test.mjs index ea6d3808c..9eab9519c 100644 --- a/web/app-link-sharing.test.mjs +++ b/web/app-link-sharing.test.mjs @@ -259,6 +259,48 @@ describe("the device list", () => { assert.doesNotMatch(said, /last phone/i, "two phones are paired and one was called the last"); }); + // A watch sits under the phone that added it. Its reach follows that + // phone, so it gets Remove and nothing else. + it("lists a watch under its phone, with no role button", async () => { + const { el } = load({ + devices: [ + { id: "aaaa1111", role: "owner" }, + { id: "wwww3333", role: "watch", parent: "aaaa1111" }, + { id: "bbbb2222", role: "viewer" }, + ], + }); + await settle(); + + const rows = el("app-link-devices").children; + assert.equal(rows.length, 3); + assert.match(rows[1].words(), /Watch wwww3333/); + assert.match(rows[1].words(), /Can look and charge/); + assert.match(rows[1].className, /app-device-watch/, "the watch is not set in under its phone"); + assert.doesNotMatch(rows[0].className, /app-device-watch/); + assert.deepEqual(rows[1].buttons().map((b) => b.textContent), ["Remove"]); + }); + + it("says a phone's watches go with it, and a watch goes alone", async () => { + const { el, confirmed } = load({ + devices: [ + { id: "aaaa1111", role: "owner", last_owner: true }, + { id: "wwww3333", role: "watch", parent: "aaaa1111" }, + ], + }); + await settle(); + + const rows = el("app-link-devices").children; + rows[1].buttons().find((b) => b.textContent === "Remove").click(); + await settle(); + assert.match(confirmed[0], /Remove this watch\?/); + assert.doesNotMatch(confirmed[0], /last phone/i, "removing a watch was called removing the last phone"); + + rows[0].buttons().find((b) => b.textContent === "Remove").click(); + await settle(); + assert.match(confirmed[1], /last phone/i, "a phone with a watch was not called the last phone"); + assert.match(confirmed[1], /Its watch goes with it/, "the warning hides that the watch goes too"); + }); + it("hides sharing until there is somebody to share from", async () => { // The first phone on a box is its owner whatever code it used, so an // invite offered before then would silently make an owner. diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js index 01a0a0cf8..eb0e95766 100644 --- a/web/settings/tabs/app.js +++ b/web/settings/tabs/app.js @@ -59,6 +59,7 @@ // What each role may do, in the words the row uses. The box is what // enforces these; this only names them. function roleText(role) { + if (role === "watch") return "Can look and charge"; return role === "viewer" ? "Can look" : "Can change things"; } @@ -70,17 +71,27 @@ // has to be honest about what is on the other side of the button, and about // what is left: a household with viewers still paired keeps its viewers, and // one down to a single phone is left with nothing paired at all. - function removalWarning(d, total) { + // + // A watch belongs to the phone that added it. Removing the watch touches + // nothing else; removing the phone removes its watches too, so the warning + // says so. + function removalWarning(d, phones, watches) { + if (d.parent) { + return "Remove this watch? It loses access immediately. Its phone can add it again."; + } + var also = watches > 0 + ? (watches === 1 ? " Its watch goes with it." : " Its " + watches + " watches go with it.") + : ""; if (!d.last_owner) { - return "Remove this phone? It loses access immediately and must scan a new code to come back."; + return "Remove this phone? It loses access immediately and must scan a new code to come back." + also; } - if (total === 1) { + if (phones === 1) { return "This is the last phone. Remove it and nothing can see or change this " + - "home until you pair a phone again, with a code from this page. Remove it?"; + "home until you pair a phone again, with a code from this page." + also + " Remove it?"; } return "This is the only phone that can change things. Remove it and the phones " + "left can look but change nothing, until you pair another owner with a code " + - "from this page. Remove it?"; + "from this page." + also + " Remove it?"; } // Reports a refusal the box gave, rather than swallowing it. Without a @@ -104,6 +115,9 @@ // Removing a guest and locking a phone out are the same button, because // they are the same thing: a guest's phone is a paired phone. Removal is // immediate — the box drops any live session too. + // + // A watch comes right after the phone that added it, set in under it. Its + // reach follows that phone, so it has no role button of its own. function refreshDevices() { var list = document.getElementById("app-link-devices"); if (!list) return; @@ -119,14 +133,18 @@ var shareButton = document.getElementById("app-link-share"); if (shareButton) shareButton.hidden = devices.length === 0; if (devices.length === 0) return; + var phones = devices.filter(function (d) { return !d.parent; }).length; + var watchesOf = function (id) { + return devices.filter(function (d) { return d.parent === id; }).length; + }; devices.forEach(function (d) { var row = document.createElement("div"); - row.className = "app-device-row"; + row.className = d.parent ? "app-device-row app-device-watch" : "app-device-row"; var name = document.createElement("span"); name.className = "mono"; - name.textContent = "Phone " + d.id; + name.textContent = (d.parent ? "Watch " : "Phone ") + d.id; row.appendChild(name); var what = document.createElement("span"); @@ -149,7 +167,7 @@ only.className = "hint"; only.textContent = "The only phone that can change things. Pair another owner to step this one down."; row.appendChild(only); - } else { + } else if (!d.parent) { var toRole = d.role === "viewer" ? "owner" : "viewer"; var change = document.createElement("button"); change.type = "button"; @@ -175,7 +193,7 @@ btn.type = "button"; btn.textContent = "Remove"; btn.addEventListener("click", function () { - if (!confirm(removalWarning(d, devices.length))) return; + if (!confirm(removalWarning(d, phones, watchesOf(d.id)))) return; btn.disabled = true; fetch("/api/app-link/devices/" + encodeURIComponent(d.id), { method: "DELETE" }) .then(function (r) { diff --git a/web/style.css b/web/style.css index 5f6d2f814..89ea96101 100644 --- a/web/style.css +++ b/web/style.css @@ -1764,6 +1764,10 @@ footer { .app-device-row button { flex: 0 0 auto; } +/* A watch sits under the phone that added it. */ +.app-device-watch { + padding-left: 16px; +} /* Who the next code is for, on one line and wrapping on a narrow screen. Only one code is ever live, so these belong together rather than scattered down the panel as if they were independent settings. How the code travels —