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
6 changes: 4 additions & 2 deletions .changeset/app-uplink-and-enrollment.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
"ftw": minor
---

The box now speaks the FTW app's protocol for real. It mints its own Noise static key, a long-lived rendezvous secret and single-use pairing codes, prints them as the QR payload the app parses, and holds an outbound connection to the blind relay under a handle that changes every hour — derived from the secret, so the relay cannot follow a household from one epoch to the next. Up to four phones share the uplink; each gets its own encrypted session, and the box tells them apart by which one's key authenticates a frame, because the relay cannot say. A phone is let in by a pairing code once and by its own key thereafter, so reconnecting never needs a new code and a photographed code cannot pair a second device.
The box now speaks the FTW app's protocol for real. It mints its own Noise static key, a long-lived rendezvous secret and single-use pairing codes, prints them as the QR payload the app parses, and holds an outbound connection to the content-blind relay under a handle that changes every hour. The rotating handle removes a stable protocol ID; it does not hide the source IP, timing or connection pattern, which the relay can use to correlate a household. Up to four phones share the uplink; each gets its own encrypted session, and the box tells them apart by which one's key authenticates a frame, because the relay cannot say. A phone is let in by a pairing code once and by its own key thereafter, so reconnecting never needs a new code and a photographed code cannot pair a second device.

Turn it on with `app_link.enabled: true`.
The app link is on when an existing config omits the `app_link` section, so
upgraded stable boxes join the supported relay without a manual YAML edit.
`app_link.enabled: false` remains an explicit opt-out.

`controlRev` now means something: it is a fingerprint of the site's controllable state, so it moves when the mode or a target does and holds still through the per-tick churn. A command that expected an older revision is refused, and a session that falls behind is resynced with a fresh snapshot rather than left refusing every command until it reconnects. Site mode changes from every door now go through one function, so none can arrive having set the mode without dropping the manual hold and resetting the PI integrator.
7 changes: 4 additions & 3 deletions .changeset/remove-home-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ so no server ever sees it, and afterwards the phone reconnects on its own key
a photographed code cannot pair a second phone. You can remove one paired phone
at a time from the same tab; removal drops its live session at once. The relay
holds no keys and cannot read a frame, and the name the box joins under changes
every hour, so the relay operator cannot tell which household is online or
follow one across the day. Nothing about local control, setup, history or
fallback planning changes.
every hour, so the protocol carries no stable household ID. The relay still
sees source IP, timing and connection continuity, which can correlate a
household across those rotations. Nothing about local control, setup, history
or fallback planning changes.

An existing `home_link:` block in your `config.yaml` is ignored — the box boots
without you editing anything. Home Link's rows in `state.db` are left where
Expand Down
11 changes: 6 additions & 5 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,13 @@ state:
path: state.db
# backup_dir: backups # use an absolute mounted path for off-device backups

# The FTW app. Opt-in, and one switch: the box holds an outbound connection to
# a relay that carries encrypted frames and holds no keys, so there is no
# endpoint to choose and no transport to pick. Pair a phone by scanning the
# box's code; the pairing code is single-use and expires in ten minutes.
# The FTW app. On by default: the box holds an outbound connection to a relay
# that carries encrypted frames and holds no keys. The relay still sees the
# source IP, timing and connection pattern. Set enabled to false to opt out.
# An empty app_link object is also off. Pair a phone by scanning the box's code;
# the pairing code is single-use and expires in ten minutes.
app_link:
enabled: false
enabled: true

# Anonymous fleet ping. Once a day, at a time that moves, the box tells the
# FTW relay six things: its FTW version, its release channel, which driver
Expand Down
38 changes: 20 additions & 18 deletions docs/adr/0006-app-uplink.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,17 @@ two pairing stories and two things to get right at 1 Hz, for one product.
commands, not reading.

3. **The relay is `wss://relay.ftw.energy` and holds no keys.** It forwards
encrypted frames and cannot read or attribute them. Up to four sessions
share one uplink; the relay broadcasts, and the box lets the AEAD decide
which session a frame came from, because asking the relay would mean the
relay had to know.
encrypted frames and cannot read their contents or attribute them from a
protocol identity. It still sees source IPs, timing and connection
continuity. Up to four sessions share one uplink; the relay broadcasts, and
the box lets the AEAD decide which session a frame came from, because asking
the relay would mean the relay had to know.

4. **The box's name on the relay rotates hourly.** The join handle is derived
per epoch from the rendezvous secret with HKDF-SHA256, so the relay operator
cannot follow one household from hour to hour. There is no DNS alias, no
three-word host, and nothing stable for the relay to key a household on.
per epoch from the rendezvous secret with HKDF-SHA256. This removes the
stable protocol handle and DNS alias. It does not stop the relay operator
from correlating a continuous or promptly reconnected socket through its IP
and timing metadata.

5. **Authority is unchanged, which is the point.** A command carries an expiry
and preconditions; core revalidates against fresh state before acting. Site
Expand All @@ -86,12 +88,11 @@ Home Link working:
- **`GET /api/home-link/status`, `POST /api/home-link/pairing` and
`POST /api/home-link/passkeys/revoke` are gone**, along with the LAN UI's
Remote tab.
- **There is no pairing surface on the box's own UI yet.** The QR payload is
minted at boot but nothing renders it. Until that lands, a site that turns on
`app_link` cannot pair a phone from the local UI.
- **Revocation is coarser.** Home Link could revoke one passkey and leave the
others. Today the box can rotate the rendezvous secret, which moves every
paired phone at once. Per-device revocation is owed.
- **Pairing moves to the app tab.** The local UI mints a short-lived owner code
for the first phone and viewer codes for sharing. It lists paired phones and
can remove one without moving the others.
- **The last owner cannot remove or demote itself.** A second owner must exist
first, so a remote role change cannot leave the home without an owner.
- **WebAuthn as an on-box capability is gone**, including the signature-counter
clone check and the emergency fail-closed markers beside `state.db`. If a
future feature needs a local relying party, it starts from nothing.
Expand Down Expand Up @@ -122,8 +123,9 @@ Home Link working:
this. A box with `app_link.enabled: false` behaves exactly as before.
- One remote path means one thing to threat-model, one pairing story and one
place freshness can be got wrong.
- The relay is now a dumb frame forwarder. It can deny service and it can count
connections per handle for an hour. It cannot identify a household, read a
reading, or name a device.
- Per-device revocation and an on-box pairing surface are open work, and the
app uplink is not finished until they land.
- The relay is now a frame forwarder. It can deny service, count connections
per handle for an hour and observe IP, timing and connection metadata. It
cannot read a reading, name a device from the protocol or decrypt a frame.
- The app link defaults on when an existing config omits the section. An
explicit `app_link.enabled: false` keeps the outbound connection off. An
empty `app_link: {}` section also reads as off.
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ The properties that matter:
and later ones by the app's pinned static key;
- the relay forwards encrypted frames and holds no keys. The handle the box
joins under is derived per epoch from the rendezvous secret, so it rotates
hourly and gives the relay operator nothing stable to key a household on;
hourly and leaves no stable household ID in the protocol. The relay still
sees source IP, timing and connection continuity, which can correlate a
household across handle rotations;
- the machine identity in [`go/internal/gatewayidentity`](../go/internal/gatewayidentity)
is separate and does not authenticate this connection. It resolves to a
hardware-protected P-256 key where the hardware exists and a bound software
Expand Down
5 changes: 3 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ planning, and does not.

The box holds one outbound WSS connection to `wss://relay.ftw.energy`, joining
under a handle derived per epoch from the rendezvous secret with HKDF-SHA256.
The handle rotates hourly, so the relay operator cannot follow a household from
one hour to the next, and there is no DNS alias or other stable per-box name.
The handle rotates hourly, so the protocol exposes no stable per-box name or
DNS alias. The relay still sees source IP, timing and connection continuity,
which can correlate a household across the hour boundary.
An epoch correction from the relay is read as a clock correction and clamped;
it is never an order.

Expand Down
5 changes: 3 additions & 2 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ func (a *appPlans) CeilingW() *int64 {
return &ceiling
}

// startAppLink connects the box to the app relay, if the site asked for it.
// startAppLink connects the box to the app relay when its loaded config leaves
// the link enabled. Config defaults an omitted app_link section on.
//
// It returns the enrollment identity whether or not the uplink runs, because
// the QR code on the lid is minted from it and a box with the link switched
Expand All @@ -462,7 +463,7 @@ func startAppLink(
gateway *lateAPI,
webPush *notifications.WebPush,
) (*appenroll.Identity, *appuplink.Uplink, bool, error) {
enabled := cfg != nil && cfg.AppLink != nil && cfg.AppLink.Enabled
enabled := cfg != nil && cfg.AppLink.On()

enroll, err := appenroll.LoadOrCreate(identityKeyPath)
if err != nil {
Expand Down
204 changes: 204 additions & 0 deletions go/cmd/ftw/app_link_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package main

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
)

// An upgraded config starts without app_link. The settings round trip must
// persist the default before restart instead of silently turning the relay off.
func TestAppLinkDefaultSurvivesSettingsSaveAndRestart(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
oldYAML := []byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\n")
if err := os.WriteFile(configPath, oldYAML, 0o600); err != nil {
t.Fatalf("write old config: %v", err)
}

cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load old config: %v", err)
}
if !cfg.AppLink.On() {
t.Fatal("old config did not default the app link on")
}

var cfgMu sync.RWMutex
var ctrlMu sync.Mutex
srv := api.New(&api.Deps{
Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu,
Cfg: cfg, CfgMu: &cfgMu,
ConfigPath: configPath,
DriverDir: dir, UserDriverDir: dir,
SaveConfig: config.SaveAtomic,
})

get := httptest.NewRequest(http.MethodGet, "/api/config", nil)
getRR := httptest.NewRecorder()
srv.Handler().ServeHTTP(getRR, get)
if getRR.Code != http.StatusOK {
t.Fatalf("GET /api/config = %d: %s", getRR.Code, getRR.Body.String())
}
var shown config.Config
if err := json.Unmarshal(getRR.Body.Bytes(), &shown); err != nil {
t.Fatalf("decode GET /api/config: %v", err)
}
if !shown.AppLink.On() {
t.Fatal("GET /api/config showed the defaulted app link as off")
}

post := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(getRR.Body.Bytes()))
post.Header.Set("Content-Type", "application/json")
postRR := httptest.NewRecorder()
srv.Handler().ServeHTTP(postRR, post)
if postRR.Code != http.StatusOK {
t.Fatalf("POST /api/config = %d: %s", postRR.Code, postRR.Body.String())
}

restarted, err := config.Load(configPath)
if err != nil {
t.Fatalf("reload saved config: %v", err)
}
if !restarted.AppLink.On() {
t.Fatal("settings save changed the defaulted app link to off after restart")
}
}

func TestAppLinkOldFullConfigClientUsesEffectiveRestartState(t *testing.T) {
cases := []struct {
name string
postedValue any
includeKey bool
wantOn bool
wantRestart bool
}{
{name: "omitted", wantOn: true},
{name: "explicit null", includeKey: true, wantRestart: true},
{name: "explicit false", postedValue: map[string]any{"enabled": false}, includeKey: true, wantRestart: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
oldYAML := []byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\n")
if err := os.WriteFile(configPath, oldYAML, 0o600); err != nil {
t.Fatalf("write old config: %v", err)
}

cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load old config: %v", err)
}
var cfgMu sync.RWMutex
var ctrlMu sync.Mutex
srv := api.New(&api.Deps{
Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu,
Cfg: cfg, CfgMu: &cfgMu,
ConfigPath: configPath,
DriverDir: dir, UserDriverDir: dir,
SaveConfig: config.SaveAtomic,
})

get := httptest.NewRequest(http.MethodGet, "/api/config", nil)
getRR := httptest.NewRecorder()
srv.Handler().ServeHTTP(getRR, get)
if getRR.Code != http.StatusOK {
t.Fatalf("GET /api/config = %d: %s", getRR.Code, getRR.Body.String())
}
var posted map[string]any
if err := json.Unmarshal(getRR.Body.Bytes(), &posted); err != nil {
t.Fatalf("decode GET /api/config: %v", err)
}
delete(posted, "app_link")
if tc.includeKey {
posted["app_link"] = tc.postedValue
}
body, err := json.Marshal(posted)
if err != nil {
t.Fatalf("encode old-client config: %v", err)
}

post := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
post.Header.Set("Content-Type", "application/json")
postRR := httptest.NewRecorder()
srv.Handler().ServeHTTP(postRR, post)
if postRR.Code != http.StatusOK {
t.Fatalf("POST /api/config = %d: %s", postRR.Code, postRR.Body.String())
}
var response struct {
RestartRequired bool `json:"restart_required"`
}
if err := json.Unmarshal(postRR.Body.Bytes(), &response); err != nil {
t.Fatalf("decode POST /api/config: %v", err)
}
if response.RestartRequired != tc.wantRestart {
t.Fatalf("restart_required = %v, want %v; response: %s", response.RestartRequired, tc.wantRestart, postRR.Body.String())
}

restarted, err := config.Load(configPath)
if err != nil {
t.Fatalf("reload saved config: %v", err)
}
if got := restarted.AppLink.On(); got != tc.wantOn {
t.Fatalf("app link after restart = %v, want %v", got, tc.wantOn)
}
})
}
}

func TestEmptyAppLinkSectionRemainsOff(t *testing.T) {
cfg, err := config.Parse([]byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\napp_link: {}\n"), t.TempDir())
if err != nil {
t.Fatalf("parse: %v", err)
}
if cfg.AppLink == nil || cfg.AppLink.On() {
t.Fatalf("empty app_link section must remain off, got %+v", cfg.AppLink)
}
}

func TestAppLinkNullPostedThroughAPIStaysOffAfterRestart(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := &config.Config{}
var cfgMu sync.RWMutex
var ctrlMu sync.Mutex
srv := api.New(&api.Deps{
Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu,
Cfg: cfg, CfgMu: &cfgMu,
ConfigPath: configPath,
DriverDir: dir, UserDriverDir: dir,
SaveConfig: config.SaveAtomic,
})
body := []byte(`{
"site": {"name": "Test", "smoothing_alpha": 0.3},
"fuse": {"max_amps": 16, "phases": 3, "voltage": 230},
"api": {"port": 8080},
"drivers": [],
"app_link": null
}`)
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST /api/config = %d: %s", rr.Code, rr.Body.String())
}

restarted, err := config.Load(configPath)
if err != nil {
t.Fatalf("reload saved config: %v", err)
}
if restarted.AppLink == nil || restarted.AppLink.On() {
t.Fatalf("explicit JSON null did not persist as disabled: %+v", restarted.AppLink)
}
}
16 changes: 14 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1421,11 +1421,23 @@ func restoreDriverConfigSecrets(incoming, existing *config.Config, secretsByLua
}

func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) {
var newCfg config.Config
if err := readJSON(r, &newCfg); err != nil {
var posted struct {
config.Config
AppLink json.RawMessage `json:"app_link"`
}
if err := readJSON(r, &posted); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid config: " + err.Error()})
return
}
newCfg := posted.Config
if posted.AppLink != nil {
Comment thread
frahlg marked this conversation as resolved.
if bytes.Equal(bytes.TrimSpace(posted.AppLink), []byte("null")) {
newCfg.AppLink = &config.AppLink{Enabled: false}
} else if err := json.Unmarshal(posted.AppLink, &newCfg.AppLink); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid config: app_link: " + err.Error()})
return
}
}
// Preserve secrets the UI sent back as empty (masked) values.
s.deps.CfgMu.RLock()
newCfg.PreserveMaskedSecrets(s.deps.Cfg)
Expand Down
Loading