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-exchange-through-cloud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

An Apple Watch can charge the car without its iPhone near. Its commands reach the box through FTW Cloud as short encrypted exchanges the box answers itself, and an owner's phone adds the watch by its key, with a passkey step-up. The box lists `cmd.exchange` while FTW Cloud carries them.
30 changes: 17 additions & 13 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -766,11 +766,11 @@ func startAppLink(
// the same whichever session reads it.
views := agentViews{site: &cloudSite{site: site, api: gateway, cars: carsRT}, cars: carsRT}

// handlerFor builds one handler for one session: a phone's stream, or a
// watch's exchange. They share the ports above, which are read-only
// apart from the mode and the command log, and the mode goes through
// control's own validation.
handlerFor := func() appuplink.HandlerBuilder {
// handlerFor builds one handler for one session: a phone's stream, or
// with exchange set a watch's exchange. They share the ports above, which
// are read-only apart from the mode and the command log, and the mode
// goes through control's own validation.
handlerFor := func(exchange bool) appuplink.HandlerBuilder {
return func(
sender appproto.Sender,
caller apiauth.Caller,
Expand All @@ -793,6 +793,7 @@ func startAppLink(
Caller: caller,
Grants: grants,
Commands: cmds,
Exchange: exchange,
Access: agents.access,
Agents: agents.state,
Audit: agents.audit,
Expand All @@ -818,16 +819,17 @@ func startAppLink(
}

uplink, err := appuplink.New(appuplink.Options{
Enroll: enroll,
Handler: handlerFor(),
Arrivals: arrivalsFor(ctx, tel),
Enroll: enroll,
Handler: handlerFor(false),
ExchangeHandler: handlerFor(true),
Arrivals: arrivalsFor(ctx, tel),
})
if err != nil {
return enroll, nil, enabled, err
}
agents.access.Drop = func(key []byte) { uplink.DropSessionsByAppKey(key) }
agents.boxPage = func(by string) (*appproto.Handler, error) {
return handlerFor()(discardSender{}, boxPageCaller(by), boxPageGrant{})
return handlerFor(false)(discardSender{}, boxPageCaller(by), boxPageGrant{})
}
go sweepAgents(ctx, agents)

Expand Down Expand Up @@ -940,10 +942,10 @@ func (a *appLinkAPI) MintPairingCode(role string) ([]byte, time.Time, error) {
return code, expires, appLinkError(err)
}

// MintWatchCode issues a watch code bound to the owner phone that asked.
func (a *appLinkAPI) MintWatchCode(parentID string) ([]byte, time.Time, error) {
code, expires, err := a.enroll.MintWatchCode(parentID, appenroll.WatchTTL)
return code, expires, appLinkError(err)
// AddWatch adds a watch by its key under the owner phone that asked.
func (a *appLinkAPI) AddWatch(parentID string, key []byte) (string, error) {
grant, err := a.enroll.AddWatch(parentID, key)
return grant.DeviceID, appLinkError(err)
}

func (a *appLinkAPI) MintSpokenCode(role string) (string, time.Time, error) {
Expand Down Expand Up @@ -1068,6 +1070,8 @@ func appLinkError(err error) error {
return api.ErrWatchAppRole
case errors.Is(err, appenroll.ErrWatchNeedsOwner):
return api.ErrWatchNeedsOwner
case errors.Is(err, appenroll.ErrWatchKey):
return api.ErrWatchAppKey
default:
return err
}
Expand Down
16 changes: 14 additions & 2 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,13 @@ func (n *cloudNotices) notify(kind string) {
// enrolment, which holds the phones the roster lists; without either it
// returns nil and the routes say FTW Cloud is unavailable. tunnel is the app
// uplink's session table, nil when the app link is off, and then the box
// takes no tun and links only for a grant.
// takes no tun and links only for a grant. exchange is the watches' door, nil
// with the app link off; it rides beside the tunnel.
func startCloudLink(
ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity,
tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision,
siteMeterStale time.Duration, measurementOptions func() telemetry.ForecastOptions, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices,
tunnel *appuplink.Tunnel, carsRT *cars.Runtime, webPush *notifications.WebPush,
tunnel *appuplink.Tunnel, exchange *appuplink.Exchange, carsRT *cars.Runtime, webPush *notifications.WebPush,
) *cloudlink.Link {
if st == nil || enroll == nil {
return nil
Expand Down Expand Up @@ -430,6 +431,9 @@ func startCloudLink(
if tunnel != nil {
opts.Tunnel = tunnel
}
if exchange != nil {
opts.Exchange = exchange
}
link, err := cloudlink.New(opts)
if err != nil {
slog.Warn("FTW Cloud unavailable", "err", err)
Expand All @@ -438,6 +442,9 @@ func startCloudLink(
if tunnel != nil {
tunnel.Attach(link)
}
if exchange != nil {
exchange.Attach(link)
}
enroll.OnChange(link.RosterChanged)
// A code shown links the box at once, so a QR that names the home can.
enroll.OnPairingCode(link.RosterChanged)
Expand Down Expand Up @@ -481,6 +488,11 @@ func (c *cloudSession) caps(caps []string) []string {
if link.TakesTun() {
caps = append(caps, appproto.CapCloudTun)
}
// A watch reaches the box through FTW Cloud's exchange, so the phone
// offers to add one only while the link carries it.
if link.TakesX() {
caps = append(caps, appproto.CapCmdExchange)
}
return caps
}

Expand Down
6 changes: 1 addition & 5 deletions go/cmd/ftw/cloud_roster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,7 @@ func TestTheRosterNarrowsAWatchToWhatItsPhoneHolds(t *testing.T) {
}
rosterPhone(t, id, apiauth.RoleOwner)
phone := rosterPhone(t, id, apiauth.RoleOwner)
code, _, err := id.MintWatchCode(phone.DeviceID, appenroll.WatchTTL)
if err != nil {
t.Fatal(err)
}
watch, err := id.AuthoriseWatch(rosterKey(t), code)
watch, err := id.AddWatch(phone.DeviceID, rosterKey(t))
if err != nil {
t.Fatal(err)
}
Expand Down
4 changes: 3 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2473,11 +2473,13 @@ func main() {
// signed, readable copy of the house, off until an owner turns it on.
// Both use the one FTW Cloud connection.
var appTunnel *appuplink.Tunnel
var appExchange *appuplink.Exchange
if appUplink != nil {
appTunnel = appUplink.Tunnel()
appExchange = appUplink.Exchange()
}
cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll,
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, liveMeasurementOptions, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush)
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, liveMeasurementOptions, priceSvc, appAPI, cloudNotify, appTunnel, appExchange, carsRT, webPush)
appCloud.bind(cloudLink)
if cloudLink != nil {
agents.home = cloudLink.Home
Expand Down
74 changes: 44 additions & 30 deletions go/internal/api/api_app_link.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"encoding/base64"
"errors"
"net"
"net/http"
Expand Down Expand Up @@ -39,11 +40,11 @@ type AppEnroller interface {
// MintSpokenCode issues a box code: eight characters somebody can read
// down a phone. Same single-use, same replacement of any live code.
MintSpokenCode(role string) (string, time.Time, error)
// MintWatchCode issues a code that lets one watch in, bound to the owner
// phone named by parentID. It has a slot of its own, so it leaves a
// pending phone code alone. Returns ErrWatchNeedsOwner when that phone is
// not an owner now.
MintWatchCode(parentID string) ([]byte, time.Time, error)
// AddWatch stores a watch's Noise key under the owner phone named by
// parentID, and returns the watch's device id. Returns
// ErrWatchNeedsOwner when that phone is not an owner now, and
// ErrWatchAppKey for a key that cannot be a watch's.
AddWatch(parentID string, key []byte) (string, error)
// EnrollmentURL is what goes in the QR.
EnrollmentURL(code []byte, lanHint string) (string, error)
// AuthorisedCount is how many phones may currently connect. Watches
Expand Down Expand Up @@ -97,9 +98,11 @@ var (
// ErrWatchAppRole is the watch role asked for where it cannot be given:
// a phone's code, or a role change to or from watch.
ErrWatchAppRole = errors.New("api: a watch comes only from its phone")
// ErrWatchNeedsOwner is a watch code asked for by a phone that is not an
// owner.
// ErrWatchNeedsOwner is a watch added by a phone that is not an owner.
ErrWatchNeedsOwner = errors.New("api: only an owner phone can add a watch")
// ErrWatchAppKey is a watch key the box cannot add: not 32 bytes, or a
// key it knows as something else.
ErrWatchAppKey = errors.New("api: that is not a watch's key")
)

type appLinkStatus struct {
Expand All @@ -126,8 +129,11 @@ type appLinkPairing struct {
// gets read to the wrong person.
Role string `json:"role"`
// ExpiresAtMs is when the code stops working, so the UI can say so rather
// than leaving a stale square on screen.
// than leaving a stale square on screen. Zero for a watch added by its
// key, which needs no code.
ExpiresAtMs int64 `json:"expires_at_ms"`
// DeviceID is the watch a key added, by the id the box lists it under.
DeviceID string `json:"device_id,omitempty"`
}

// appLinkPairingRequest is what a page or an app asks for.
Expand All @@ -142,6 +148,9 @@ type appLinkPairingRequest struct {
// Kind is "qr", "spoken", or "claim" for a code entered on the LAN.
Kind string `json:"kind"`
Code string `json:"code,omitempty"`
// Key is a watch's Noise static key, base64url without padding. With
// role watch it adds that watch at once, with no code.
Key string `json:"key,omitempty"`
}

// appLinkRoleRequest changes what one paired phone may do.
Expand Down Expand Up @@ -249,20 +258,19 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
// appPairingKindSpoken asks for a code somebody can read aloud.
const appPairingKindSpoken = "spoken"

// handleWatchPairing mints a watch code for the owner phone asking (ADR 0009,
// decision 4).
// handleWatchPairing adds a watch by its key for the owner phone asking (ADR
// 0009, decision 4, "Through FTW Cloud").
//
// Three rules, each refused rather than bent. The request comes over a
// session, because the code is bound to the phone that asks and the LAN has
// session, because the watch is bound to the phone that asks and the LAN has
// no phone to bind it to. The phone is an owner now, because a watch never
// holds more than its phone and only an owner adds a device. And this very
// request carried a passkey step-up: the grace window the passthrough keeps
// for settings screens does not stand in for one, since a watch is a new key
// that can charge the car from anywhere.
//
// The answer is a QR payload like any other code's, with the box's static key,
// the rendezvous secret and the code. The phone hands it to its watch; nothing
// in it is new to the phone, and none of it passes a Sourceful service.
// A request without a key is refused: a watch code had no way in once the
// relay stopped, because FTW Cloud admits only what the roster lists.
func (s *Server) handleWatchPairing(w http.ResponseWriter, r *http.Request, req appLinkPairingRequest) {
caller, named := apiauth.FromRequest(r)
if !named || caller.Kind != apiauth.KindApp {
Expand All @@ -280,35 +288,41 @@ func (s *Server) handleWatchPairing(w http.ResponseWriter, r *http.Request, req
"confirm with your passkey to add a watch.")
return
}
if req.Kind == appPairingKindSpoken {
if req.Key == "" {
writeAppLinkError(w, http.StatusBadRequest,
"a watch code goes from the phone to the watch, never read aloud.")
"update the FTW app to add this watch: it is added by its key.")
return
}
s.addWatchByKey(w, r, req.Key)
}

code, expiresAt, err := s.deps.AppEnroll.MintWatchCode(callerDeviceID(r))
// addWatchByKey adds the watch whose key the phone names, under the same
// three rules as a watch code (handleWatchPairing has checked them). The
// watch reaches the box through FTW Cloud, which admits only what the roster
// lists, so a code it would have to carry through the service has no place:
// the phone's own session vouches for the key, and the watch proves it holds
// the key in every handshake after.
func (s *Server) addWatchByKey(w http.ResponseWriter, r *http.Request, encoded string) {
key, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
writeAppLinkError(w, http.StatusBadRequest, "that is not a watch's key.")
return
}
id, err := s.deps.AppEnroll.AddWatch(callerDeviceID(r), key)
switch {
case err == nil:
case errors.Is(err, ErrWatchNeedsOwner), errors.Is(err, ErrUnknownAppDevice):
writeAppLinkError(w, http.StatusForbidden,
"only an owner's phone can add a watch.")
return
default:
writeAppLinkError(w, http.StatusInternalServerError, "could not mint a watch code")
case errors.Is(err, ErrWatchAppKey):
writeAppLinkError(w, http.StatusBadRequest, "that is not a watch's key.")
return
}
// No LAN hint: the watch picks its own network and reaches the box
// through the relay's exchange.
url, err := s.deps.AppEnroll.EnrollmentURL(code, "")
if err != nil {
writeAppLinkError(w, http.StatusInternalServerError, "could not build the watch code")
default:
writeAppLinkError(w, http.StatusInternalServerError, "could not add the watch")
return
}
writeJSON(w, http.StatusOK, appLinkPairing{
URL: url,
Role: apiauth.RoleWatch,
ExpiresAtMs: expiresAt.UnixMilli(),
})
writeJSON(w, http.StatusOK, appLinkPairing{Role: apiauth.RoleWatch, DeviceID: id})
}

func (s *Server) writeAppLinkMintError(w http.ResponseWriter, err error) {
Expand Down
19 changes: 10 additions & 9 deletions go/internal/api/api_app_link_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ func (e *realEnroller) MintSpokenCode(role string) (string, time.Time, error) {
return code, expires, enrollError(err)
}

func (e *realEnroller) MintWatchCode(parentID string) ([]byte, time.Time, error) {
code, expires, err := e.id.MintWatchCode(parentID, appenroll.WatchTTL)
return code, expires, enrollError(err)
func (e *realEnroller) AddWatch(parentID string, key []byte) (string, error) {
grant, err := e.id.AddWatch(parentID, key)
return grant.DeviceID, enrollError(err)
}

func (e *realEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) {
Expand Down Expand Up @@ -101,6 +101,8 @@ func enrollError(err error) error {
return ErrWatchAppRole
case errors.Is(err, appenroll.ErrWatchNeedsOwner):
return ErrWatchNeedsOwner
case errors.Is(err, appenroll.ErrWatchKey):
return ErrWatchAppKey
default:
return err
}
Expand Down Expand Up @@ -495,10 +497,10 @@ func TestNoDoorButItsPhoneHandsOutAWatch(t *testing.T) {
name string
serve func(http.ResponseWriter)
}{
{"a scanned watch code from the LAN", func(w http.ResponseWriter) {
{"a watch from the LAN", func(w http.ResponseWriter) {
s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch"}`))
}},
{"a spoken watch code from the LAN", func(w http.ResponseWriter) {
{"a spoken watch from the LAN", func(w http.ResponseWriter) {
s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch","kind":"spoken"}`))
}},
{"the guest changed into a watch from the LAN", func(w http.ResponseWriter) {
Expand All @@ -514,12 +516,11 @@ func TestNoDoorButItsPhoneHandsOutAWatch(t *testing.T) {
}
}

// Over a session, from a phone this box does not know as an owner.
rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
status, refusal, body := rig.call(t, 1, appproto.APIReq{
Method: appproto.APIPost, Path: "/api/app-link/pairing",
Body: []byte(`{"role":"watch"}`), StepUp: true})
status, refusal, body := rig.call(t, 1, watchByKey(appKey(9), true))
if status != http.StatusForbidden {
t.Fatalf("a watch code over the session answered %d %q %s, want 403", status, refusal, body)
t.Fatalf("a watch over the session answered %d %q %s, want 403", status, refusal, body)
}

for _, d := range enrol.Devices() {
Expand Down
8 changes: 4 additions & 4 deletions go/internal/api/api_app_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type stubEnroller struct {
spoken int
watch int
revoked int
// watchParent is the phone the last watch code was bound to.
// watchParent is the phone the last watch was added under.
watchParent string
mintedRole string
roleSet string
Expand Down Expand Up @@ -69,13 +69,13 @@ func (s *stubEnroller) MintSpokenCode(role string) (string, time.Time, error) {
return "ABCD-EFGH", time.Now().Add(5 * time.Minute), nil
}

func (s *stubEnroller) MintWatchCode(parentID string) ([]byte, time.Time, error) {
func (s *stubEnroller) AddWatch(parentID string, key []byte) (string, error) {
if s.err != nil {
return nil, time.Time{}, s.err
return "", s.err
}
s.watch++
s.watchParent = parentID
return make([]byte, 16), time.Now().Add(10 * time.Minute), nil
return "wAtCh123", nil
}

func (s *stubEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) {
Expand Down
Loading
Loading