diff --git a/.changeset/schedule-no-stepup.md b/.changeset/schedule-no-stepup.md new file mode 100644 index 00000000..29a5af6c --- /dev/null +++ b/.changeset/schedule-no-stepup.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A signed-in owner can save the car's charging schedule without a second Face ID. Login still uses a passkey. Minting access, replacing the whole config, and moving energy still need the extra proof. diff --git a/docs/architecture.md b/docs/architecture.md index b88c5a76..30f33184 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -474,8 +474,10 @@ The honest limits, which belong here rather than in a comment nobody reads: cannot verify that a passkey ceremony happened — it has no relationship with the authenticator, and being a WebAuthn relying party would need an origin, which the box deliberately never has. It stops a phone left unlocked on a - table from being used to reconfigure the site. It stops nothing that a - modified client on an enrolled device could not already do through `cmd`; + table from being used to reconfigure the site. Routes marked `NoStepUp` skip + the ceremony: owner is enough. The charging schedule is the case. It stops + nothing that a modified client on an enrolled device could not already do + through `cmd`; - **revocation is immediate at the box.** Three layers: the session is torn down and the call it was making is cancelled, the grant is re-read from `appenroll` on every privileged request so a socket cannot outlive a revoke, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 0353dc02..89c5eab5 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -361,6 +361,7 @@ func (s *Server) Route(r *http.Request) apiauth.RouteFacts { } facts.CmdOp = mark.cmdOp facts.ReplacesAll = mark.replacesAll + facts.NoStepUp = mark.noStepUp facts.Static = mark.static } } @@ -374,8 +375,9 @@ func (s *Server) Route(r *http.Request) apiauth.RouteFacts { // // Read answers a question, changes nothing, and hands back nothing that // could be replayed as authority. A shared viewer may ask for it. -// Configure changes a setting. Owner, with a step-up. A late execution is -// the same instruction, only later. +// Configure changes a setting. Owner. A late execution is the same +// instruction, only later. Usually with a step-up; mark +// NoStepUp when login is already enough. // Actuate moves energy, or takes control of what is moving it. Refused // through the passthrough — the app sends a cmd, which carries an // expiry the box revalidates. Add Via(op) to name that command. @@ -389,7 +391,8 @@ func (s *Server) Route(r *http.Request) apiauth.RouteFacts { // as ordinary from their verb alone. Neither is. // // ReplacesAll is a separate mark rather than a tier: it says the body replaces -// a whole document instead of editing part of one. +// a whole document instead of editing part of one. NoStepUp is the same kind +// of mark: owner is still required, the ceremony is not. func (s *Server) routes() { // ---- JSON endpoints ---- s.handle("GET /api/health", Read, s.handleHealth) @@ -512,10 +515,12 @@ func (s *Server) routes() { // The schedule is configuration where its sibling target is // actuation: a schedule saved late is the same instruction, only // later, while target/soc/force_start move energy now. The split is - // what lets a phone save one through the passthrough. + // what lets a phone save one through the passthrough. NoStepUp: + // the session already proved who is asking, and Face ID is the + // wrong cost for a ready time. s.handle("POST /api/loadpoints/{id}/vehicle", Configure, s.handleLoadpointVehicle) - s.handle("PUT /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointSchedulePut) - s.handle("DELETE /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointScheduleClear) + s.handle("PUT /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointSchedulePut, NoStepUp) + s.handle("DELETE /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointScheduleClear, NoStepUp) s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC, Via(appproto.OpLoadpointSoCSet)) s.handle("POST /api/loadpoints/{id}/force_start", Actuate, s.handleLoadpointForceStart) s.handle("POST /api/loadpoints/{id}/manual_hold", Actuate, s.handleLoadpointManualHold) @@ -610,6 +615,7 @@ type routeMark struct { tier apiauth.Tier cmdOp string replacesAll bool + noStepUp bool static bool } @@ -641,6 +647,12 @@ func Via(op string) RouteMark { // passthrough refuses these outright rather than trusting the round trip. func ReplacesAll(m *routeMark) { m.replacesAll = true } +// NoStepUp marks a configure route that needs owner but not a ceremony. +// The charging schedule is the case: login already proved who is asking, +// and a second Face ID is the wrong cost for a ready time. A write +// accepted this way does not open the step-up window. +func NoStepUp(m *routeMark) { m.noStepUp = true } + // ---- Common helpers ---- func writeJSON(w http.ResponseWriter, status int, v any) { @@ -3700,10 +3712,11 @@ func (s *Server) replanForScheduleChange(id string) { // // Priced Configure where its sibling POST …/target is Actuate, because // a schedule is a standing instruction about future days: saved late, -// it is the same instruction, only later. The target route also -// carries one-shot fields that move energy now, which is why it stays -// on Actuate and why the app's passthrough needed this route to save a -// schedule at all. +// it is the same instruction, only later. Marked NoStepUp: an owner +// session is enough, and a second Face ID is the wrong cost for a +// ready time. The target route also carries one-shot fields that move +// energy now, which is why it stays on Actuate and why the app's +// passthrough needed this route to save a schedule at all. func (s *Server) handleLoadpointSchedulePut(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { writeJSON(w, 404, map[string]string{"error": "loadpoints not configured"}) @@ -3729,8 +3742,9 @@ func (s *Server) handleLoadpointSchedulePut(w http.ResponseWriter, r *http.Reque } // DELETE /api/loadpoints/{id}/schedule clears the schedule. Same price -// as PUT: removing the standing instruction is configuration too. Its derived -// target clears after storage succeeds. Manual charging remains active. +// as PUT: removing the standing instruction is configuration too, and +// owner is enough — NoStepUp, same as the PUT. Its derived target +// clears after storage succeeds. Manual charging remains active. func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { writeJSON(w, 404, map[string]string{"error": "loadpoints not configured"}) diff --git a/go/internal/api/api_passthrough_test.go b/go/internal/api/api_passthrough_test.go index 31d9fb99..b20015c0 100644 --- a/go/internal/api/api_passthrough_test.go +++ b/go/internal/api/api_passthrough_test.go @@ -19,6 +19,7 @@ import ( "github.com/srcfl/ftw/go/internal/appuplink" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -326,6 +327,96 @@ func TestAConfigureWithoutStepUpAsksForOne(t *testing.T) { } } +// The charging schedule is still configuration — a late save is the same +// instruction — but login already proved who is asking. Face ID on that +// write is the wrong cost. +func TestAnOwnerSavesAScheduleWithoutACeremony(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) + rig := newAppSession(t, apiauth.RoleOwner, func(d *Deps) { + d.Loadpoints = mgr + }) + + rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{ + Method: appproto.APIPut, + Path: "/api/loadpoints/garage/schedule", + Body: []byte(`{"soc_pct":80,"time_of_day_min_utc":360,"recurring":true,"days":31}`), + }) + head := decode[appproto.APIHeadMsg](t, rig.frames.await(t, appproto.MsgAPIHead)) + if head.Status != 200 { + t.Fatalf("status = %d, want 200", head.Status) + } + got, ok := mgr.GetSchedule("garage") + if !ok { + t.Fatal("the schedule write reached a 200 but the box did not store it") + } + if got.TimeOfDayMinUTC != 360 { + t.Fatalf("stored time = %d, want 360", got.TimeOfDayMinUTC) + } +} + +func TestAnOwnerClearsAScheduleWithoutACeremony(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) + rig := newAppSession(t, apiauth.RoleOwner, func(d *Deps) { + d.Loadpoints = mgr + }) + + rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{ + Method: appproto.APIDelete, + Path: "/api/loadpoints/garage/schedule", + }) + head := decode[appproto.APIHeadMsg](t, rig.frames.await(t, appproto.MsgAPIHead)) + if head.Status != 200 { + t.Fatalf("status = %d, want 200", head.Status) + } + if _, ok := mgr.GetSchedule("garage"); ok { + t.Fatal("DELETE without a ceremony left the schedule in place") + } +} + +func TestAViewerCannotSaveASchedule(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) + rig := newAppSession(t, apiauth.RoleViewer, func(d *Deps) { + d.Loadpoints = mgr + }) + + rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{ + Method: appproto.APIPut, + Path: "/api/loadpoints/garage/schedule", + Body: []byte(`{"soc_pct":80,"time_of_day_min_utc":360,"recurring":true}`), + }) + refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError)) + if refusal.Code != appproto.ErrScopeDenied { + t.Fatalf("refusal = %+v, want E_SCOPE_DENIED", refusal) + } + if _, ok := mgr.GetSchedule("garage"); ok { + t.Fatal("a viewer stored a schedule") + } +} + +func TestScheduleConfigureSkipsTheCeremony(t *testing.T) { + srv := New(&Deps{}) + for _, tc := range []struct{ method, path string }{ + {"PUT", "/api/loadpoints/1/schedule"}, + {"DELETE", "/api/loadpoints/1/schedule"}, + } { + facts := srv.Route(newSyntheticRequest(tc.method, tc.path)) + if facts.Tier != apiauth.TierConfigure { + t.Fatalf("%s %s tier = %q, want configure", tc.method, tc.path, facts.Tier) + } + if !facts.NoStepUp { + t.Fatalf("%s %s still needs a ceremony; a ready-time write must not", tc.method, tc.path) + } + } + rules := srv.Route(newSyntheticRequest("PUT", "/api/notifications/rules")) + if rules.NoStepUp { + t.Fatal("notification rules skipped the ceremony") + } +} + // -------------------------------------------------------------------------- // The second door: actuation stays on cmd // -------------------------------------------------------------------------- @@ -668,6 +759,10 @@ func TestEveryMarkedRouteIsReachable(t *testing.T) { t.Fatalf("%s resolves to replacesAll=%v, but is marked %v", pattern, facts.ReplacesAll, mark.replacesAll) } + if facts.NoStepUp != mark.noStepUp { + t.Fatalf("%s resolves to noStepUp=%v, but is marked %v", + pattern, facts.NoStepUp, mark.noStepUp) + } if facts.Static != mark.static { t.Fatalf("%s resolves to static=%v, but is marked %v", pattern, facts.Static, mark.static) diff --git a/go/internal/apiauth/apiauth.go b/go/internal/apiauth/apiauth.go index 723c608d..67f5fc88 100644 --- a/go/internal/apiauth/apiauth.go +++ b/go/internal/apiauth/apiauth.go @@ -89,7 +89,9 @@ const ( // that could be replayed as authority. A shared viewer may ask for it. TierRead Tier = "read" // TierConfigure changes a setting. A late execution is the same - // instruction, only later. Owner, with a step-up. + // instruction, only later. Owner, and usually a step-up. A route + // marked NoStepUp skips the ceremony: the session already proved + // who is asking. TierConfigure Tier = "configure" // TierActuate moves energy, or takes control of what is moving it. It // never travels over the passthrough, because an HTTP request carries no @@ -143,6 +145,12 @@ type RouteFacts struct { // where at least the browser had just loaded the whole document from the // same box. A phone on a relay has no such guarantee. ReplacesAll bool + + // NoStepUp marks a configure route that needs owner but not a fresh + // passkey ceremony. The charging schedule is the case: changing when + // the car should be ready is a household setting, and the session + // already proved who is asking. + NoStepUp bool } // ScopeSet is what a caller may ask for. diff --git a/go/internal/appproto/passthrough.go b/go/internal/appproto/passthrough.go index aca609cb..138e92f8 100644 --- a/go/internal/appproto/passthrough.go +++ b/go/internal/appproto/passthrough.go @@ -302,6 +302,13 @@ func (h *Handler) gateAPI(caller apiauth.Caller, facts apiauth.RouteFacts, req A Args: map[string]any{"needRole": apiauth.RoleOwner, "role": caller.Role}, } } + if facts.NoStepUp { + // Owner is enough. Login already proved who is asking; a + // second Face ID is the wrong cost for this write. Do not + // open the window: a ready-time save must not let an + // unmarked configure through. + return nil + } // Step-up, with a grace window. A configure action needs recent human // presence; one ceremony proves it, and the box now remembers that // proof for StepUpWindowMs instead of demanding a fresh one per write. diff --git a/go/internal/appproto/passthrough_test.go b/go/internal/appproto/passthrough_test.go index b1bafebc..8c982f38 100644 --- a/go/internal/appproto/passthrough_test.go +++ b/go/internal/appproto/passthrough_test.go @@ -768,6 +768,72 @@ func TestAConfigureWithNoCeremonyIsRefused(t *testing.T) { } } +// Owner is enough for a route marked NoStepUp. The charging schedule is +// the case: login already proved who is asking. +func TestANoStepUpConfigureRunsWithoutACeremony(t *testing.T) { + h, _, rec, _ := newAPIRig(t, &stubAPI{ + facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure, NoStepUp: true}, + serve: text(`{"ok":true}`), + }) + + deliver(t, h, MsgAPIReq, ptrU32(27), APIReq{ + Method: APIPut, Path: "/api/loadpoints/1/schedule", + }) + if head := body[APIHeadMsg](t, waitFor(t, rec, MsgAPIHead)); head.Status != http.StatusOK { + t.Fatalf("answered %d, want 200", head.Status) + } + if rec.has(MsgError) { + t.Fatal("a NoStepUp configure was refused a ceremony") + } +} + +func TestANoStepUpConfigureStillNeedsTheOwner(t *testing.T) { + h, _, rec, grants := newAPIRig(t, &stubAPI{ + facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure, NoStepUp: true}, + serve: text(`{"ok":true}`), + }) + grants.setRole(apiauth.RoleViewer) + + deliver(t, h, MsgAPIReq, ptrU32(28), APIReq{ + Method: APIPut, Path: "/api/loadpoints/1/schedule", + }) + if err := body[ErrorBody](t, waitFor(t, rec, MsgError)); err.Code != ErrScopeDenied { + t.Fatalf("refusal was %+v, want E_SCOPE_DENIED", err) + } + if rec.has(MsgAPIHead) { + t.Fatal("a viewer's NoStepUp write reached the handler") + } +} + +// A ready-time save must not open the window that lets an unmarked +// configure through. Otherwise an unlocked phone could change access +// after touching the schedule. +func TestANoStepUpWriteDoesNotOpenTheWindow(t *testing.T) { + api := &stubAPI{ + facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure, NoStepUp: true}, + serve: text(`{}`), + } + h, _, rec, _ := newAPIRig(t, api) + + deliver(t, h, MsgAPIReq, ptrU32(29), APIReq{ + Method: APIPut, Path: "/api/loadpoints/1/schedule", + }) + waitFor(t, rec, MsgAPIEnd) + waitIdle(t, h) + + api.facts = apiauth.RouteFacts{Tier: apiauth.TierConfigure} + rec.reset() + deliver(t, h, MsgAPIReq, ptrU32(30), APIReq{ + Method: APIPost, Path: "/api/app-link/pairing", + }) + if err := body[ErrorBody](t, waitFor(t, rec, MsgError)); err.Code != ErrNeedsStepUp { + t.Fatalf("a later unmarked configure was %+v, want E_NEEDS_STEP_UP", err) + } + if rec.has(MsgAPIHead) { + t.Fatal("a schedule save opened the step-up window for a privileged write") + } +} + // The whole point: one ceremony, then the writes that follow it on the same // session cost nothing. A settings screen that subscribes, saves and tests is // three configure calls; this is what turns three Face IDs into one.