Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/watch-role-enrolment.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions docs/adr/0009-watch-exchange.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
73 changes: 49 additions & 24 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -739,15 +745,25 @@ 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()
out := make([]api.AppDevice, 0, len(infos))
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
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion go/cmd/ftw/app_link_zone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions go/cmd/ftw/cloud_roster_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading