Skip to content

Commit c56fccf

Browse files
frahlgclaude
andauthored
feat(auth): connect an assistant without a terminal (#1832)
* feat(auth): connect an assistant without a terminal The house password, not the address, proves the owner. Any browser that reaches the box may set the first one, also through a Docker port or on the box itself, and choosing it signs that browser in. Settings → Access asks for it right above the assistant's code, and the setup wizard asks for it on a new box. Any non-empty password is accepted; a short one gets a warning. A forgotten password is turned off on the box with `ftw password reset` (`/app/ftw password reset` in Docker). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): close the first-password race and sign in only with proof From an independent review of the previous commit: - A request Authenticate let in while the lock was still off can no longer replace the password the first browser just chose. - The sign-in cookie goes only to a direct local browser that chose the first password or proved the current one. The box itself may still replace a password it never proved, as it may change any setting, but that earns no sign-in. - Every change of the house password writes an audit line with the address. - Access no longer offers "choose a password" when the box's status could not be read, and a choose that meets a password set meanwhile turns into a sign-in. - The password limit counts characters, not bytes. - The docs say how to reset on a native box whose ftw command predates this release, and say plainly that loopback is not a wall against programs on the box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 286ac14 commit c56fccf

25 files changed

Lines changed: 895 additions & 200 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"ftw": minor
3+
---
4+
5+
Connecting an assistant no longer needs a terminal. When the box has no house password, Settings → Access asks you to choose one right above the assistant's code, and choosing it signs that browser in. Any browser that reaches the box may set the first one, also through a Docker port or on the box itself. A new box asks for it on the setup wizard's last step. Any password will do: a short one gets a warning, not a refusal. The Home Assistant app still needs none.
6+
7+
The password, not the address, now proves the owner: a browser on the box itself can let an assistant in once it is signed in. Settings → System calls it the house password and explains it without developer words. If you forget it, or someone else set it first, run `ftw password reset` on the box (`docker compose exec ftw /app/ftw password reset` on Docker), then choose a new one on the box's page. On a native box whose `ftw` command predates this release, run `/opt/ftw/releases/<release>/ftw password reset` instead.

‎docker-compose.macos.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
# In config.yaml set the MQTT host to `mosquitto` (port 1883), NOT
2323
# `localhost` / `127.0.0.1`. There is no host networking to make
2424
# localhost mean "the broker".
25-
# - First enable of the house password (api.lan_auth) is loopback-only
26-
# inside the container. A Mac curl to http://127.0.0.1:8080 arrives as
27-
# the bridge gateway. Use `docker compose -f docker-compose.macos.yml
28-
# exec ftw` and then curl http://127.0.0.1:8080 from there.
25+
# - A Mac browser on http://127.0.0.1:8080 arrives as the bridge
26+
# gateway, not loopback. Set the house password in the page as usual.
27+
# To reset a forgotten one: `docker compose -f docker-compose.macos.yml
28+
# exec ftw /app/ftw password reset`.
2929
# - mDNS (`zap.local`) and UDP broadcast device discovery do NOT cross
3030
# the Docker Desktop VM boundary. Configure every driver with an
3131
# EXPLICIT IP address. Outbound unicast TCP (Modbus TCP to a known

‎docs/agents.md‎

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ above and its approval key, and an agent can never reach them.
628628
| `GET /api/agents` | A client on the LAN. Rows, requests and keys, the box's facts and the registry's agent ops |
629629
| `GET /api/agents/audit?before=&n=` | A client on the LAN |
630630
| `POST /api/agents/offer {offer}` | A client on the LAN. The offer as the box reads it; grants nothing |
631-
| `POST /api/agents {offer, scopes, ops, caps, ttlS}` | A LAN client signed in with the house password |
631+
| `POST /api/agents {offer, scopes, ops, caps, ttlS}` | A browser signed in with the house password, on the LAN or on the box |
632632
| `POST /api/agents/{id}/widen {epoch, scopes, ops, caps, ttlS}` | The same |
633633
| `POST /api/agent-requests/{reqId} {approve: true}` | The same |
634634
| `POST /api/agents/approval-key {deviceId, pub}` | The same: a phone that lost its key |
@@ -672,13 +672,14 @@ above and its approval key, and an agent can never reach them.
672672
replace the offer's when given. Without `ttlS` the grant lasts 30 days, or
673673
the offer's length if shorter. A `ttlS` past the role's longest grant is
674674
`E_CAP_EXCEEDED {cap: "expiresAtMs", max}`.
675-
- **Every route that widens access needs another device on the home
676-
network, signed in with the house password.** The box never reads the
677-
sign-in from loopback, so a browser on the box, or one through an SSH
678-
tunnel to it, cannot widen access with or without the password: a
679-
driver's Lua and the session passthrough can both reach the box from
680-
inside it. `box.widen` in `GET /api/agents` says whether this request
681-
would pass, so the page stops before a code is spent.
675+
- **Every route that widens access needs a browser signed in with the
676+
house password,** on another device or on the box itself. The password,
677+
not the address, proves the owner. Loopback stays an owner for settings
678+
without it, so it could replace the password and then sign in: it is not
679+
a wall against programs on the box. A driver cannot reach the API
680+
(below), and the session passthrough is refused. `box.widen` in
681+
`GET /api/agents` says whether this request would pass, so the page stops
682+
before a code is spent.
682683
- **Drivers never reach the box's API.** A driver's HTTP, TCP or WebSocket
683684
connection to a port the API listens on is refused, whether it goes to a
684685
loopback address or to an address of the box. The check runs on the
@@ -691,16 +692,25 @@ above and its approval key, and an agent can never reach them.
691692

692693
What the sign-in is worth today:
693694

694-
- **Off by default.** With `api.lan_auth` off, which is the default, every
695-
client on the home network is an owner with no sign-in. Granting a remote
696-
agent from there would let any device on the Wi-Fi give the outside world
697-
a way in, so those routes refuse with 403 until the house password is on.
698-
- **With the house password on.** The password has at least 10 characters
699-
and is stored as argon2id (64 MiB, 3 passes). Five wrong guesses lock every
700-
sign-in for 30 seconds, box-wide. A sign-in sets a random 32-byte cookie,
701-
`HttpOnly` and `SameSite=Strict`, that lasts 12 hours and dies with a
702-
restart. Browser requests from other sites are refused by Origin and
703-
`Sec-Fetch-Site` checks.
695+
- **Off until someone chooses it.** A new box asks for it during setup; an
696+
older box may still have it off. With `api.lan_auth` off every client on
697+
the home network is an owner with no sign-in. Granting a remote agent
698+
from there would let any device on the Wi-Fi give the outside world a way
699+
in, so those routes refuse with 403 until the house password is on. The
700+
Access page asks for it right above the code: any browser that reaches
701+
the box may choose the first one, which signs that browser in. While the
702+
lock is off that browser is already an owner, so choosing it grants
703+
nothing new (Fredrik, 25 October 2026). An owner locked out by someone
704+
else's password, or who forgot it, runs `ftw password reset` on the box;
705+
see [operations.md](operations.md#house-password).
706+
- **With the house password on.** Its length is the owner's choice: the
707+
page warns below 8 characters but takes any password that is not empty
708+
(Fredrik, 25 October 2026). It is stored as argon2id (64 MiB, 3
709+
passes). Five wrong guesses lock every sign-in for 30 seconds, box-wide.
710+
A sign-in sets a random 32-byte cookie,
711+
`HttpOnly` and `SameSite=Strict`, that lasts 30 days, survives a restart
712+
and ends when the password changes. Browser requests from other sites are
713+
refused by Origin and `Sec-Fetch-Site` checks.
704714
- **What it lacks.** The page is plain HTTP, so anyone who can read the home
705715
network's traffic can read the password and the cookie. There is one
706716
shared password and no person behind it, so the audit row names the
@@ -756,8 +766,8 @@ receipts for current agents, plus 32 old receipts, with a 128-row storage
756766
bound. No expired or cancelled record can reopen a valid code after pruning.
757767

758768
The local routes are `GET/POST /api/agent-invites` and
759-
`DELETE /api/agent-invites/{id}`. POST uses the same house-password and
760-
non-loopback LAN gate as an ordinary grant. Signed app messages allow an
769+
`DELETE /api/agent-invites/{id}`. POST uses the same house-password gate
770+
as an ordinary grant. Signed app messages allow an
761771
owner to share access away from home. The recipient connects FTW from its
762772
own assistant, selects **I have an invitation code** on the consent page and
763773
pastes the code there. Core must be online. The normal signed-roster,

‎docs/operations.md‎

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -185,31 +185,62 @@ mutations remain locked.
185185
remote access; remote access is the FTW app. See
186186
[architecture.md](architecture.md#remote-access-boundary).
187187

188-
`api.lan_auth` is off by default. Turn it on from loopback inside the
189-
process: `http://127.0.0.1:8080` Settings → System on the machine, or `curl`
190-
to `127.0.0.1`. When on, protected LAN routes need the house password or
191-
`FTW_API_TOKEN`. `curl` sends `Authorization: Bearer <house-password>` or
192-
`Authorization: Bearer <FTW_API_TOKEN>`. The browser login form sets a
193-
session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`) never
194-
asks. Live status stays readable without the password; a viewer caller is
195-
minted for those reads. The two secrets do not share a lockout: retrying the
196-
API token cannot lock the household out of Settings. A first enable from
197-
another LAN address is refused, so a visitor cannot set the password.
198-
`POST /api/config` cannot flip the flag.
199-
200-
On an older Docker install under Docker Desktop (`docker-compose.macos.yml`),
201-
a host curl to localhost arrives as the bridge gateway, not loopback, so
202-
Settings and host curl get 403. Desktop SNAT uses that same peer for every
203-
published-port client, including LAN visitors, so it must not count as
204-
loopback. Enable from inside the container:
188+
### House password
189+
190+
The house password (`api.lan_auth`) keeps other devices on the home network
191+
out of Settings and stops them letting an assistant in. Live status stays
192+
readable without it; a viewer caller is minted for those reads.
193+
194+
- **A new box** asks for it on the setup wizard's last step and on the
195+
assistant setup path. The Home Assistant app asks for none: Home
196+
Assistant signs its users in. A script that posts first-run settings
197+
without `house_password` leaves the lock off.
198+
- **A running box without one** asks for it where it is needed: Settings →
199+
System → House password, or Settings → Access, right above the
200+
assistant's code. Any browser that reaches the box may set the first
201+
one. While the lock is off that browser is already an owner, so setting
202+
it grants nothing new; it closes the door behind it. Choosing it signs
203+
that browser in. `POST /api/config` cannot flip the flag.
204+
- **Any password** is accepted, however short. The page warns below 8
205+
characters; the choice is the owner's.
206+
- **When it is on**, protected LAN routes need the password, a browser
207+
session, or `FTW_API_TOKEN`. Scripts send `Authorization: Bearer
208+
<house-password>` or `Authorization: Bearer <FTW_API_TOKEN>`. The browser
209+
sign-in sets an `HttpOnly` session cookie (`ftw_lan`, 30 days). Five
210+
wrong passwords lock every sign-in for 30 seconds; retrying the API
211+
token never counts. From another device, changing or turning off the
212+
password needs it.
213+
- **The box itself** (`127.0.0.1` / `::1`) needs no password for settings,
214+
so `ftw` commands keep working and `ftw password reset` can turn it off.
215+
A browser on the box signs in like any other before it lets an
216+
assistant in. Drivers cannot reach the API at all; anything else that
217+
runs on the box can already change every setting.
218+
219+
**Forgot it**, or someone else set it first: turn it off on the box, then
220+
choose a new one on the box's page.
205221

206222
```bash
207-
docker compose -f docker-compose.macos.yml exec ftw \
208-
wget -qO- --header='Content-Type: application/json' \
209-
--post-data='{"enabled":true,"password":"ETT-LANGT-LOSEN"}' \
210-
http://127.0.0.1:8080/api/auth/password
223+
ftw password reset
211224
```
212225

226+
The `ftw` command moves with `install.sh --refresh`, not with `ftw update`.
227+
If it answers `unknown command password`, run the same step with Core's own
228+
binary for the release `ftw status` names:
229+
230+
```bash
231+
/opt/ftw/releases/v0.X.Y/ftw password reset
232+
```
233+
234+
On Docker, run the box's own binary in its container (add
235+
`-f docker-compose.macos.yml` on Docker Desktop):
236+
237+
```bash
238+
docker compose exec ftw /app/ftw password reset
239+
```
240+
241+
Either signs every browser out. Without shell access to the box, the
242+
password cannot be reset.
243+
213244
An owner pairing QR is minted only from loopback or with the house
214245
password. Promoting a paired phone to owner uses the same gate. The
215246
first pairing on an empty box is an owner whatever the code said, so
@@ -218,9 +249,6 @@ LAN once an owner exists.
218249

219250
The FTW app and Home Assistant MQTT are unchanged.
220251

221-
Recovery: `curl` to `127.0.0.1`, or set `api.lan_auth: false` in
222-
`config.yaml` and restart Core.
223-
224252
### "remote access to protected API routes is disabled"
225253

226254
The full message is `remote access to protected API routes is disabled;

‎go/cmd/ftw/bootstrap.go‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,19 @@ func runBootstrap(configPath, webDir, driverDir string) {
102102
}
103103
writeBootstrapJSON(w, http.StatusOK, chargers)
104104
})
105-
mux.HandleFunc("POST /api/config", func(w http.ResponseWriter, r *http.Request) {
105+
mux.HandleFunc("POST /api/config", bootstrapConfig(configPath, saveInitial, func() error { return execCurrentProcess(nil) }))
106+
107+
srv := newHTTPServer(":8080", secureBootstrapMutations(mux))
108+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
109+
slog.Error("bootstrap server", "err", err)
110+
os.Exit(1)
111+
}
112+
}
113+
114+
// bootstrapConfig saves the wizard's settings, with the house password it
115+
// asked for, then restarts into the normal server.
116+
func bootstrapConfig(configPath string, save func(*config.Config) error, restart func() error) http.HandlerFunc {
117+
return func(w http.ResponseWriter, r *http.Request) {
106118
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
107119
r.Body.Close()
108120
if err != nil {
@@ -118,23 +130,21 @@ func runBootstrap(configPath, webDir, driverDir string) {
118130
writeBootstrapJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
119131
return
120132
}
121-
if err := saveInitial(&cfg); err != nil {
133+
if err := applyInitialHousePassword(r, body, &cfg); err != nil {
134+
writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
135+
return
136+
}
137+
if err := save(&cfg); err != nil {
122138
writeBootstrapJSON(w, http.StatusInternalServerError, map[string]string{"error": "write config: " + err.Error()})
123139
return
124140
}
125141
slog.Info("config written by setup wizard — restarting", "path", configPath)
126142
writeBootstrapJSON(w, http.StatusOK, map[string]string{"status": "ok", "restart": "true"})
127143
go func() {
128-
if err := execCurrentProcess(nil); err != nil {
144+
if err := restart(); err != nil {
129145
slog.Error("bootstrap re-exec failed", "err", err)
130146
}
131147
}()
132-
})
133-
134-
srv := newHTTPServer(":8080", secureBootstrapMutations(mux))
135-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
136-
slog.Error("bootstrap server", "err", err)
137-
os.Exit(1)
138148
}
139149
}
140150

@@ -165,7 +175,7 @@ func bootstrapAssistant(save func(*config.Config) error, restart func() error) h
165175
Confirmed bool `json:"confirmed"`
166176
Password string `json:"password"`
167177
}
168-
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
178+
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
169179
dec.DisallowUnknownFields()
170180
if err := dec.Decode(&q); err != nil || !q.Confirmed {
171181
writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": "confirm starting an empty measurement installation"})
@@ -199,6 +209,25 @@ func bootstrapAssistant(save func(*config.Config) error, restart func() error) h
199209
}
200210
}
201211

212+
// applyInitialHousePassword locks a new box with the house password the
213+
// wizard asked for, beside its settings. Home Assistant signs its users in
214+
// itself and sends none. A script that posts settings without one leaves the
215+
// lock off, as before; the page asks for a password later.
216+
func applyInitialHousePassword(r *http.Request, body []byte, cfg *config.Config) error {
217+
var q struct {
218+
HousePassword string `json:"house_password"`
219+
}
220+
if err := json.Unmarshal(body, &q); err != nil || q.HousePassword == "" || api.HomeAssistantIngress(r) {
221+
return nil
222+
}
223+
hash, err := api.HashInitialLANPassword(q.HousePassword)
224+
if err != nil {
225+
return err
226+
}
227+
cfg.API.LANAuth, cfg.LANPasswordHash = true, hash
228+
return nil
229+
}
230+
202231
// Bootstrap needs the same country/zone choices before any config exists.
203232
func bootstrapPriceZones(w http.ResponseWriter, _ *http.Request) {
204233
zones := prices.Zones()

‎go/cmd/ftw/bootstrap_assistant_test.go‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"net/http/httptest"
56
"os"
67
"path/filepath"
@@ -133,3 +134,80 @@ func TestHomeAssistantBootstrapNeedsNoSecondPassword(t *testing.T) {
133134
t.Fatal("HA setup invented a password or equipment")
134135
}
135136
}
137+
138+
// The wizard's own settings save carries the house password it asked for,
139+
// so a new box starts locked. Home Assistant signs its users in itself, and
140+
// a script that sends none keeps the lock off as before.
141+
func TestBootstrapWizardSavesTheHousePassword(t *testing.T) {
142+
t.Setenv("FTW_API_TOKEN", "")
143+
const settings = `"site":{"name":"Home","control_interval_s":5,"grid_tolerance_w":42,"watchdog_timeout_s":60,"smoothing_alpha":0.3,"gain":0.5,"slew_rate_w":500,"min_dispatch_interval_s":5},"fuse":{"max_amps":16,"phases":3,"voltage":230},"drivers":[],"api":{"port":8080}`
144+
for _, tc := range []struct {
145+
name, body string
146+
want int
147+
locked bool
148+
}{
149+
{"with a password", `{` + settings + `,"house_password":"first-owner-password"}`, 200, true},
150+
{"a short one, the owner's choice", `{` + settings + `,"house_password":"1234"}`, 200, true},
151+
{"too long", `{` + settings + `,"house_password":"` + strings.Repeat("x", 513) + `"}`, 400, false},
152+
{"from a script without one", `{` + settings + `}`, 200, false},
153+
{"through Home Assistant", `{` + settings + `,"house_password":"first-owner-password"}`, 200, false},
154+
} {
155+
t.Run(tc.name, func(t *testing.T) {
156+
path := filepath.Join(t.TempDir(), "config.yaml")
157+
restarted := make(chan struct{}, 1)
158+
handler := secureBootstrapMutations(bootstrapConfig(path, func(cfg *config.Config) error { return saveInitialConfig(path, cfg) },
159+
func() error { restarted <- struct{}{}; return nil }))
160+
req := httptest.NewRequest("POST", "http://box.local/api/config", strings.NewReader(tc.body))
161+
req.RemoteAddr = "192.168.1.12:1234"
162+
req.Header.Set("Content-Type", "application/json")
163+
req.Header.Set("Origin", "http://box.local")
164+
if tc.name == "through Home Assistant" {
165+
handler = api.WithHomeAssistantIngress(handler)
166+
req.RemoteAddr = "172.30.32.2:1234"
167+
req.Header.Del("Origin")
168+
req.Header.Set("X-Ingress-Path", "/api/hassio_ingress/test")
169+
req.Header.Set("X-Remote-User-Id", "owner")
170+
}
171+
w := httptest.NewRecorder()
172+
handler.ServeHTTP(w, req)
173+
if w.Code != tc.want {
174+
t.Fatalf("%d %s", w.Code, w.Body.String())
175+
}
176+
if tc.want != 200 {
177+
if _, err := os.Stat(path); !os.IsNotExist(err) {
178+
t.Fatal("a refused save wrote settings")
179+
}
180+
return
181+
}
182+
<-restarted
183+
cfg, err := config.Load(path)
184+
if err != nil {
185+
t.Fatal(err)
186+
}
187+
if cfg.API.LANAuth != tc.locked || (cfg.LANPasswordHash != "") != tc.locked {
188+
t.Fatalf("lan_auth %v, hash set %v; want %v", cfg.API.LANAuth, cfg.LANPasswordHash != "", tc.locked)
189+
}
190+
raw, _ := os.ReadFile(path)
191+
if strings.Contains(string(raw), "first-owner-password") {
192+
t.Fatal("the locator holds the house password")
193+
}
194+
var sent struct {
195+
HousePassword string `json:"house_password"`
196+
}
197+
_ = json.Unmarshal([]byte(tc.body), &sent)
198+
if tc.locked && !api.VerifyStoredLANSecret(mustOpenState(t, cfg.ConfigDatabase), sent.HousePassword) {
199+
t.Fatal("the stored hash does not match the password the wizard sent")
200+
}
201+
})
202+
}
203+
}
204+
205+
func mustOpenState(t *testing.T, path string) *state.Store {
206+
t.Helper()
207+
st, err := state.Open(path)
208+
if err != nil {
209+
t.Fatal(err)
210+
}
211+
t.Cleanup(func() { _ = st.Close() })
212+
return st
213+
}

‎go/cmd/ftw/main.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import (
5454
"github.com/srcfl/ftw/go/internal/drivers"
5555
"github.com/srcfl/ftw/go/internal/events"
5656
"github.com/srcfl/ftw/go/internal/forecast"
57+
"github.com/srcfl/ftw/go/internal/ftwcli"
5758
"github.com/srcfl/ftw/go/internal/gatewayidentity"
5859
"github.com/srcfl/ftw/go/internal/ha"
5960
"github.com/srcfl/ftw/go/internal/loadmodel"
@@ -325,6 +326,12 @@ func main() {
325326
// Shift os.Args so the subcommand's flag.FlagSet sees its own flags.
326327
runNovaClaim(os.Args[2:])
327328
return
329+
case "password":
330+
// A container has no separate ftw command, so Core's own binary
331+
// resets the house password: docker compose exec ftw /app/ftw
332+
// password reset. It asks the running Core on loopback.
333+
exitCode = ftwcli.Run(os.Args[1:], os.Stdout, os.Stderr)
334+
return
328335
}
329336
}
330337

0 commit comments

Comments
 (0)