diff --git a/.changeset/watch-role-enrolment.md b/.changeset/watch-role-enrolment.md new file mode 100644 index 00000000..854f30b8 --- /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 d105d87b..b00c6a7c 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 0b7fbad4..c5b2cdba 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 895d4508..a72f1073 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 fbb5eb03..db771687 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 00000000..000d3c5a --- /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 de346212..1f900627 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 4d33238b..719b6a2b 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 0b968951..7e82aba6 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 00000000..1cbdb827 --- /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 d461ad87..37651f33 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 9763331d..a97ec97c 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 11576dee..540d1007 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 00000000..2aab4e80 --- /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 00000000..60b169f2 --- /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 d714e119..502d3d82 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 5b4ebb1f..24ac371c 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 138e92f8..7bff5881 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 317ea3e1..89886c78 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 00000000..1615ef25 --- /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 f36ee6c6..5f9c6c54 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 535ee727..69ee9bf6 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 00000000..de6ae5dd --- /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 ea6d3808..9eab9519 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 01a0a0cf..eb0e9576 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 5f6d2f81..89ea9610 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 —