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/iphone-pairing-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Pair an iPhone from the same phone's browser or with a local pairing code. Show a restart action beside the app connection setting and keep restart progress in view.
31 changes: 31 additions & 0 deletions docs/pairing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Pair with one iPhone

On the home Wi-Fi, open the box's dashboard in Safari and choose
**Settings → FTW app**. Turn on **Let the FTW app connect to this box** if
needed. Choose **Save and restart FTW** and wait for the page to return.
FTW pauses energy control while it restarts.

Choose one way to connect:

- **Show pairing code → Open in FTW on this iPhone.** The app shows which
box the link names before you connect it. If Safari keeps the link in the
browser, copy the pairing link and paste it under **Enter pairing code**
in FTW.
- **Use a pairing code.** In FTW, choose **Enter pairing code**. Enter the
eight characters and the box address shown beside them. **Copy pairing
code and address** copies both for the app's Paste button. If the box has
a house password, enter it when the app asks.
- Take a screenshot of the QR and choose **Choose a picture of the QR**
in FTW. The app reads the code from that image.

A short code works once for five minutes. Five wrong tries cancel it.
Making a new code cancels the previous code. The QR and link keep their
existing expiry and single-use checks.

The short-code exchange runs directly against the box's local, protected
`POST /api/app-link/pairing` with `{"kind":"claim","code":"…"}`. It keeps
the code's role and expiry and replaces it with a full enrollment token.
The response carries the same v3 link as the QR. No short-code hash or box
secret goes through a cloud lookup. Remote clients, proxies and app sessions
cannot use this exchange. The existing house-password and browser-origin
checks still apply.
8 changes: 8 additions & 0 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,14 @@ each time it receives such a roster. With no id, there is no copy.
Only a QR code is listed. A spoken code has 40 bits, and its hash would let
the service find it offline; it pairs over the relay or the home network.

A new iPhone may exchange a short code directly with the box on the home
network through `POST /api/app-link/pairing` with `kind: "claim"`. The box
replaces that code with a full 128-bit enrollment token, keeping its role and
expiry, and returns the same v3 link as the QR. Only the full token's hash
enters the roster above. The exchange keeps the pairing route's local-address,
house-password and browser-origin checks and refuses app sessions. See
[Pair with one iPhone](pairing.md) for the steps.

**`stats`**

Signed box to service only. Never forwarded to phones or agents. The box
Expand Down
21 changes: 21 additions & 0 deletions go/cmd/ftw/app_pairing_claim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import (
"errors"
"time"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/appenroll"
)

func (a *appLinkAPI) RedeemPairingCode(typed, lanHint string) (string, time.Time, error) {
code, expires, err := a.enroll.RedeemSpokenCode(typed)
if errors.Is(err, appenroll.ErrBadPairing) {
return "", time.Time{}, api.ErrBadAppPairingCode
}
if err != nil {
return "", time.Time{}, err
}
url, err := a.enroll.EnrollmentURL(code, lanHint)
return url, expires, err
}
71 changes: 71 additions & 0 deletions go/cmd/ftw/app_pairing_claim_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
)

func TestPairingCodeHTTPReturnsTheRealBoxsV3Payload(t *testing.T) {
id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "box.key"))
if err != nil {
t.Fatal(err)
}
id.SetHome(func() string { return "abcdefghijklmnopqrstuvwx23" })
code, _, err := id.MintSpokenCode(apiauth.RoleViewer)
if err != nil {
t.Fatal(err)
}
s := httptest.NewServer(api.New(&api.Deps{AppEnroll: &appLinkAPI{enroll: id}}).Handler())
defer s.Close()
claim := func() *http.Response {
t.Helper()
body, _ := json.Marshal(map[string]string{"kind": "claim", "code": code, "role": "owner"})
response, err := http.Post(s.URL+"/api/app-link/pairing", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
return response
}
response := claim()
defer response.Body.Close()
if response.StatusCode != 200 || response.Header.Get("Cache-Control") != "no-store" {
t.Fatal("local claim failed or was cacheable")
}
var reply struct {
URL string `json:"url"`
}
if err := json.NewDecoder(response.Body).Decode(&reply); err != nil {
t.Fatal(err)
}
link, err := url.Parse(reply.URL)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(link.Fragment, ".")
if link.Scheme != "https" || link.Host != "app.ftw.energy" || link.Path != "/p" || len(parts) != 6 || parts[0] != "v3" {
t.Fatal("claim did not return the unchanged v3 format")
}
enc := base64.RawURLEncoding.EncodeToString
token, _, pending := id.PendingCode()
if !pending || parts[1] != enc(id.StaticKey().Public()) || parts[2] != enc(token) || parts[4] != enc(id.RendezvousSecret()) || parts[5] != enc([]byte("abcdefghijklmnopqrstuvwx23")) {
t.Fatal("payload did not name this box and its full enrollment token")
}
if id.AuthorisedCount() != 0 {
t.Fatal("claim bypassed the Noise enrollment")
}
again := claim()
defer again.Body.Close()
if again.StatusCode != 400 {
t.Fatal("short code could be claimed twice")
}
}
16 changes: 10 additions & 6 deletions go/internal/api/api_app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (
// Pairing a phone with this box.
//
// The box mints a single-use code and shows the payload as a QR. The app scans
// it, and the box's static key travels optically — never through Sourceful.
// it, or receives it directly from the box by link, paste or local code
// exchange. The box's static key never passes through Sourceful.
// That is what lets a hostile or compelled relay deny service without being
// able to impersonate a box.
//
Expand Down Expand Up @@ -138,8 +139,9 @@ type appLinkPairingRequest struct {
// Role is "owner" or "viewer". Validated by the enroller against the
// registry, never against a list written here.
Role string `json:"role"`
// Kind is "qr" or "spoken".
// Kind is "qr", "spoken", or "claim" for a code entered on the LAN.
Kind string `json:"kind"`
Code string `json:"code,omitempty"`
}

// appLinkRoleRequest changes what one paired phone may do.
Expand Down Expand Up @@ -177,6 +179,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.Kind == "claim" {
s.handlePairingClaim(w, r, req.Code)
return
}
if req.Role == apiauth.RoleWatch {
s.handleWatchPairing(w, r, req)
return
Expand All @@ -196,10 +202,8 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
// needs a person in the room. A code mintable from a phone anywhere in the
// world takes that argument away.
//
// It would also buy nothing. A typed code carries the pairing code alone —
// not the box's static key, not the rendezvous secret — so it re-admits a
// phone that already holds those and cannot let in a guest's phone, which
// has never seen this box. There is no second, weaker way to mint one.
// A new phone exchanges this code locally for the full enrollment payload.
// A phone that already knows the box can also use it in a Noise handshake.
if req.Kind == appPairingKindSpoken {
if appLinkOverSession(r) {
writeAppLinkError(w, http.StatusForbidden,
Expand Down
39 changes: 39 additions & 0 deletions go/internal/api/api_pairing_claim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api

import (
"errors"
"net/http"
"time"
)

var ErrBadAppPairingCode = errors.New("api: pairing code expired, used or incorrect")

type appPairingRedeemer interface {
RedeemPairingCode(code, lanHint string) (string, time.Time, error)
}

// The existing pairing route supplies the local-address, JSON, browser-origin
// and house-password checks. A claim never runs through an app session and
// cannot choose a role: the box keeps the role of the code it issued.
func (s *Server) handlePairingClaim(w http.ResponseWriter, r *http.Request, code string) {
if appLinkOverSession(r) {
writeAppLinkError(w, http.StatusForbidden, "enter a pairing code while on your home network")
return
}
redeemer, ok := s.deps.AppEnroll.(appPairingRedeemer)
if !ok {
writeAppLinkError(w, http.StatusServiceUnavailable, "update the box to pair with a typed code")
return
}
url, expires, err := redeemer.RedeemPairingCode(code, s.appLinkLANHint(r))
if errors.Is(err, ErrBadAppPairingCode) {
writeAppLinkError(w, http.StatusBadRequest, "that code has expired, was used or is incorrect; make a new code in Settings → FTW app")
return
}
if err != nil {
writeAppLinkError(w, http.StatusInternalServerError, "could not read the pairing code; make a new one and try again")
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, appLinkPairing{URL: url, ExpiresAtMs: expires.UnixMilli()})
}
88 changes: 88 additions & 0 deletions go/internal/api/api_pairing_claim_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package api

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/apiauth"
)

type claimEnroller struct {
stubEnroller
claimed string
}

func (e *claimEnroller) RedeemPairingCode(code, hint string) (string, time.Time, error) {
e.claimed = code
if code != "K7M2-9QRT" {
return "", time.Time{}, ErrBadAppPairingCode
}
return "https://app.ftw.energy/p#v3.full-payload", time.Now().Add(time.Minute), nil
}

func claimRequest() *http.Request {
r := httptest.NewRequest("POST", "http://ftw.local:8080/api/app-link/pairing", strings.NewReader(`{"kind":"claim","code":"K7M2-9QRT","role":"owner"}`))
r.RemoteAddr = "192.168.1.42:1234"
r.Header.Set("Content-Type", "application/json")
return r
}

func TestLocalPairingClaimKeepsThePairingSecurityChecks(t *testing.T) {
for _, tc := range []struct {
name string
change func(*http.Request)
want int
}{
{"local", func(r *http.Request) {}, 200},
{"public host", func(r *http.Request) { r.Host = "outside.example" }, 403},
{"remote peer", func(r *http.Request) { r.RemoteAddr = "203.0.113.1:1" }, 403},
{"proxy", func(r *http.Request) { r.Header.Set("Forwarded", "for=203.0.113.1") }, 403},
{"cross site", func(r *http.Request) { r.Header.Set("Origin", "https://outside.example") }, 403},
{"app session", func(r *http.Request) {
*r = *r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{Kind: apiauth.KindApp, Role: apiauth.RoleOwner, Scopes: apiauth.EveryScope()}))
}, 403},
} {
t.Run(tc.name, func(t *testing.T) {
enroll := &claimEnroller{}
s := New(&Deps{AppEnroll: enroll})
r := claimRequest()
tc.change(r)
w := httptest.NewRecorder()
Authenticate(http.HandlerFunc(s.handleAppLinkPairing), MutationPolicy{}).ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if tc.want != 200 && enroll.claimed != "" {
t.Fatal("refused request spent the code")
}
if enroll.minted != 0 {
t.Fatal("claim minted the requested role")
}
})
}
}

func TestLocalPairingClaimStillNeedsTheHousePassword(t *testing.T) {
enroll := &claimEnroller{}
s := New(&Deps{AppEnroll: enroll})
policy := MutationPolicy{LANAuthEnabled: func() bool { return true }, VerifyLANSecret: func(secret string) bool { return secret == "house-secret" }}
h := Authenticate(http.HandlerFunc(s.handleAppLinkPairing), policy)
for _, token := range []string{"", "house-secret"} {
r := claimRequest()
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
want := 401
if token != "" {
want = 200
}
if w.Code != want {
t.Fatalf("status %d, want %d: %s", w.Code, want, w.Body.String())
}
}
}
15 changes: 4 additions & 11 deletions go/internal/appenroll/boxcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,10 @@ package appenroll
// box, reading eight characters down the phone. Everything here exists to make
// those eight characters safe to say out loud.
//
// It is redeemed exactly where the QR code's payload is redeemed — inside
// Noise handshake message 1 — so there is no new endpoint, no new carrier and
// no HTTP path. The app decodes the characters back to five bytes and sends
// those. The wire does not change at all.
//
// What it is not, and the box's own page says so on the screen: it is not a
// way in for a phone that has never seen this box. These characters are the
// pairing code and nothing else. The box's static key and its rendezvous
// secret travel only in the QR payload, so a phone with no record of this box
// can neither find it nor be sure the box answering is the right one. This
// re-admits a phone that already holds those.
// A phone that knows the box may send its five decoded bytes in Noise
// handshake message 1. A new phone uses RedeemSpokenCode through the box's
// protected local HTTP API to obtain the same payload a QR carries. The
// trust anchor passes directly from the box to the phone on the home network.
//
// What it proves is not that the phone is trusted. It is that somebody was
// standing at the box: the code is minted only through the LAN-only pairing
Expand Down
39 changes: 39 additions & 0 deletions go/internal/appenroll/pairing_claim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package appenroll

import (
"crypto/subtle"
"fmt"
"time"
)

// RedeemSpokenCode exchanges a local setup code for the full enrollment
// payload's token. It keeps the role and expiry, and replaces the short code
// atomically. The caller must use the box's protected, local HTTP API.
func (i *Identity) RedeemSpokenCode(typed string) ([]byte, time.Time, error) {
code, err := DecodeSpokenCode(typed)
i.mu.Lock()
pending := i.pairing
if pending == nil || pending.kind != PairingKindSpoken || !i.now().Before(pending.expiresAt) {
i.mu.Unlock()
return nil, time.Time{}, ErrBadPairing
}
if err != nil || subtle.ConstantTimeCompare(code, pending.code) != 1 {
pending.attempts++
if pending.attempts >= MaxPairingAttempts {
i.pairing = nil
}
i.mu.Unlock()
return nil, time.Time{}, ErrBadPairing
}

token := make([]byte, PairingCodeBytes)
if _, err := i.randRead(token); err != nil {
i.mu.Unlock()
return nil, time.Time{}, fmt.Errorf("appenroll: creating enrollment token: %w", err)
}
expires := pending.expiresAt
i.pairing = &pairingCode{code: token, expiresAt: expires, role: pending.role, kind: PairingKindQR}
i.mu.Unlock()
i.minted()
return append([]byte(nil), token...), expires, nil
}
Loading
Loading