Skip to content
Open
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/owner-pairing-at-the-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

An owner pairing code can only be minted on the box itself, or after the house password is on. A viewer invite still works from the LAN.
3 changes: 3 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ sets a session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`)
never asks. Live status stays readable without the password; a viewer
caller is minted for those reads.

An owner pairing QR is minted only from loopback or with the house
password. A viewer invite still works from the LAN.

The FTW app and Home Assistant MQTT are unchanged.

Recovery: `curl` to `127.0.0.1`, or set `api.lan_auth: false` in
Expand Down
22 changes: 22 additions & 0 deletions go/internal/api/api_app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
if !s.appLinkRoleAllowed(w, r, req.Role) {
return
}
if !s.appLinkOwnerMintAllowed(w, r, req.Role) {
return
}

// A spoken code is minted at the box and nowhere else.
//
Expand Down Expand Up @@ -448,6 +451,25 @@ func (s *Server) appLinkRoleAllowed(w http.ResponseWriter, r *http.Request, aske
return true
}

// appLinkOwnerMintAllowed refuses an owner code on the open LAN.
//
// Presence on a private address is not enough. That is how a guest or a
// ZeroTier peer turns "I can reach :8080" into a Noise owner that works
// from anywhere. Loopback is the box itself. A house-password proof is
// the other door, and it only exists when api.lan_auth is on.
func (s *Server) appLinkOwnerMintAllowed(w http.ResponseWriter, r *http.Request, asked string) bool {
if asked != apiauth.RoleOwner || appLinkOverSession(r) || isLoopbackClient(r.RemoteAddr) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require owner proof for the first viewer pairing

When app-link has no enrolled devices, this exemption lets any LAN or ZeroTier peer mint a role:"viewer" QR without loopback or house-password proof. appenroll.Identity.Authorise then promotes that first viewer enrollment to owner (go/internal/appenroll/enroll.go:576-582), giving the peer the same durable owner access this change is intended to prevent. Require owner-level proof when AuthorisedCount() == 0, or remove the implicit first-device promotion.

Useful? React with 👍 / 👎.

return true
}
houseOK, checked := lanSecretFrom(r.Context())
if checked && houseOK {
return true
}
writeAppLinkError(w, http.StatusForbidden,
"making another owner is done on the box, or after the house password is on.")
return false
}

func writeAppLinkError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
Expand Down
13 changes: 8 additions & 5 deletions go/internal/api/api_app_link_sharing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ func TestAPairingRequestThatNamesNoRoleIsRefused(t *testing.T) {
}

// The same request with its role named still works, or the rule above
// would be indistinguishable from a broken endpoint.
// would be indistinguishable from a broken endpoint. Owner mint is
// loopback (or a house-password proof), not any LAN address.
w := httptest.NewRecorder()
s.handleAppLinkPairing(w, localRequest(
http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`))
atBox := localRequest(http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`)
atBox.RemoteAddr = "127.0.0.1:1234"
s.handleAppLinkPairing(w, atBox)
if w.Code != http.StatusOK {
t.Fatalf("naming the role got %d, want 200: %s", w.Code, w.Body.String())
}
Expand Down Expand Up @@ -158,8 +160,9 @@ func TestABoxCodeIsTextAndAQRCodeIsNot(t *testing.T) {
// appear in the field a screen would print as text. Decoded into a fresh
// value, because an absent field leaves whatever was there before.
w = httptest.NewRecorder()
s.handleAppLinkPairing(w, localRequest(
http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`))
atBox := localRequest(http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`)
atBox.RemoteAddr = "127.0.0.1:1234"
s.handleAppLinkPairing(w, atBox)
var scanned appLinkPairing
if err := json.Unmarshal(w.Body.Bytes(), &scanned); err != nil {
t.Fatalf("decoding the answer: %v", err)
Expand Down
38 changes: 36 additions & 2 deletions go/internal/api/api_app_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,12 @@ func (s *stubEnroller) AuthorisedCount() int { return 2 }
// none is refused now, because a default at this endpoint decides who owns a
// house.
func pairingRequest(host, remote string, headers map[string]string) *http.Request {
return pairingRequestRole(host, remote, headers, "owner")
}

func pairingRequestRole(host, remote string, headers map[string]string, role string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/app-link/pairing",
strings.NewReader(`{"role":"owner"}`))
strings.NewReader(`{"role":"`+role+`"}`))
r.Header.Set("Content-Type", "application/json")
r.Host = host
r.RemoteAddr = remote
Expand Down Expand Up @@ -141,13 +145,43 @@ func TestPairingIsLocalOnly(t *testing.T) {
}
}

func TestPairingFromTheLAN(t *testing.T) {
func TestViewerPairingFromTheLAN(t *testing.T) {
enroll := &stubEnroller{}
s := New(&Deps{AppEnroll: enroll})

w := httptest.NewRecorder()
s.handleAppLinkPairing(w, pairingRequestRole("192.168.1.1", "192.168.1.5:1234", nil, "viewer"))

if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
}
if enroll.minted != 1 {
t.Fatalf("minted %d codes, want 1", enroll.minted)
}
}

func TestOwnerPairingFromTheLANIsRefused(t *testing.T) {
enroll := &stubEnroller{}
s := New(&Deps{AppEnroll: enroll})

w := httptest.NewRecorder()
s.handleAppLinkPairing(w, pairingRequest("192.168.1.1", "192.168.1.5:1234", nil))

if w.Code != http.StatusForbidden {
t.Fatalf("got %d, want 403: %s", w.Code, w.Body.String())
}
if enroll.minted != 0 {
t.Fatalf("minted %d owner codes from the open LAN", enroll.minted)
}
}

func TestOwnerPairingFromLoopback(t *testing.T) {
enroll := &stubEnroller{}
s := New(&Deps{AppEnroll: enroll})

w := httptest.NewRecorder()
s.handleAppLinkPairing(w, pairingRequest("127.0.0.1", "127.0.0.1:1234", nil))

if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
}
Expand Down