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/schedule-no-stepup.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 26 additions & 12 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -610,6 +615,7 @@ type routeMark struct {
tier apiauth.Tier
cmdOp string
replacesAll bool
noStepUp bool
static bool
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"})
Expand All @@ -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"})
Expand Down
95 changes: 95 additions & 0 deletions go/internal/api/api_passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
// --------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion go/internal/apiauth/apiauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions go/internal/appproto/passthrough.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions go/internal/appproto/passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down