diff --git a/.changeset/agents-access.md b/.changeset/agents-access.md new file mode 100644 index 00000000..ff50c46f --- /dev/null +++ b/.changeset/agents-access.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The box enforces agent access (`docs/agents.md`): an owner can let an assistant, Sourceful support for one ticket or the release check reach the box through FTW Cloud's tunnel, never the relay. Each grant has scopes, an op allowlist, caps on hold length, power and rate, and a wall-clock expiry that fails closed without NTP; revoke and expiry bite at the next call. Holds end by themselves; durable changes wait as requests until an owner confirms them with a phone's approval key or on the box page with the house password. The box keeps an audit log of who did what, serves log excerpts and configuration redacted on the box, and pushes `agent.added` and `agent.request` to owners. diff --git a/docs/agents.md b/docs/agents.md index 5b774cc9..8afe6d7d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -94,7 +94,7 @@ any other request from an agent with `E_SCOPE_DENIED`. | Message | Needs | Answer | |---|---|---| -| `hello` | — | `hello_ok`, with the role and the scopes the agent holds now, and no `cloud` | +| `hello` | — | `hello_ok`, with the role and the scopes the agent holds now, and no `cloud`. A `sub` in an agent's `hello` is ignored | | A read in the registry's `reads` | Its scope there | See [Reads](#reads) | | `cmd` | The op's agent scope, `ftw.dispatch.hold` | `cmd.ack`, `cmd.result` | | `agent.request` | The op's agent scope, `ftw.goals.request` | `agent.ok` | @@ -107,6 +107,12 @@ are priced for an owner. A box that lacks one of the reads answers `E_UNKNOWN_OP`, as it does for any message it does not know. A phone may send the reads too, under the same scopes. +Every answer to an agent names things by agent id. An error's `args` keep +only keys the box knows to be safe, such as `op`, `arg`, `cap` and `max`, +with charger and source names turned into agent ids and any other text +dropped: a handler written for a phone may name a device, and FTW Cloud +reads every answer an agent gets. + ## Operations Each op in the registry may name an `agent` scope. An op without one is never @@ -153,9 +159,17 @@ open to an agent. - **The owner's hold wins.** Every hold carries its setter. An agent's hold carries `agent:`. While a hold an owner set is active on a charger, an agent's `loadpoint.hold` there gets `E_NEEDS_OWNER`, and an agent never - replaces or clears it. `clear` releases only a hold the same agent set. -- **The box ends an agent's holds** when its grant is revoked or expires, - at once, whatever the hold had left. + replaces or clears it. `clear` releases only a hold the same agent set, + and with none to release gets `E_NOT_FOUND`. The charger's controller + checks the setter and writes in one step, so an owner's hold that lands + between the check and the write still wins. +- **One hold per charger every 30 seconds.** An agent that sets holds on + one charger faster gets `E_RATE_LIMITED {reason: "hold_gap"}` with + `retryAfterMs`, so it cannot flap a charger between nothing and full + power. +- **The box ends an agent's holds** when its grant is revoked, narrowed or + expires, and when the owner phone that approved it is removed or stops + being an owner, at once, whatever the hold had left. - **Renewal is a tool call.** FTW Cloud never renews a hold on its own; it sends one only in answer to the agent's tool call. Charging for longer than `holdS` is a durable goal: a `ftw.goals.request` op, such as the car's goal @@ -191,8 +205,9 @@ open to an agent. 4. An owner reads it with `agent.list`. The app shows the op and each argument by its label, and the agent's `why` as the agent's words. The owner answers with `agent.confirm`. Approving needs an approval signature - over the request id, the agent, the op and the hash of the args; declining - needs none. + over the request id, the agent, the op and the hash of the args, and a + wall clock the box trusts, or it gets `E_UNAVAILABLE {subsystem: clock}`; + declining needs neither. 5. On approval the box runs the op on the confirming owner's session as a command with id `req:`, under the owner's scopes and against fresh state, and sends that session `cmd.ack` and `cmd.result`. Nothing the @@ -344,9 +359,9 @@ base64url. `actor` is the caller subject (`agent:`, `app:`, `lan:
` or `box`); `principal` is the client or ticket; `by` is who made a support call; `what` is the message type or op; `result` is `ok` or the error code. -`count` folds an agent's reads: one row per agent, message and minute, with -how many there were. Grants, keys, commands and requests get a row each, -from a budget of their own. +`count` folds an agent's reads and refusals: one row per agent, message, +`by`, result and minute, with how many there were. Grants, keys, commands +and requests get a row each, from a budget of their own. ### Reads @@ -385,7 +400,11 @@ The registry's `reads` table names each read and the scope it needs. fallbacks, refusals and plan against actual join it when the box keeps them; a field the box cannot fill is absent. - The box answers `events.get` and `diag.run` with `E_UNKNOWN_OP` until it - builds them. + builds them, and `cars.get` until it has the car store. Until then + `loadpoints.get` carries no car id. +- `logs.get` looks back over the box's whole log ring, its newest 2,000 + lines. `beforeMs` and `afterMs` together pick the lines between them; an + `afterMs` at or past `beforeMs` gets `E_UNKNOWN_OP {field: "beforeMs"}`. ## Redaction on the box @@ -395,12 +414,15 @@ unredacted, and nothing is stored in the cloud. - **Logs.** The box keeps the time, the level and the box's own message text, scrubbed. A driver's free text never leaves: its line keeps the level, the driver's agent id and the attributes the allowlist below - keeps. Text is scrubbed of every name the configuration and the car store - know (site, drivers, devices, chargers, cars, VINs, identifiers, sources), - IP and MAC addresses, host names, URLs, e-mail addresses, quoted text, - serial-like words (letters and digits mixed, six or more), runs of seven or - more digits, and anything that looks like a credential: bearer and basic - headers, secret-named keys and long tokens. + keeps. Text is scrubbed of every name the configuration knows (site, + drivers, chargers, vehicles and their identifiers; the car store's names + join when the box has one), IP and MAC addresses, host names, URLs, + e-mail addresses, quoted text, serial-like words (letters and digits + mixed, six or more), runs of seven or more digits, and anything that + looks like a credential: bearer and basic headers, secret-named keys and + long tokens. A host name is a dotted name that ends in any two-letter + label, as every country's does, or in a common longer one such as `.com` + or `.xyz`. A code word such as `planner.prefs.set` ends in neither. - **Attributes.** A line's attributes keep only keys on the box's allowlist. A value survives when its key is allowed and the value is a flag, a duration, a short lowercase code word, or a number for a key the @@ -409,7 +431,10 @@ unredacted, and nothing is stored in the cloud. - **Config.** Every secret-named key is replaced by `***`. Names become agent ids. Location, addresses, host names, URLs, serials, MAC addresses, vehicle and car ids, VINs, e-mail addresses and notification targets are - removed. + removed. A number stays only under a key that ends in its unit (`_w`, + `_kwh`, `_s`, `_pct`, `_soc` and the like) or names a known setting such + as `phases`; any other number goes, so a port, a Modbus unit id or an + account number does not leave. - **Vectors.** `go/protocol/testdata/redaction-vectors.json` holds lines in and out, so a change to the rules shows. @@ -533,9 +558,17 @@ above and its approval key, and an agent can never reach them. | `POST /api/agents/{id}/widen {epoch, scopes, ops, caps, ttlS}` | The same | | `POST /api/agent-requests/{reqId} {approve: true}` | The same | | `POST /api/agents/approval-key {deviceId, pub}` | The same: a phone that lost its key | +| `POST /api/agents/clock-reset` | The same. Ends every agent's grant | | `POST /api/agent-requests/{reqId} {approve: false}` | A client on the LAN | | `DELETE /api/agents/{id}` | A client on the LAN | +- A request that brings a house password, and a wrong one, is refused on + every route, the narrowing ones too. +- `clock-reset` is for a clock that once ran ahead. The box never trusts a + wall time below the highest it has seen, so until the clock passes that + time again every agent is locked out. The reset makes now the mark and + ends every agent's grant, since a grant made while the clock ran ahead + would end years late. - `offer` is the [signed offer](#the-grant-offer), base64url. The box checks its signature against the offer keys built into it and refuses one that is unsigned, expired, or for another home. The page gets it from the owner: @@ -546,8 +579,15 @@ above and its approval key, and an agent can never reach them. - **Every route that widens access needs the house password,** from any address, loopback included: a driver's Lua and the session passthrough can both reach the box from inside it. -- **Drivers never reach the box.** A driver's HTTP request to a loopback - address, or to an address of the box itself, is refused. +- **Drivers never reach the box's API.** A driver's HTTP, TCP or WebSocket + connection to a port the API listens on is refused, whether it goes to a + loopback address or to an address of the box. The check runs on the + address dialled, after any name lookup. Other ports stay open: a driver + may talk to a helper on the box that its configuration names, such as a + bridge on `127.0.0.1:8787`. A reverse proxy, or Home Assistant ingress, + that serves the API on another port is not covered by this check. There + the house password still guards every widening route, and the proxy's + forwarding headers make those routes refuse. What the sign-in is worth today: @@ -567,7 +607,9 @@ What the sign-in is worth today: address. Narrowing stays open to any LAN client, as `DELETE /api/cloud/grant` does, -because removing access is safe. +because removing access is safe. With `api.lan_auth` on, every route here, +the reads and the narrowing ones too, needs the sign-in, as every protected +route on the box does. ## The grant offer @@ -618,8 +660,10 @@ old one signed has expired. client at FTW Cloud. The app and the box refuse a support offer from any other client. - **The box** takes an offer whose `home` is empty or its own. -- The owner may grant less than the offer asks. FTW Cloud learns the outcome - from the roster: the grant is whatever the roster lists for that key. +- The owner may grant less than the offer asks, never more. The box page + refuses a scope, an op, a cap or a `ttlS` beyond the offer's. FTW Cloud + learns the outcome from the roster: the grant is whatever the roster lists + for that key. ## Push kinds @@ -631,17 +675,21 @@ old one signed has expired. its approval key. - `owner.added` goes to every owner when a phone pairs as an owner. -Today these travel as events, and with the readable copy off, or without -events in it, FTW Cloud carries no events. `agent.added`, -`approval.key.set` and `owner.added` are to go as notices that carry no data, -whenever the link is up, copy or not; that is FTW Cloud protocol work still -to come. +These are security notices. They go whatever the owner's notification +settings say, through the box's own transports, and to FTW Cloud as a +`notice {kind}` that carries nothing but the kind, whenever the link is up, +copy or not (docs/protocol-cloud.md). FTW Cloud turns a notice into a push +to the home's phones; until it does, it drops the class, as it drops any +box message it does not know. ## The audit log The box writes a row for every call an agent makes and for every change to -an agent grant or an approval key, whoever makes it, and folds an agent's -reads by the minute. Rows live in the box's state store, at most 10,000 and +an agent grant or an approval key, whoever makes it. An agent's reads and +refusals fold by the minute: one row per agent, message, `by` and result, +with a count, so junk cannot crowd out the rows that matter. An agent's +rows come from a budget of their own and never push out an owner's or the +box's. Rows live in the box's state store, at most 10,000 and at most 90 days. An owner reads them with `audit.get` or on the box page. FTW Cloud keeps its own log of what it forwarded; neither replaces the other. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 8bb02ba9..abfc648c 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -531,6 +531,17 @@ record cannot turn a withdrawn consent back on. **`stats.withdraw`** `{statsId}`. The service deletes that id's rows within 24 hours. +**`notice`**, whenever the box is linked, with the readable copy on or off. + +| Field | Type | Meaning | +|---|---|---| +| `kind` | text | One of `agent.added`, `agent.request`, `agent.expiring`, `approval.key.set`, `owner.added` | + +A security notice: someone gained access, asks for a change, is about to +lose access, or can now approve access. The service sends each owner device +registered for alerts a push with no data beyond the kind, which the phone +words from its catalogue. See [Agents](agents.md). + **`bye`** `{why, purge}`. `why` is `revoked`, `shutdown` or `update`. `purge: true` asks the service to delete the home. `why: "update"` holds back `box.unreachable` until the box returns or an hour passes. diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go new file mode 100644 index 00000000..f59de4bb --- /dev/null +++ b/go/cmd/ftw/agents.go @@ -0,0 +1,542 @@ +package main + +// Agents: an owner's grants to an assistant, Sourceful support or the release +// check (docs/agents.md). Enrolment holds the grants and checks every +// approval; the app session enforces them on every call; this file wires the +// clock, the audit log, the log lines an agent may read, the configuration +// it may read, the owners' push and the sweep that removes expired rows. + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/redact" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// agentWall is the wall clock agents expire by, and whether it can be +// trusted: NTP has set it, and it has not gone back below the highest time +// the box has seen. Setting the clock back must not revive an expired grant. +var agentWall = &wallMark{} + +type wallMark struct { + mu sync.Mutex + highMs int64 + saved int64 +} + +const wallHighKey = "agents_wall_high_ms" + +func agentClock() (time.Time, bool) { return agentWall.now() } + +func (w *wallMark) now() (time.Time, bool) { + now := time.Now() + ok := cloudlink.ClockSynced() + w.mu.Lock() + defer w.mu.Unlock() + switch { + case ok && now.UnixMilli() < w.highMs: + return now, false + case ok: + w.highMs = now.UnixMilli() + } + return now, ok +} + +// load reads the mark the box kept, so a clock set back across a restart +// counts as none too. +func (w *wallMark) load(st *state.Store) { + if st == nil { + return + } + if raw, ok := st.LoadConfig(wallHighKey); ok { + if v, err := strconv.ParseInt(raw, 10, 64); err == nil { + w.mu.Lock() + w.highMs = max(w.highMs, v) + w.saved = v + w.mu.Unlock() + } + } +} + +// reset makes now the mark, lower or not, and keeps it. +func (w *wallMark) reset(st *state.Store, nowMs int64) error { + w.mu.Lock() + w.highMs, w.saved = nowMs, nowMs + w.mu.Unlock() + if st == nil { + return nil + } + return st.SaveConfig(wallHighKey, strconv.FormatInt(nowMs, 10)) +} + +// save keeps the mark, at most once a sweep. +func (w *wallMark) save(st *state.Store) { + if st == nil { + return + } + w.mu.Lock() + high, saved := w.highMs, w.saved + w.mu.Unlock() + if high <= saved { + return + } + if err := st.SaveConfig(wallHighKey, strconv.FormatInt(high, 10)); err == nil { + w.mu.Lock() + w.saved = high + w.mu.Unlock() + } +} + +// agentPorts is what agents need from the rest of the box. +type agentPorts struct { + logs *telemetry.LogRing + cfg *config.Config + cfgMu *sync.RWMutex + st *state.Store + // notices is bound to the notification service once it exists. + notices *cloudNotices + // home is FTW Cloud's home id for this box, bound once the link exists. + home func() string + // notice sends FTW Cloud a security notice for the owners' phones, + // bound once the link exists. It goes copy or not. + notice func(kind string) bool + + // Filled in by startAppLink. + access *appuplink.AgentAccess + state *appproto.AgentState + audit appproto.AuditLog + // boxPage builds a handler for the box page's own decisions. + boxPage func(by string) (*appproto.Handler, error) + // key cuts every id an agent sees, and the redactor's. + key []byte + // warned holds the rows already told they expire within a day. + warnMu sync.Mutex + warned map[string]bool +} + +// push tells every owner about a change to who may reach the box: through +// the box's own transports, and as a data-free notice through FTW Cloud. +func (p *agentPorts) push(kind string) { + if p.notice != nil { + p.notice(kind) + } + if p.notices == nil { + return + } + if svc := p.notices.svc.Load(); svc != nil { + svc.NotifyAgents(kind) + } +} + +// agentKey is the key every id an agent sees is cut with, derived from the +// box's own secret, so ids are stable on this box and mean nothing on +// another or in the readable copy. +func agentKey(enroll *appenroll.Identity) []byte { return enroll.DeriveKey("ftw.agent.ids.v1") } + +// redactor scrubs with the agent key and every name the configuration +// knows. Built per read, so a renamed driver is scrubbed at once. +func (p *agentPorts) redactor() *redact.Redactor { + return redact.New(p.key, p.names()) +} + +// names is everything the configuration calls something. +func (p *agentPorts) names() []string { + if p.cfg == nil { + return nil + } + p.cfgMu.RLock() + defer p.cfgMu.RUnlock() + out := []string{p.cfg.Site.Name} + for _, d := range p.cfg.Drivers { + out = append(out, d.Name) + } + for _, lp := range p.cfg.Loadpoints { + out = append(out, lp.ID, lp.DriverName) + } + for _, v := range p.cfg.Vehicles { + out = append(out, v.ID, v.Name) + out = append(out, v.Identifiers...) + } + return out +} + +// configDoc is the configuration as plain JSON values, for redaction. +func (p *agentPorts) configDoc() (any, error) { + p.cfgMu.RLock() + raw, err := json.Marshal(p.cfg) + p.cfgMu.RUnlock() + if err != nil { + return nil, err + } + var doc any + return doc, json.Unmarshal(raw, &doc) +} + +// ringLogs is the log ring as agents read it. +type ringLogs struct{ ring *telemetry.LogRing } + +func (r ringLogs) Recent(n int) []redact.Line { + entries := r.ring.RecentGlobal(n) + out := make([]redact.Line, 0, len(entries)) + for _, e := range entries { + out = append(out, redact.Line{TsMs: e.TS.UnixMilli(), Level: e.Level, Msg: e.Msg, Driver: e.Driver, Attrs: e.Attrs}) + } + return out +} + +// stateAudit writes audit rows to the state store on a goroutine of its +// own, so a call never waits on the disk. An agent's row may be dropped when +// the queue is full, and is counted; an owner's or the box's never is. +type stateAudit struct { + st *state.Store + rows chan protocol.AuditRow + owners chan protocol.AuditRow + dropped atomic.Int64 +} + +func newStateAudit(ctx context.Context, st *state.Store) *stateAudit { + a := &stateAudit{st: st, rows: make(chan protocol.AuditRow, 256), owners: make(chan protocol.AuditRow, 1024)} + go func() { + for { + select { + case <-ctx.Done(): + return + case r := <-a.owners: + a.write(r) + case r := <-a.rows: + a.write(r) + } + } + }() + return a +} + +func (a *stateAudit) write(r protocol.AuditRow) { + if err := a.st.AppendAgentAudit(state.AgentAuditRow{AtMs: r.AtMs, Actor: r.Actor, Role: r.Role, + Principal: r.Principal, By: r.By, What: r.What, Target: r.Target, Result: r.Result, + Count: int64(r.Count)}); err != nil { + slog.Warn("could not write the agent audit log", "err", err) + } +} + +func (a *stateAudit) Append(r protocol.AuditRow) { + if !strings.HasPrefix(r.Actor, apiauth.KindAgent+":") { + select { + case a.owners <- r: + default: + // Full: write it now rather than lose an owner's row. + a.write(r) + } + return + } + select { + case a.rows <- r: + default: + if n := a.dropped.Add(1); n == 1 || n%100 == 0 { + slog.Warn("agent audit queue full; an agent's rows dropped", "dropped", n) + } + } +} + +func (a *stateAudit) Page(beforeSeq uint64, n int) ([]protocol.AuditRow, bool, error) { + rows, more, err := a.st.AgentAudit(int64(beforeSeq), n) + out := make([]protocol.AuditRow, 0, len(rows)) + for _, r := range rows { + out = append(out, protocol.AuditRow{Seq: uint64(r.Seq), AtMs: r.AtMs, Actor: r.Actor, Role: r.Role, + Principal: r.Principal, By: r.By, What: r.What, Target: r.Target, Result: r.Result, + Count: uint64(max(r.Count, 0))}) + } + return out, more, err +} + +// sweepAgents removes expired agent rows every half minute and ends their +// sessions. Each call checks the clock on its own; this keeps the list and +// the roster short. +func sweepAgents(ctx context.Context, p *agentPorts) { + tick := time.NewTicker(30 * time.Second) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + sweepOnce(p) + } +} + +// agentGone finishes an agent row a removal took: its holds end, its +// requests and its minute are forgotten, and the audit log says who. +func (p *agentPorts) agentGone(id, by string) { + if p.access == nil { + return + } + p.access.EndHolds(id) + p.state.Forget(id) + if p.audit != nil { + now, ok := agentClock() + at := now.UnixMilli() + if !ok { + at = 0 + } + p.audit.Append(protocol.AuditRow{AtMs: at, Actor: by, What: "agent.remove", Target: id, Result: "ok"}) + } +} + +// sweepOnce removes expired rows, warns owners of grants that end within a +// day, writes the reads folded into minutes that have ended, and keeps the +// clock's mark. +func sweepOnce(p *agentPorts) { + now, synced := agentClock() + for _, gone := range p.access.ExpireAgents() { + p.state.Forget(gone.ID) + if p.audit != nil { + p.audit.Append(protocol.AuditRow{AtMs: now.UnixMilli(), Actor: "box", What: "agent.expire", + Target: gone.ID, Result: protocol.RequestExpired}) + } + } + if synced { + p.warnExpiring(now.UnixMilli()) + } + p.state.FlushAudit(p.audit, now.UnixMilli()) + p.state.Sweep(now.UnixMilli()) + agentWall.save(p.st) +} + +// warnExpiring pushes agent.expiring once for each grant that ends within a +// day. +func (p *agentPorts) warnExpiring(nowMs int64) { + p.warnMu.Lock() + defer p.warnMu.Unlock() + if p.warned == nil { + p.warned = map[string]bool{} + } + for _, row := range p.access.Agents() { + key := row.ID + ":" + strconv.FormatInt(row.ExpiresAtMs, 10) + if row.ExpiresAtMs-nowMs <= 24*60*60*1000 && !p.warned[key] { + p.warned[key] = true + p.push(protocol.PushAgentExpiring) + } + } +} + +// views builds the typed reads' bodies from the same source as FTW Cloud's +// copy, with the agent key's ids. +type agentViews struct{ site *cloudSite } + +func (v agentViews) Live(key []byte) cloudwire.Live { + return cloudlink.ProjectLive(key, time.Since(v.site.site.started).Milliseconds(), v.site.Live()) +} + +func (v agentViews) Loadpoints(key []byte) (cloudwire.LP, bool) { + lp, ok := v.site.Loadpoints() + if !ok { + return cloudwire.LP{}, false + } + return cloudlink.ProjectLP(key, lp), true +} + +// endAgentHolds releases every hold an agent set, by its subject. +func endAgentHolds(lp appproto.Loadpoints) func(subject string) { + return func(subject string) { + if lp == nil { + return + } + now := time.Now() + for _, r := range lp.Readings() { + if lp.ClearHoldIfOwn(r.ID, subject, now) { + slog.Info("released an agent's hold", "loadpoint", r.ID, "agent", subject) + } + } + } +} + +// boxPageAgents is the box page's view of agents (api.AgentAdmin). +type boxPageAgents struct{ p *agentPorts } + +func (b boxPageAgents) Agents() []protocol.AgentRow { return b.p.access.Agents() } + +func (b boxPageAgents) ApprovalKeys() []protocol.ApprovalKeyInfo { return b.p.access.ApprovalKeys() } + +func (b boxPageAgents) Requests() []protocol.Request { + h, err := b.p.boxPage("box") + if err != nil { + return nil + } + defer h.Close() + return h.Requests() +} + +func (b boxPageAgents) Home() string { + if b.p.home == nil { + return "" + } + return b.p.home() +} + +func (b boxPageAgents) Wall() (int64, bool) { return b.p.access.Wall() } + +// OpenOffer checks a signed offer against the offer keys this build pins. +func (b boxPageAgents) OpenOffer(raw string) (protocol.GrantOffer, error) { + signed, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return protocol.GrantOffer{}, protocol.ErrOfferShape + } + var so protocol.SignedOffer + if err := protocol.UnmarshalSigned(signed, &so); err != nil { + return protocol.GrantOffer{}, protocol.ErrOfferShape + } + now, synced := b.p.access.Wall() + if !synced { + return protocol.GrantOffer{}, protocol.ErrOfferExpired + } + return protocol.OpenOffer(cloudlink.ParseOfferKeys(cloudlink.OfferKeys), so, now) +} + +// ReplaceApprovalKey sets a phone's approval key from the box page. +func (b boxPageAgents) ReplaceApprovalKey(by, deviceID string, pub []byte) error { + _, err := b.p.access.Enroll.ReplaceApprovalKey(deviceID, pub) + b.record(by, protocol.MsgApprovalKeySet, deviceID, err) + if err == nil { + b.p.push(protocol.PushApprovalKeySet) + } + return err +} + +func (b boxPageAgents) record(by, what, target string, err error) { + if b.p.audit == nil { + return + } + result := "ok" + if err != nil { + result = "refused" + if r, ok := err.(*protocol.Refusal); ok { + result = r.Code + } + } + now, ok := b.p.access.Wall() + if !ok { + now = 0 + } + b.p.audit.Append(protocol.AuditRow{AtMs: now, Actor: by, Role: apiauth.RoleOwner, What: what, Target: target, Result: result}) +} + +func (b boxPageAgents) AddAgent(by string, m protocol.AgentAdd) (protocol.AgentRow, error) { + row, err := b.p.access.Enroll.AddAgent(appenroll.AtTheBoxPage, m) + b.record(by, protocol.MsgAgentAdd, row.ID, err) + if err == nil { + b.p.push(protocol.PushAgentAdded) + } + return row, err +} + +func (b boxPageAgents) WidenAgent(by string, m protocol.AgentWiden) (protocol.AgentRow, error) { + row, err := b.p.access.Enroll.WidenAgent(appenroll.AtTheBoxPage, m) + b.record(by, protocol.MsgAgentWiden, m.ID, err) + if err == nil { + if !slices.Contains(row.Ops, protocol.OpLoadpointHold) || !slices.Contains(row.Scopes, protocol.ScopeDispatchHold) { + b.p.access.EndHolds(row.ID) + } + b.p.push(protocol.PushAgentAdded) + } + return row, err +} + +func (b boxPageAgents) RevokeAgent(by, id string) error { + if _, ok := b.p.access.Enroll.Agent(id); !ok { + err := protocol.Refuse(protocol.ErrNotFound, map[string]any{"id": id}) + b.record(by, protocol.MsgAgentRevoke, id, err) + return err + } + err := b.p.access.RevokeAgent(id) + b.record(by, protocol.MsgAgentRevoke, id, err) + if err == nil { + b.p.state.Forget(id) + } + return err +} + +// ResetClock is for a clock that once ran ahead: until it passes that time +// again, the box trusts no wall time and every agent stays locked out. The +// owner says the clock is right now. Every agent's grant ends with it, since +// a grant made while the clock ran ahead would end years late. +func (b boxPageAgents) ResetClock(by string) error { + if !cloudlink.ClockSynced() { + err := protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"}) + b.record(by, "agents.clock.reset", "", err) + return err + } + for _, row := range b.p.access.Agents() { + if err := b.RevokeAgent(by, row.ID); err != nil { + return err + } + } + err := agentWall.reset(b.p.st, time.Now().UnixMilli()) + b.record(by, "agents.clock.reset", "", err) + return err +} + +func (b boxPageAgents) Decide(ctx context.Context, by, reqID string, approve bool) (string, *protocol.CmdResult, error) { + h, err := b.p.boxPage(by) + if err != nil { + return "", nil, err + } + defer h.Close() + return h.Decide(ctx, reqID, approve) +} + +func (b boxPageAgents) Audit(beforeSeq int64, n int) ([]protocol.AuditRow, bool, error) { + if b.p.audit == nil { + return nil, false, nil + } + return b.p.audit.Page(uint64(max(beforeSeq, 0)), n) +} + +// boxPageGrant is the box page's grant: an owner, always, since whoever is +// signed in there may do anything the box can. +type boxPageGrant struct{} + +func (boxPageGrant) Grant() (appproto.Enrolment, bool) { + return appproto.Enrolment{Role: apiauth.RoleOwner, Epoch: 1}, true +} + +// discardSender takes frames nobody reads: the box page reads a command's +// outcome from the command log instead. +type discardSender struct{} + +func (discardSender) Send([]byte) error { return nil } + +// boxPageCaller is the box page's caller for one decision. +func boxPageCaller(by string) apiauth.Caller { + return apiauth.Caller{Subject: by, Kind: apiauth.KindLAN, Role: apiauth.RoleOwner, + Scopes: appproto.ScopesForRole(apiauth.RoleOwner), Epoch: 1} +} + +// agentsForAPI hands the box page its agents, or nothing when the app link +// is off: an untyped nil, so the routes see no admin rather than a typed nil +// that panics. +func agentsForAPI(p *agentPorts) api.AgentAdmin { + if p == nil || p.access == nil { + return nil + } + return boxPageAgents{p: p} +} diff --git a/go/cmd/ftw/agents_clock_test.go b/go/cmd/ftw/agents_clock_test.go new file mode 100644 index 00000000..0553a0c3 --- /dev/null +++ b/go/cmd/ftw/agents_clock_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/state" +) + +// A reset makes now the mark even when now is lower, and the mark it keeps +// is the one a restart reads. +func TestTheClockMarkResetsLower(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + w := &wallMark{highMs: 4_000_000_000_000} + w.saved = w.highMs + if err := w.reset(st, 1_800_000_000_000); err != nil { + t.Fatal(err) + } + again := &wallMark{} + again.load(st) + if again.highMs != 1_800_000_000_000 || w.highMs != 1_800_000_000_000 { + t.Fatalf("mark %d, after a restart %d", w.highMs, again.highMs) + } +} diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 8e028d33..a64f0e9b 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -26,6 +26,7 @@ import ( "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" ) // The FTW app's uplink, wired to the box. @@ -440,6 +441,14 @@ func (a *appLoadpoints) ObservedHold(id string, now time.Time) (loadpoint.Manual return a.ctrl.GetManualHold(id, now) } +func (a *appLoadpoints) HoldIfOwn(id string, h loadpoint.ManualHold, now time.Time) bool { + return a.ctrl.SetManualHoldIfOwn(id, h, now) +} + +func (a *appLoadpoints) ClearHoldIfOwn(id, setBy string, now time.Time) bool { + return a.ctrl.ClearManualHoldIfOwn(id, setBy, now) +} + func (a *appLoadpoints) Boost(id string, lease loadpoint.BatteryBoostLease, now time.Time) error { if _, err := a.ctrl.EnableBatteryBoost(id, lease, now); err != nil { return err @@ -650,6 +659,7 @@ func startAppLink( gateway *lateAPI, webPush *notifications.WebPush, cloud *cloudSession, + agents *agentPorts, ) (*appenroll.Identity, *appuplink.Uplink, bool, error) { enabled := cfg != nil && cfg.AppLink.On() @@ -657,6 +667,12 @@ func startAppLink( if err != nil { return nil, nil, enabled, err } + // Agent rows expire by the wall clock, and do nothing without one, nor + // below the highest time the box has seen. + agentWall.load(st) + enroll.SetClock(agentClock) + agents.key = agentKey(enroll) + enroll.OnOwnerAdded(func() { agents.push(protocol.PushOwnerAdded) }) if !enabled { return enroll, nil, false, nil } @@ -738,6 +754,20 @@ func startAppLink( // says so, so a phone offers "Add watch". caps = append(caps, appproto.CapCmdExchange) + // Agents (docs/agents.md): an owner's grants to an assistant, Sourceful + // support or the release check. They reach the box through FTW Cloud's + // tunnel only, and every call meets its grant here. + agents.access = &appuplink.AgentAccess{Enroll: enroll, Clock: agentClock, Push: agents.push, + Holds: endAgentHolds(loadpoints)} + agents.state = appproto.NewAgentState() + if st != nil { + agents.audit = newStateAudit(ctx, st) + } + caps = append(caps, appproto.CapAccessAgents) + // One view for every agent session, so a charger's plug-in count is + // the same whichever session reads it. + views := agentViews{site: &cloudSite{site: site, api: gateway}} + // handlerFor builds one handler for one session: a phone's stream, or a // watch's exchange. They share the ports above, which are read-only // apart from the mode and the command log, and the mode goes through @@ -765,6 +795,14 @@ func startAppLink( Grants: grants, Commands: cmds, Exchange: exchange, + Access: agents.access, + Agents: agents.state, + Audit: agents.audit, + Logs: ringLogs{ring: agents.logs}, + Redactor: agents.redactor, + Views: views, + AgentKey: agents.key, + ConfigDoc: agents.configDoc, // FTW Cloud is asked per session: its link is bound after // this returns, and its grant moves while the box runs. Caps: cloud.caps(caps), @@ -790,6 +828,11 @@ func startAppLink( if err != nil { return enroll, nil, enabled, err } + agents.access.Drop = func(key []byte) { uplink.DropSessionsByAppKey(key) } + agents.boxPage = func(by string) (*appproto.Handler, error) { + return handlerFor(false)(discardSender{}, boxPageCaller(by), boxPageGrant{}) + } + go sweepAgents(ctx, agents) go func() { if err := uplink.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { @@ -866,6 +909,9 @@ func (l *lateAPI) Route(r *http.Request) apiauth.RouteFacts { type appLinkAPI struct { enroll *appenroll.Identity uplink *appuplink.Uplink + // agents ends the holds and the state of agent rows a removal takes + // with it. Nil while the app link is off. + agents *agentPorts // st sweeps a revoked phone's push subscriptions. Nil in embeddings // without storage; the sweep is then vacuously done. st *state.Store @@ -934,7 +980,19 @@ func (a *appLinkAPI) Devices() []api.AppDevice { // was withdrawn, which for a demotion is not true and for a promotion is the // opposite of true. func (a *appLinkAPI) SetDeviceRole(id, role string) error { - return appLinkError(a.enroll.SetRole(id, role)) + if err := a.enroll.SetRole(id, role); err != nil { + return appLinkError(err) + } + // A phone that is no longer an owner vouches for no control: the holds + // of the agents it approved end now. + if role != apiauth.RoleOwner && a.agents != nil && a.agents.access != nil { + for _, row := range a.agents.access.Agents() { + if row.ApprovedBy == apiauth.KindApp+":"+id { + a.agents.access.EndHolds(row.ID) + } + } + } + return nil } // RevokeDevice locks one device out, and a phone's watches with it. atTheBox @@ -942,10 +1000,24 @@ func (a *appLinkAPI) SetDeviceRole(id, role string) error { // thing that decides whether the last owner may go; Presence is enrollment's // word for the same fact. func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error { + agentRows := map[string]bool{} + if a.agents != nil && a.agents.access != nil { + for _, row := range a.agents.access.Agents() { + agentRows[row.ID] = true + } + } removed, err := a.enroll.Revoke(id, appenroll.Presence(atTheBox)) if err != nil { return appLinkError(err) } + // An agent row this took, by its id or with the phone that approved it, + // goes the way a revoke goes: its holds end, its state goes, and the + // audit log says so. + for _, gone := range removed { + if agentRows[gone.ID] { + a.agents.agentGone(gone.ID, "app-link") + } + } var swept int64 for _, gone := range removed { // Forgetting the key locks the next handshake out; dropping the @@ -1005,9 +1077,9 @@ func appLinkError(err error) error { // panics on first use. Returning an untyped nil is the difference between // "pairing is off" and a crash on the one screen someone reaches for when // nothing else works. -func appEnrollForAPI(enroll *appenroll.Identity, uplink *appuplink.Uplink, st *state.Store, enabled bool) api.AppEnroller { +func appEnrollForAPI(enroll *appenroll.Identity, uplink *appuplink.Uplink, st *state.Store, enabled bool, agents *agentPorts) api.AppEnroller { if !enabled || enroll == nil { return nil } - return &appLinkAPI{enroll: enroll, uplink: uplink, st: st} + return &appLinkAPI{enroll: enroll, uplink: uplink, st: st, agents: agents} } diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index 08aa2459..67cba77f 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -105,6 +105,7 @@ type noPhones struct{} func (noPhones) Devices() []cloudlink.RosterDevice { return nil } func (noPhones) Pending() ([]byte, time.Time, bool) { return nil, time.Time{}, false } +func (noPhones) Noise() []byte { return make([]byte, 32) } func newTestCloudLink(t *testing.T, keys []*ecdsa.PublicKey) *cloudlink.Link { t.Helper() diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 8c5d4caa..42c0a42e 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -474,6 +474,11 @@ func main() { slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", explainBindError(httpSrv.Addr, err)) os.Exit(1) } + // A driver's request from inside the box would reach the API as the box + // itself, so drivers may not dial its port. + if tcp, ok := listener.Addr().(*net.TCPAddr); ok { + drivers.SetSelfAPIPorts(tcp.Port) + } go func() { slog.Info("HTTP API listening (boot phase)", "addr", httpSrv.Addr) if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed { @@ -2426,10 +2431,14 @@ func main() { appAPI := &lateAPI{} // FTW Cloud as app sessions see it, bound below once the link exists. appCloud := &cloudSession{} + // Owners hear about agents through the notification service, bound + // below once it exists, as FTW Cloud's grant notices are. + cloudNotify := &cloudNotices{} + agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs, - controlRev, appLinkWatchdog, appAPI, webPush, appCloud, + controlRev, appLinkWatchdog, appAPI, webPush, appCloud, agents, ) switch { case appLinkErr != nil: @@ -2449,10 +2458,13 @@ func main() { if appUplink != nil { appTunnel = appUplink.Tunnel() } - cloudNotify := &cloudNotices{} cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel) appCloud.bind(cloudLink) + if cloudLink != nil { + agents.home = cloudLink.Home + agents.notice = cloudLink.Notice + } // The anonymous fleet ping: how many boxes, which versions, which drivers. // Its separate relay HTTP door keeps daily totals; the encrypted app path @@ -2485,7 +2497,8 @@ func main() { State: st, CapMu: capMu, Capacities: capacities, TelemetryCapacities: telemetryCapacities, BatteryIdentity: batteryIdentity, - AppEnroll: appEnrollForAPI(appEnroll, appUplink, st, appLinkEnabled), + AppEnroll: appEnrollForAPI(appEnroll, appUplink, st, appLinkEnabled, agents), + Agents: agentsForAPI(agents), FleetPing: fleetPinger, Cloud: cloudForAPI(cloudLink), CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index afb61644..3b6500a5 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -102,6 +102,10 @@ type Deps struct { // which the pairing routes report rather than hiding. AppEnroll AppEnroller + // Agents is the box page's view of agent grants (docs/agents.md). Nil + // when the app link is off; the routes then answer 503. + Agents AgentAdmin + // FleetPing is the once-a-day anonymous count. Held so Settings can render // the exact payload the sender would post, rather than a second rendering // that could quietly disagree with it. Nil in tests and minimal @@ -429,6 +433,7 @@ func (s *Server) routes() { s.handle("PATCH /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRole) s.handle("GET /api/fleet-ping", Read, s.handleFleetPing) s.cloudRoutes() + s.agentRoutes() s.handle("POST /api/mode", Actuate, s.handleSetMode, Via(appproto.OpSetMode)) s.handle("GET /api/planner/prefs", Read, s.handleGetPlannerPrefs) s.handle("POST /api/planner/prefs", Actuate, s.handleSetPlannerPrefs, Via(appproto.OpPlannerPrefsSet)) diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go new file mode 100644 index 00000000..522113d2 --- /dev/null +++ b/go/internal/api/api_agents.go @@ -0,0 +1,354 @@ +package api + +// Agents on the box's own page (docs/agents.md, Approval on the box page). +// +// An owner phone manages agents over its session, each grant signed by its +// approval key. On the home network the box is itself the authority, so +// these routes ask for no signature. They ask for something else: widening +// access needs the box itself or the house password. With api.lan_auth off, +// which is the default, every device on the home network is an owner with no +// sign-in at all, and granting a remote agent from there would let any of +// them open the house to the outside. Narrowing stays open to any LAN +// client, as DELETE /api/cloud/grant does. +// +// Every route is local: the session passthrough never carries them, so a +// phone must use its signed messages and an agent can never reach them. + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +// AgentAdmin is the box page's view of agent grants. by names the caller +// for the audit log: "box" for the box itself, or "lan:
". +type AgentAdmin interface { + Agents() []protocol.AgentRow + Requests() []protocol.Request + ApprovalKeys() []protocol.ApprovalKeyInfo + // Home is this box's home id at FTW Cloud, or "" before it links. + Home() string + // Wall is the wall clock in ms, and whether NTP has set it. + Wall() (int64, bool) + // OpenOffer checks a signed offer, base64url as the consent page gave + // it, against the offer keys this build pins. + OpenOffer(raw string) (protocol.GrantOffer, error) + // ReplaceApprovalKey sets an owner phone's approval key, for a phone + // that lost its own. + ReplaceApprovalKey(by, deviceID string, pub []byte) error + AddAgent(by string, m protocol.AgentAdd) (protocol.AgentRow, error) + WidenAgent(by string, m protocol.AgentWiden) (protocol.AgentRow, error) + RevokeAgent(by, id string) error + Decide(ctx context.Context, by, reqID string, approve bool) (string, *protocol.CmdResult, error) + Audit(beforeSeq int64, n int) ([]protocol.AuditRow, bool, error) + // ResetClock makes the box trust its clock again after it once ran + // ahead, and ends every agent's grant. + ResetClock(by string) error +} + +func (s *Server) agentRoutes() { + s.handle("GET /api/agents", Local, s.handleAgentsList) + s.handle("GET /api/agents/audit", Local, s.handleAgentsAudit) + s.handle("POST /api/agents", Local, s.handleAgentsAdd) + s.handle("POST /api/agents/{id}/widen", Local, s.handleAgentsWiden) + s.handle("DELETE /api/agents/{id}", Local, s.handleAgentsRevoke) + s.handle("POST /api/agent-requests/{reqId}", Local, s.handleAgentsDecide) + s.handle("POST /api/agents/approval-key", Local, s.handleAgentsApprovalKey) + s.handle("POST /api/agents/clock-reset", Local, s.handleAgentsClockReset) +} + +// handleAgentsClockReset is for a clock that once ran ahead and now keeps +// every agent locked out. It needs the house password, and every agent's +// grant ends. +func (s *Server) handleAgentsClockReset(w http.ResponseWriter, r *http.Request) { + by, ok := s.agentsGate(w, r, true) + if !ok { + return + } + if err := s.deps.Agents.ResetClock(by); err != nil { + agentError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// agentsGate admits a client on the home network, and with widen only one +// signed in with the house password, from any address: a driver's Lua and +// the session passthrough can both reach the box from inside it, so loopback +// proves nothing. It returns the caller's name for the audit log. +func (s *Server) agentsGate(w http.ResponseWriter, r *http.Request, widen bool) (string, bool) { + if s.deps == nil || s.deps.Agents == nil { + writeAppLinkError(w, http.StatusServiceUnavailable, "agents are not available on this box") + return "", false + } + auth, err := parseAuthority(r.Host) + if hasForwardingHeader(r.Header) || appLinkOverSession(r) || err != nil || + !isLocalAuthority(auth) || !isLocalClient(r.RemoteAddr) { + writeAppLinkError(w, http.StatusForbidden, "agents are managed in the app or on your home network") + return "", false + } + by := "lan:" + remoteHost(r) + if isLoopbackClient(r.RemoteAddr) { + by = "box" + } + ok, checked := lanSecretFrom(r.Context()) + if checked && !ok { + // A wrong house password is a refusal, even for a route that only + // narrows. + writeAppLinkError(w, http.StatusForbidden, "sign in with the house password") + return "", false + } + if !widen || ok { + return by, true + } + writeAppLinkError(w, http.StatusForbidden, + "give access in the app, or on your home network signed in with the house password") + return "", false +} + +// agentError answers a refusal with its code, and anything else as a 500. +func agentError(w http.ResponseWriter, err error) { + var r *protocol.Refusal + if !errors.As(err, &r) { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + status := http.StatusBadRequest + switch r.Code { + case protocol.ErrNotFound: + status = http.StatusNotFound + case protocol.ErrConflict: + status = http.StatusConflict + case protocol.ErrUnavailable: + status = http.StatusServiceUnavailable + case protocol.ErrScopeDenied: + status = http.StatusForbidden + } + writeJSON(w, status, map[string]any{"error": r.Code, "args": r.Args}) +} + +func (s *Server) handleAgentsList(w http.ResponseWriter, r *http.Request) { + if _, ok := s.agentsGate(w, r, false); !ok { + return + } + a := s.deps.Agents + writeJSON(w, http.StatusOK, protocol.AgentList{ + Agents: nonNil(a.Agents()), Requests: nonNil(a.Requests()), Keys: nonNil(a.ApprovalKeys()), + }) +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} + +func (s *Server) handleAgentsAudit(w http.ResponseWriter, r *http.Request) { + if _, ok := s.agentsGate(w, r, false); !ok { + return + } + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + rows, more, err := s.deps.Agents.Audit(before, n) + if err != nil { + agentError(w, err) + return + } + writeJSON(w, http.StatusOK, protocol.AuditPage{Rows: nonNil(rows), More: more}) +} + +// agentAddBody is a grant offer, as FTW Cloud's consent page shows it, and +// what the owner chose to grant of it. A field left out grants what the +// offer asks. +type agentAddBody struct { + Offer string `json:"offer"` + Scopes []string `json:"scopes,omitempty"` + Ops []string `json:"ops,omitempty"` + Caps *protocol.Caps `json:"caps,omitempty"` + TTLS uint64 `json:"ttlS,omitempty"` +} + +func (s *Server) handleAgentsAdd(w http.ResponseWriter, r *http.Request) { + by, ok := s.agentsGate(w, r, true) + if !ok { + return + } + var body agentAddBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is not a grant"}) + return + } + offer, err := s.deps.Agents.OpenOffer(body.Offer) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offer is not signed, has expired or is not readable"}) + return + } + if home := s.deps.Agents.Home(); offer.Home != "" && offer.Home != home { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offer is for another box"}) + return + } + now, synced := s.deps.Agents.Wall() + if !synced { + agentError(w, protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"})) + return + } + if !withinOffer(offer, body) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "the box may grant less than the offer, not more"}) + return + } + m := protocol.AgentAdd{ + Key: offer.Key, Role: offer.Role, Scopes: offer.Scopes, Ops: offer.Ops, Caps: offer.Caps, + Principal: protocol.Principal{Client: offer.Client, ClientID: offer.ClientID, Ticket: offer.Ticket}, + } + if body.Scopes != nil { + m.Scopes = body.Scopes + } + if body.Ops != nil { + m.Ops = body.Ops + } + if body.Caps != nil { + m.Caps = *body.Caps + } + ttl := offer.TTLS + if body.TTLS != 0 { + ttl = body.TTLS + } + m.ExpiresAtMs = now + int64(ttl)*int64(time.Second/time.Millisecond) + row, err := s.deps.Agents.AddAgent(by, m) + if err != nil { + agentError(w, err) + return + } + writeJSON(w, http.StatusCreated, row) +} + +// withinOffer reports whether what the owner chose on the page gives no more +// than the offer the agent's client signed for. +func withinOffer(offer protocol.GrantOffer, body agentAddBody) bool { + for _, scope := range body.Scopes { + if !slices.Contains(offer.Scopes, scope) { + return false + } + } + for _, op := range body.Ops { + if !slices.Contains(offer.Ops, op) { + return false + } + } + if c := body.Caps; c != nil { + perMin := func(n uint64) uint64 { + if n == 0 { + return protocol.PerMinDefault + } + return n + } + if c.HoldS > offer.Caps.HoldS || c.MaxW > offer.Caps.MaxW || perMin(c.PerMin) > perMin(offer.Caps.PerMin) { + return false + } + } + return body.TTLS <= offer.TTLS +} + +// agentWidenBody is a grant's new rights, whole. +type agentWidenBody struct { + Epoch uint64 `json:"epoch"` + Scopes []string `json:"scopes"` + Ops []string `json:"ops"` + Caps protocol.Caps `json:"caps"` + TTLS uint64 `json:"ttlS"` +} + +func (s *Server) handleAgentsWiden(w http.ResponseWriter, r *http.Request) { + by, ok := s.agentsGate(w, r, true) + if !ok { + return + } + var body agentWidenBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is not a grant"}) + return + } + now, synced := s.deps.Agents.Wall() + if !synced { + agentError(w, protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"})) + return + } + row, err := s.deps.Agents.WidenAgent(by, protocol.AgentWiden{ + ID: r.PathValue("id"), Epoch: body.Epoch, Scopes: body.Scopes, Ops: body.Ops, Caps: body.Caps, + ExpiresAtMs: now + int64(body.TTLS)*int64(time.Second/time.Millisecond), + }) + if err != nil { + agentError(w, err) + return + } + writeJSON(w, http.StatusOK, row) +} + +func (s *Server) handleAgentsRevoke(w http.ResponseWriter, r *http.Request) { + by, ok := s.agentsGate(w, r, false) + if !ok { + return + } + if err := s.deps.Agents.RevokeAgent(by, r.PathValue("id")); err != nil { + agentError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleAgentsDecide(w http.ResponseWriter, r *http.Request) { + var body struct { + Approve bool `json:"approve"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body needs approve"}) + return + } + // Approving needs what widening needs; declining is open, as removing + // access is. + by, ok := s.agentsGate(w, r, body.Approve) + if !ok { + return + } + state, res, err := s.deps.Agents.Decide(r.Context(), by, r.PathValue("reqId"), body.Approve) + if err != nil { + agentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"state": state, "result": res}) +} + +// handleAgentsApprovalKey sets an owner phone's approval key from the box +// page, for a phone whose key a new Face ID enrolment voided. +func (s *Server) handleAgentsApprovalKey(w http.ResponseWriter, r *http.Request) { + by, ok := s.agentsGate(w, r, true) + if !ok { + return + } + var body struct { + DeviceID string `json:"deviceId"` + Pub string `json:"pub"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body needs deviceId and pub"}) + return + } + pub, err := base64.RawURLEncoding.DecodeString(body.Pub) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pub is not base64url"}) + return + } + if err := s.deps.Agents.ReplaceApprovalKey(by, body.DeviceID, pub); err != nil { + agentError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/go/internal/api/api_agents_test.go b/go/internal/api/api_agents_test.go new file mode 100644 index 00000000..7002db70 --- /dev/null +++ b/go/internal/api/api_agents_test.go @@ -0,0 +1,308 @@ +package api + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll/approvaltest" + "github.com/srcfl/ftw/go/protocol" +) + +type stubAgents struct { + keys map[string][]byte + replaced []string + added []protocol.AgentAdd + revoked []string + decided []bool + by []string + home string + unsynced bool + resets int +} + +func (a *stubAgents) Agents() []protocol.AgentRow { return nil } +func (a *stubAgents) Requests() []protocol.Request { return nil } +func (a *stubAgents) ApprovalKeys() []protocol.ApprovalKeyInfo { return nil } +func (a *stubAgents) Home() string { return a.home } +func (a *stubAgents) Wall() (int64, bool) { return 1_000_000, !a.unsynced } +func (a *stubAgents) AddAgent(by string, m protocol.AgentAdd) (protocol.AgentRow, error) { + a.added = append(a.added, m) + a.by = append(a.by, by) + return protocol.AgentRow{ID: "agent001", Role: m.Role}, nil +} +func (a *stubAgents) WidenAgent(by string, m protocol.AgentWiden) (protocol.AgentRow, error) { + a.by = append(a.by, by) + return protocol.AgentRow{ID: m.ID}, nil +} +func (a *stubAgents) RevokeAgent(by, id string) error { + a.revoked = append(a.revoked, id) + a.by = append(a.by, by) + return nil +} +func (a *stubAgents) Decide(_ context.Context, by, _ string, approve bool) (string, *protocol.CmdResult, error) { + a.decided = append(a.decided, approve) + a.by = append(a.by, by) + return "approved", nil, nil +} +func (a *stubAgents) Audit(int64, int) ([]protocol.AuditRow, bool, error) { return nil, false, nil } +func (a *stubAgents) ResetClock(by string) error { + a.resets++ + a.by = append(a.by, by) + return nil +} +func (a *stubAgents) OpenOffer(raw string) (protocol.GrantOffer, error) { + signed, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return protocol.GrantOffer{}, err + } + var so protocol.SignedOffer + if err := protocol.UnmarshalSigned(signed, &so); err != nil { + return protocol.GrantOffer{}, err + } + return protocol.OpenOffer(a.keys, so, 1_000_000) +} +func (a *stubAgents) ReplaceApprovalKey(by, deviceID string, _ []byte) error { + a.replaced = append(a.replaced, deviceID) + a.by = append(a.by, by) + return nil +} + +// offerSigner is FTW Cloud's offer key, for the tests. +var offerSigner = approvaltest.New() + +func newStubAgents(home string) *stubAgents { + return &stubAgents{home: home, keys: map[string][]byte{"k1": offerSigner.Pub()}} +} + +func offerBody(t *testing.T, home string) string { + t.Helper() + return signedOfferBody(t, home, offerSigner) +} + +func signedOfferBody(t *testing.T, home string, signer *approvaltest.Signer) string { + t.Helper() + o, err := protocol.Marshal(protocol.GrantOffer{ + V: protocol.GrantOfferVersion, Home: home, Key: make([]byte, 32), Role: apiauth.RoleAgent, + Client: "Claude", Scopes: []string{protocol.ScopeLiveRead}, Ops: []string{}, TTLS: 3600, Code: "ABCD", + ExpMs: 1_000_000 + 60_000, + }) + if err != nil { + t.Fatal(err) + } + msg, err := protocol.OfferBytes(o) + if err != nil { + t.Fatal(err) + } + raw, err := protocol.Marshal(protocol.SignedOffer{O: o, Kid: "k1", Sig: signer.Sign(msg)}) + if err != nil { + t.Fatal(err) + } + return `{"offer":"` + base64.RawURLEncoding.EncodeToString(raw) + `"}` +} + +// Giving an agent access from the box page needs the house password, from +// any address: with lan_auth off every device on the Wi-Fi is an owner with +// no sign-in, and a driver or the passthrough can reach the box from inside. +func TestWhoMayGiveAnAgentAccessOnTheBoxPage(t *testing.T) { + body := offerBody(t, "home-1") + cases := []struct { + name string + req *http.Request + want int + }{ + {"the box itself, without the password", loopbackRequest(http.MethodPost, "/api/agents", body), http.StatusForbidden}, + {"a LAN client", localRequest(http.MethodPost, "/api/agents", body), http.StatusForbidden}, + {"a LAN client with the house password", func() *http.Request { + r := localRequest(http.MethodPost, "/api/agents", body) + return r.WithContext(withLANSecret(r.Context(), true)) + }(), http.StatusCreated}, + {"an owner's phone through the passthrough", sessionRequest(http.MethodPost, "/api/agents", body), http.StatusForbidden}, + {"through a proxy", func() *http.Request { + r := loopbackRequest(http.MethodPost, "/api/agents", body) + r.Header.Set("X-Forwarded-For", "203.0.113.9") + return r + }(), http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + agents := newStubAgents("home-1") + s := New(&Deps{Agents: agents}) + w := httptest.NewRecorder() + s.handleAgentsAdd(w, c.req) + if w.Code != c.want { + t.Fatalf("got %d, want %d: %s", w.Code, c.want, w.Body.String()) + } + if added := len(agents.added) == 1; added != (c.want == http.StatusCreated) { + t.Fatalf("added = %v with status %d", added, w.Code) + } + }) + } +} + +func signedIn(r *http.Request) *http.Request { return r.WithContext(withLANSecret(r.Context(), true)) } + +func TestTheBoxPageTakesASignedOfferAndItsLength(t *testing.T) { + agents := newStubAgents("home-1") + s := New(&Deps{Agents: agents}) + add := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.handleAgentsAdd(w, signedIn(localRequest(http.MethodPost, "/api/agents", body))) + return w + } + if w := add(offerBody(t, "")); w.Code != http.StatusCreated || len(agents.added) != 1 { + t.Fatalf("an offer that leaves the home to the owner: %d %s", w.Code, w.Body.String()) + } + m := agents.added[0] + if m.ExpiresAtMs != 1_000_000+3_600_000 || m.Principal.Client != "Claude" || agents.by[0] != "lan:192.168.1.5" { + t.Errorf("grant %+v by %v", m, agents.by) + } + if w := add(offerBody(t, "home-1")); w.Code != http.StatusCreated { + t.Errorf("an offer for this box: %d", w.Code) + } + if w := add(offerBody(t, "another-home")); w.Code != http.StatusBadRequest { + t.Errorf("an offer for another box: %d", w.Code) + } + if w := add(signedOfferBody(t, "", approvaltest.New())); w.Code != http.StatusBadRequest { + t.Errorf("an offer no pinned key signed: %d", w.Code) + } + more := func(extra string) string { + b := offerBody(t, "home-1") + return b[:len(b)-1] + "," + extra + "}" + } + for _, extra := range []string{`"scopes":["ftw.logs.read"]`, `"ops":["loadpoint.hold"]`, `"caps":{"holdS":60}`, `"ttlS":7200`} { + if w := add(more(extra)); w.Code != http.StatusBadRequest { + t.Errorf("more than the offer, %s: %d", extra, w.Code) + } + } + if w := add(more(`"scopes":[],"ttlS":60`)); w.Code != http.StatusCreated { + t.Errorf("less than the offer: %d %s", w.Code, w.Body.String()) + } + agents.unsynced = true + if w := add(offerBody(t, "home-1")); w.Code != http.StatusServiceUnavailable { + t.Errorf("without a clock: %d", w.Code) + } +} + +func TestALostApprovalKeyIsReplacedOnlyWithThePassword(t *testing.T) { + agents := newStubAgents("") + s := New(&Deps{Agents: agents}) + body := `{"deviceId":"ownerPh1","pub":"` + base64.RawURLEncoding.EncodeToString(approvaltest.New().Pub()) + `"}` + w := httptest.NewRecorder() + s.handleAgentsApprovalKey(w, localRequest(http.MethodPost, "/api/agents/approval-key", body)) + if w.Code != http.StatusForbidden || len(agents.replaced) != 0 { + t.Fatalf("without the password: %d", w.Code) + } + w = httptest.NewRecorder() + s.handleAgentsApprovalKey(w, signedIn(localRequest(http.MethodPost, "/api/agents/approval-key", body))) + if w.Code != http.StatusNoContent || len(agents.replaced) != 1 { + t.Fatalf("with the password: %d %s", w.Code, w.Body.String()) + } +} + +// Resetting the clock's mark ends every grant and needs the house password. +func TestTheClockResetNeedsThePassword(t *testing.T) { + agents := newStubAgents("") + s := New(&Deps{Agents: agents}) + w := httptest.NewRecorder() + s.handleAgentsClockReset(w, localRequest(http.MethodPost, "/api/agents/clock-reset", "")) + if w.Code != http.StatusForbidden || agents.resets != 0 { + t.Fatalf("without the password: %d", w.Code) + } + w = httptest.NewRecorder() + s.handleAgentsClockReset(w, signedIn(localRequest(http.MethodPost, "/api/agents/clock-reset", ""))) + if w.Code != http.StatusNoContent || agents.resets != 1 { + t.Fatalf("with the password: %d %s", w.Code, w.Body.String()) + } +} + +// Removing access and declining a request narrow it, so any LAN client may. +// Approving a request widens what an agent did, so it needs the password. +func TestNarrowingIsOpenOnTheLANAndApprovingIsNot(t *testing.T) { + agents := newStubAgents("") + s := New(&Deps{Agents: agents}) + + w := httptest.NewRecorder() + r := localRequest(http.MethodDelete, "/api/agents/agent001", "") + r.SetPathValue("id", "agent001") + s.handleAgentsRevoke(w, r) + if w.Code != http.StatusNoContent || len(agents.revoked) != 1 || agents.by[0] != "lan:192.168.1.5" { + t.Fatalf("revoke: %d %v %v", w.Code, agents.revoked, agents.by) + } + + w = httptest.NewRecorder() + r = localRequest(http.MethodPost, "/api/agent-requests/r1", `{"approve":false}`) + r.SetPathValue("reqId", "r1") + s.handleAgentsDecide(w, r) + if w.Code != http.StatusOK || len(agents.decided) != 1 { + t.Fatalf("decline: %d", w.Code) + } + + // A wrong house password is refused even where none is needed. + w = httptest.NewRecorder() + r = localRequest(http.MethodDelete, "/api/agents/agent002", "") + r = r.WithContext(withLANSecret(r.Context(), false)) + r.SetPathValue("id", "agent002") + s.handleAgentsRevoke(w, r) + if w.Code != http.StatusForbidden || len(agents.revoked) != 1 { + t.Fatalf("revoke with a wrong password: %d %v", w.Code, agents.revoked) + } + + w = httptest.NewRecorder() + r = localRequest(http.MethodPost, "/api/agent-requests/r1", `{"approve":true}`) + r.SetPathValue("reqId", "r1") + s.handleAgentsDecide(w, r) + if w.Code != http.StatusForbidden || len(agents.decided) != 1 { + t.Fatalf("approve without the password: %d", w.Code) + } + + w = httptest.NewRecorder() + r = localRequest(http.MethodPost, "/api/agent-requests/r1", `{"approve":true}`) + r = r.WithContext(withLANSecret(r.Context(), true)) + r.SetPathValue("reqId", "r1") + s.handleAgentsDecide(w, r) + if w.Code != http.StatusOK || len(agents.decided) != 2 || !agents.decided[1] { + t.Fatalf("approve with the password: %d", w.Code) + } +} + +// Every agent route is local: the passthrough never carries one. +func TestAgentRoutesAreLocal(t *testing.T) { + s := New(&Deps{Agents: newStubAgents("")}) + for _, c := range []struct{ method, path string }{ + {http.MethodGet, "/api/agents"}, + {http.MethodGet, "/api/agents/audit"}, + {http.MethodPost, "/api/agents"}, + {http.MethodPost, "/api/agents/abc/widen"}, + {http.MethodDelete, "/api/agents/abc"}, + {http.MethodPost, "/api/agent-requests/r1"}, + {http.MethodPost, "/api/agents/approval-key"}, + } { + if facts := s.Route(httptest.NewRequest(c.method, c.path, nil)); facts.Tier != apiauth.TierLocal { + t.Errorf("%s %s is %q", c.method, c.path, facts.Tier) + } + } +} + +// With the house password on, a LAN client that has not signed in reads +// neither the agents nor their audit log, and changes nothing. +func TestTheAgentRoutesNeedSignInWithLANAuthOn(t *testing.T) { + resetLANGuesses(t) + policy := lanAuthPolicy(testHousePassword) + for _, c := range []struct{ method, path string }{ + {http.MethodGet, "/api/agents"}, + {http.MethodGet, "/api/agents/audit"}, + {http.MethodDelete, "/api/agents/agent001"}, + {http.MethodPost, "/api/agent-requests/r1"}, + } { + if rr := serveLANAuth(policy, lanAuthRequest(c.method, "http://ftw.local:8080"+c.path, "192.168.1.5:1234", ""), nil); rr.Code != http.StatusUnauthorized { + t.Errorf("%s %s without sign-in: %d", c.method, c.path, rr.Code) + } + } + if rr := serveLANAuth(policy, lanAuthRequest(http.MethodGet, "http://ftw.local:8080/api/agents", "192.168.1.5:1234", "Bearer "+testHousePassword), nil); rr.Code != http.StatusNoContent { + t.Errorf("signed in: %d", rr.Code) + } +} diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index 3245c73d..5f149a64 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -366,6 +366,7 @@ type cloudRosterStub struct{} func (cloudRosterStub) Devices() []cloudlink.RosterDevice { return nil } func (cloudRosterStub) Pending() ([]byte, time.Time, bool) { return nil, time.Time{}, false } +func (cloudRosterStub) Noise() []byte { return make([]byte, 32) } type cloudKV struct { mu sync.Mutex diff --git a/go/internal/api/api_notifications_push_test.go b/go/internal/api/api_notifications_push_test.go index 3479c010..904bb7c3 100644 --- a/go/internal/api/api_notifications_push_test.go +++ b/go/internal/api/api_notifications_push_test.go @@ -386,9 +386,14 @@ func TestRulesPutSeedsDefaultsOnFirstTouch(t *testing.T) { if notif == nil || len(notif.Events) < len(notifications.DefaultRules()) { t.Fatalf("defaults not seeded: %+v", notif) } - // Everything is opt-in except the FTW Cloud notices, which every owner - // should hear without having asked. - onByDefault := map[string]bool{notifications.PushCloudGranted: true, notifications.PushCloudRevoked: true} + // Everything is opt-in except the FTW Cloud and agent notices, which + // every owner should hear without having asked. + onByDefault := map[string]bool{ + notifications.PushCloudGranted: true, notifications.PushCloudRevoked: true, + notifications.PushAgentAdded: true, notifications.PushAgentRequest: true, + notifications.PushAgentExpiring: true, notifications.PushApprovalKeySet: true, + notifications.PushOwnerAdded: true, + } enabled := 0 for _, e := range notif.Events { if e.Enabled && !onByDefault[e.Type] { diff --git a/go/internal/api/security.go b/go/internal/api/security.go index 46f78a65..db1b5cda 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -268,6 +268,8 @@ func protectedReadPath(path string) bool { "/api/notifications/vapid", "/api/device_repository/catalog", "/api/app-link/status", + "/api/agents", + "/api/agents/audit", "/api/ocpp/chargers": return true } diff --git a/go/internal/apiauth/apiauth.go b/go/internal/apiauth/apiauth.go index ae10d350..e2edc534 100644 --- a/go/internal/apiauth/apiauth.go +++ b/go/internal/apiauth/apiauth.go @@ -67,6 +67,9 @@ const ( // KindLAN is an unauthenticated request off the box's own LAN listener. // Naming it is not a promotion: it writes down what the LAN already is. KindLAN = "lan" + // KindAgent is an agent row (docs/agents.md): an assistant, Sourceful + // support or the release check, through FTW Cloud's tunnel only. + KindAgent = "agent" ) // AgentRole reports whether a role is an agent's: a grant an owner gave to an diff --git a/go/internal/appenroll/agents.go b/go/internal/appenroll/agents.go new file mode 100644 index 00000000..8bdeb9e0 --- /dev/null +++ b/go/internal/appenroll/agents.go @@ -0,0 +1,558 @@ +package appenroll + +// Agent rows: grants an owner gives to an assistant, to Sourceful support for +// one ticket, or to the release check (docs/agents.md). An agent row is an +// enrolment like a phone's, keyed by its Noise static key, with a grant of +// its own: scopes, ops, caps, a wall-clock expiry and who approved it. +// +// This file decides every grant and checks every approval. A row is added or +// widened only with an owner phone's approval signature or from the box's +// own page, and each check of a live row asks the clock again. + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "slices" + "sort" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +// LANApprover is ApprovedBy for a grant made on the box's own page. +const LANApprover = "lan" + +// storedAgent is an agent row's grant on disk. +type storedAgent struct { + Scopes []string `json:"scopes"` + Ops []string `json:"ops"` + Caps protocol.Caps `json:"caps"` + ExpiresAtMs int64 `json:"expiresAtMs"` + Principal protocol.Principal `json:"principal"` + // ApprovedBy is the full key of the approving phone, or LANApprover. + ApprovedBy string `json:"approvedBy"` +} + +// agentMeta is the same grant in memory. +type agentMeta = storedAgent + +// AgentGrant is an agent row's grant as it stands at this moment. +type AgentGrant struct { + Scopes []string + Ops []string + Caps protocol.Caps + ExpiresAtMs int64 + Principal protocol.Principal + // ApprovedBy is the approving phone's device id, or LANApprover. + ApprovedBy string + // Expired is true once the box's wall clock has passed ExpiresAtMs. + // NoClock is true while the box cannot tell, and then the row may do + // nothing. + Expired bool + NoClock bool +} + +// Approver is who approves a grant: an owner phone over its session, by its +// device id, or the box's own page when DeviceID is empty. +type Approver struct { + DeviceID string +} + +// AtTheBoxPage approves from the box's own page on the home network. +var AtTheBoxPage = Approver{} + +// SetClock gives enrolment the wall clock and whether NTP has set it. Until +// it is set, and whenever it answers false, no agent row may act. +func (i *Identity) SetClock(fn func() (time.Time, bool)) { + i.mu.Lock() + defer i.mu.Unlock() + i.wall = fn +} + +// wallLocked is the wall clock in ms, and whether it can be trusted. The +// caller holds the mutex. +func (i *Identity) wallLocked() (int64, bool) { + if i.wall == nil { + return 0, false + } + now, ok := i.wall() + if ok && i.sinceMs == 0 { + // The first trusted time this process saw: approvals signed before + // it are refused, since the replay memory starts here. A phone told + // E_CLOCK_SKEW signs again with the box's time. + i.sinceMs = now.UnixMilli() + } + return now.UnixMilli(), ok +} + +func refuse(code string, args map[string]any) error { return protocol.Refuse(code, args) } + +func badField(field string) error { + return refuse(protocol.ErrUnknownOp, map[string]any{"field": field}) +} + +func noClock() error { + return refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"}) +} + +// longest is the longest grant each agent role may have. +func longest(role string) int64 { + switch role { + case apiauth.RoleSupport: + return protocol.SupportMaxMs + case apiauth.RoleRelease: + return protocol.ReleaseMaxMs + default: + return protocol.AgentMaxMs + } +} + +func sortedUnique(list []string) bool { + return slices.IsSorted(list) && len(slices.Compact(slices.Clone(list))) == len(list) +} + +// checkGrant refuses a grant the role, the registry or the box's limits do +// not allow. It returns the caps with the default rate filled in. +func checkGrant(role string, scopes, ops []string, caps protocol.Caps, expiresAtMs int64, + principal protocol.Principal, nowMs int64) (protocol.Caps, error) { + if !apiauth.AgentRole(role) { + return caps, badField("role") + } + allowed := apiauth.RoleScopes[role] + if len(scopes) == 0 || !sortedUnique(scopes) { + return caps, badField("scopes") + } + for _, s := range scopes { + if !slices.Contains(allowed, s) { + return caps, refuse(protocol.ErrScopeDenied, map[string]any{"field": "scopes", "scope": s, "role": role}) + } + } + if !sortedUnique(ops) { + return caps, badField("ops") + } + for _, op := range ops { + need, ok := protocol.RegistryAgentOps[op] + if !ok || !slices.Contains(scopes, need) { + return caps, refuse(protocol.ErrOpNotGranted, map[string]any{"field": "ops", "op": op}) + } + } + if slices.Contains(scopes, protocol.ScopeDispatchHold) { + if caps.HoldS < protocol.HoldSMin || caps.HoldS > protocol.HoldSMax { + return caps, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "holdS", "min": protocol.HoldSMin, "max": protocol.HoldSMax}) + } + if caps.MaxW < protocol.MaxWMin || caps.MaxW > protocol.MaxWMax { + return caps, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "maxW", "min": protocol.MaxWMin, "max": protocol.MaxWMax}) + } + } else if caps.HoldS != 0 || caps.MaxW != 0 { + return caps, badField("caps") + } + if caps.PerMin == 0 { + caps.PerMin = protocol.PerMinDefault + } + if caps.PerMin > protocol.PerMinMax { + return caps, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "perMin", "max": protocol.PerMinMax}) + } + if expiresAtMs <= nowMs { + return caps, badField("expiresAtMs") + } + if most := nowMs + longest(role); expiresAtMs > most { + return caps, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "expiresAtMs", "max": most}) + } + for _, f := range []string{principal.Client, principal.ClientID, principal.Ticket} { + if len(f) > protocol.PrincipalFieldMax { + return caps, badField("principal") + } + } + if role == apiauth.RoleSupport && principal.Ticket == "" { + return caps, badField("principal.ticket") + } + if role == apiauth.RoleSupport && principal.ClientID != protocol.SupportClientID { + // Support is Sourceful's tooling and nothing else, however the + // grant reached the box. + return caps, badField("principal.clientId") + } + return caps, nil +} + +// approveLocked checks that an approval came from an owner and, for a +// phone, that its approval key signed exactly signed. The caller holds the +// mutex. It returns what ApprovedBy should say. +func (i *Identity) approveLocked(by Approver, atMs int64, signed func(protocol.SignedFor) ([]byte, error), sig []byte, nowMs int64) (string, error) { + if by.DeviceID == "" { + return LANApprover, nil + } + key, meta := i.findLocked(by.DeviceID) + if meta == nil || meta.role != apiauth.RoleOwner || meta.agent != nil { + return "", refuse(protocol.ErrScopeDenied, map[string]any{"needRole": apiauth.RoleOwner}) + } + if len(meta.approvalKey) == 0 { + return "", refuse(protocol.ErrNoApprovalKey, nil) + } + if err := i.verifyLocked(meta.approvalKey, by.DeviceID, atMs, signed, sig, nowMs); err != nil { + return "", err + } + return key, nil +} + +// verifyLocked checks one signature: fresh, never seen, and over the bytes +// this box would build. The caller holds the mutex. +func (i *Identity) verifyLocked(pub []byte, deviceID string, atMs int64, signed func(protocol.SignedFor) ([]byte, error), sig []byte, nowMs int64) error { + if len(sig) != protocol.ApprovalSigBytes { + return refuse(protocol.ErrBadSignature, nil) + } + // The memory of what it took holds only since this process started, so + // an approval from before then is refused rather than taken twice. + if atMs < nowMs-protocol.ApprovalSkewMs || atMs > nowMs+protocol.ApprovalSkewMs || atMs < i.sinceMs { + return refuse(protocol.ErrClockSkew, map[string]any{"boxMs": nowMs}) + } + msg, err := signed(protocol.SignedFor{BoxKey: i.static.Public(), DeviceID: deviceID}) + if err != nil { + return badField("order") + } + if protocol.VerifyApproval(pub, msg, sig) != nil { + return refuse(protocol.ErrBadSignature, nil) + } + // Keyed by the signed bytes, not the signature: (r, s) and (r, N-s) + // both verify, and either one approves these bytes once. + seen := sha256.Sum256(msg) + for d, until := range i.seenApprovals { + if until <= nowMs { + delete(i.seenApprovals, d) + } + } + if _, again := i.seenApprovals[seen]; again { + return refuse(protocol.ErrBadSignature, map[string]any{"field": "replay"}) + } + if i.seenApprovals == nil { + i.seenApprovals = map[[32]byte]int64{} + } + i.seenApprovals[seen] = nowMs + protocol.ApprovalReplayMs + return nil +} + +func (i *Identity) agentCountLocked() int { + n := 0 + for _, meta := range i.authorised { + if meta.agent != nil { + n++ + } + } + return n +} + +// AddAgent stores a new agent row from an owner's approval. +func (i *Identity) AddAgent(by Approver, m protocol.AgentAdd) (protocol.AgentRow, error) { + i.mu.Lock() + nowMs, ok := i.wallLocked() + if !ok { + i.mu.Unlock() + return protocol.AgentRow{}, noClock() + } + caps, err := checkGrant(m.Role, m.Scopes, m.Ops, m.Caps, m.ExpiresAtMs, m.Principal, nowMs) + if err != nil { + i.mu.Unlock() + return protocol.AgentRow{}, err + } + if len(m.Key) != 32 { + i.mu.Unlock() + return protocol.AgentRow{}, badField("key") + } + key := base64.RawURLEncoding.EncodeToString(m.Key) + if _, taken := i.findLocked(deviceID(key)); taken != nil || i.authorised[key] != nil { + // A row's id is its key's first eight characters. One id, one row: + // a revoke or a widen by id must never reach another. + i.mu.Unlock() + return protocol.AgentRow{}, badField("key") + } + if i.agentCountLocked() >= protocol.AgentRowsMax { + i.mu.Unlock() + return protocol.AgentRow{}, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "agents", "max": protocol.AgentRowsMax}) + } + approvedBy, err := i.approveLocked(by, m.AtMs, func(f protocol.SignedFor) ([]byte, error) { + return protocol.AddBytes(f, m) + }, m.Sig, nowMs) + if err != nil { + i.mu.Unlock() + return protocol.AgentRow{}, err + } + meta := &appMeta{addedAtMs: nowMs, role: m.Role, epoch: firstEpoch, agent: &agentMeta{ + Scopes: slices.Clone(m.Scopes), Ops: slices.Clone(m.Ops), Caps: caps, ExpiresAtMs: m.ExpiresAtMs, + Principal: m.Principal, ApprovedBy: approvedBy, + }} + if meta.agent.Ops == nil { + meta.agent.Ops = []string{} + } + i.authorised[key] = meta + row := i.agentRowLocked(key, meta) + i.mu.Unlock() + + err = i.save() + i.changed() + return row, err +} + +// WidenAgent replaces an agent row's rights from an owner's approval. +func (i *Identity) WidenAgent(by Approver, m protocol.AgentWiden) (protocol.AgentRow, error) { + i.mu.Lock() + nowMs, ok := i.wallLocked() + if !ok { + i.mu.Unlock() + return protocol.AgentRow{}, noClock() + } + key, meta := i.findLocked(m.ID) + if meta == nil || meta.agent == nil { + i.mu.Unlock() + return protocol.AgentRow{}, refuse(protocol.ErrNotFound, map[string]any{"id": m.ID}) + } + if meta.epoch != m.Epoch { + i.mu.Unlock() + return protocol.AgentRow{}, refuse(protocol.ErrConflict, map[string]any{"expected": m.Epoch, "actual": meta.epoch}) + } + caps, err := checkGrant(meta.role, m.Scopes, m.Ops, m.Caps, m.ExpiresAtMs, meta.agent.Principal, nowMs) + if err != nil { + i.mu.Unlock() + return protocol.AgentRow{}, err + } + rowKey, _ := base64.RawURLEncoding.DecodeString(key) + approvedBy, err := i.approveLocked(by, m.AtMs, func(f protocol.SignedFor) ([]byte, error) { + return protocol.WidenBytes(f, m, rowKey) + }, m.Sig, nowMs) + if err != nil { + i.mu.Unlock() + return protocol.AgentRow{}, err + } + ops := slices.Clone(m.Ops) + if ops == nil { + ops = []string{} + } + meta.agent = &agentMeta{ + Scopes: slices.Clone(m.Scopes), Ops: ops, Caps: caps, ExpiresAtMs: m.ExpiresAtMs, + Principal: meta.agent.Principal, ApprovedBy: approvedBy, + } + meta.epoch++ + row := i.agentRowLocked(key, meta) + i.mu.Unlock() + + err = i.save() + i.changed() + return row, err +} + +// SetApprovalKey registers an owner phone's approval key. The signature +// proves the phone holds the private half. Replacing a key needs PrevSig, +// the old key's signature over the same bytes; a phone that lost its key +// replaces it on the box page instead (ReplaceApprovalKey). +func (i *Identity) SetApprovalKey(deviceID string, m protocol.ApprovalKeySet) (int64, error) { + if _, err := protocol.ParseApprovalKey(m.Pub); err != nil { + return 0, badField("pub") + } + i.mu.Lock() + nowMs, ok := i.wallLocked() + if !ok { + i.mu.Unlock() + return 0, noClock() + } + _, meta := i.findLocked(deviceID) + if meta == nil || meta.role != apiauth.RoleOwner || meta.agent != nil { + i.mu.Unlock() + return 0, refuse(protocol.ErrScopeDenied, map[string]any{"needRole": apiauth.RoleOwner}) + } + bytesFor := func(f protocol.SignedFor) ([]byte, error) { return protocol.KeySetBytes(f, m) } + if len(meta.approvalKey) > 0 { + msg, err := bytesFor(protocol.SignedFor{BoxKey: i.static.Public(), DeviceID: deviceID}) + if err != nil || protocol.VerifyApproval(meta.approvalKey, msg, m.PrevSig) != nil { + i.mu.Unlock() + return 0, refuse(protocol.ErrBadSignature, map[string]any{"field": "prevSig"}) + } + } + if err := i.verifyLocked(m.Pub, deviceID, m.AtMs, bytesFor, m.Sig, nowMs); err != nil { + i.mu.Unlock() + return 0, err + } + meta.approvalKey = slices.Clone(m.Pub) + meta.approvalKeySetMs = nowMs + i.mu.Unlock() + return nowMs, i.save() +} + +// ReplaceApprovalKey sets an owner phone's approval key from the box page, +// where the house password stands in for the old key: for a phone whose key +// a new Face ID enrolment voided. +func (i *Identity) ReplaceApprovalKey(deviceID string, pub []byte) (int64, error) { + if _, err := protocol.ParseApprovalKey(pub); err != nil { + return 0, badField("pub") + } + i.mu.Lock() + nowMs, ok := i.wallLocked() + _, meta := i.findLocked(deviceID) + if meta == nil || meta.role != apiauth.RoleOwner || meta.agent != nil { + i.mu.Unlock() + return 0, refuse(protocol.ErrNotFound, map[string]any{"deviceId": deviceID}) + } + if !ok { + nowMs = 0 + } + meta.approvalKey = slices.Clone(pub) + meta.approvalKeySetMs = nowMs + i.mu.Unlock() + return nowMs, i.save() +} + +// CheckConfirm checks an owner phone's signed approval of a pending request: +// the request id, the agent that asked, the op and its args. +func (i *Identity) CheckConfirm(deviceID string, m protocol.AgentConfirm, req protocol.Request) error { + i.mu.Lock() + defer i.mu.Unlock() + nowMs, ok := i.wallLocked() + if !ok { + return noClock() + } + _, err := i.approveLocked(Approver{DeviceID: deviceID}, m.AtMs, func(f protocol.SignedFor) ([]byte, error) { + return protocol.ConfirmBytes(f, m, req) + }, m.Sig, nowMs) + return err +} + +// ApprovalKeys lists the owner phones that can approve, by device id. +func (i *Identity) ApprovalKeys() []protocol.ApprovalKeyInfo { + i.mu.Lock() + defer i.mu.Unlock() + var out []protocol.ApprovalKeyInfo + for key, meta := range i.authorised { + if len(meta.approvalKey) > 0 { + out = append(out, protocol.ApprovalKeyInfo{DeviceID: deviceID(key), SetAtMs: meta.approvalKeySetMs}) + } + } + sort.Slice(out, func(a, b int) bool { return out[a].DeviceID < out[b].DeviceID }) + return out +} + +// Agents lists every agent row, sorted by id. +func (i *Identity) Agents() []protocol.AgentRow { + i.mu.Lock() + defer i.mu.Unlock() + var out []protocol.AgentRow + for key, meta := range i.authorised { + if meta.agent != nil { + out = append(out, i.agentRowLocked(key, meta)) + } + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out +} + +// Agent is one agent row by id. +func (i *Identity) Agent(id string) (protocol.AgentRow, bool) { + i.mu.Lock() + defer i.mu.Unlock() + key, meta := i.findLocked(id) + if meta == nil || meta.agent == nil { + return protocol.AgentRow{}, false + } + return i.agentRowLocked(key, meta), true +} + +func (i *Identity) agentRowLocked(key string, meta *appMeta) protocol.AgentRow { + raw, _ := base64.RawURLEncoding.DecodeString(key) + return protocol.AgentRow{ + ID: deviceID(key), Key: raw, Role: meta.role, + Scopes: slices.Clone(meta.agent.Scopes), Ops: slices.Clone(meta.agent.Ops), Caps: meta.agent.Caps, + ExpiresAtMs: meta.agent.ExpiresAtMs, Principal: meta.agent.Principal, + ApprovedBy: i.approverNameLocked(meta.agent.ApprovedBy), + AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs, Epoch: meta.epoch, + } +} + +// approverNameLocked is ApprovedBy as the wire says it: app: or lan. +func (i *Identity) approverNameLocked(approvedBy string) string { + if approvedBy == LANApprover { + return LANApprover + } + return apiauth.KindApp + ":" + deviceID(approvedBy) +} + +// agentGrantLocked is an agent row's grant now, and the role its approver +// holds now. ok is false when the approving phone is gone, which a revoke +// never leaves behind. The caller holds the mutex. +func (i *Identity) agentGrantLocked(meta *appMeta) (AgentGrant, string, bool) { + approverRole := apiauth.RoleOwner + approvedBy := LANApprover + if meta.agent.ApprovedBy != LANApprover { + phone := i.authorised[meta.agent.ApprovedBy] + if phone == nil { + return AgentGrant{}, "", false + } + approverRole = phone.role + approvedBy = deviceID(meta.agent.ApprovedBy) + } + g := AgentGrant{ + Scopes: slices.Clone(meta.agent.Scopes), Ops: slices.Clone(meta.agent.Ops), Caps: meta.agent.Caps, + ExpiresAtMs: meta.agent.ExpiresAtMs, Principal: meta.agent.Principal, ApprovedBy: approvedBy, + } + nowMs, ok := i.wallLocked() + switch { + case !ok: + g.NoClock = true + case nowMs >= meta.agent.ExpiresAtMs: + g.Expired = true + } + return g, approverRole, true +} + +// ExpireAgents removes every agent row the clock says has run out, and +// returns what it removed so the caller can end their sessions. With no +// clock it removes nothing; those rows already may do nothing. +func (i *Identity) ExpireAgents() []Removed { + i.mu.Lock() + nowMs, ok := i.wallLocked() + if !ok { + i.mu.Unlock() + return nil + } + var gone []string + for key, meta := range i.authorised { + if meta.agent != nil && nowMs >= meta.agent.ExpiresAtMs { + gone = append(gone, key) + } + } + for _, key := range gone { + delete(i.authorised, key) + } + i.mu.Unlock() + if len(gone) == 0 { + return nil + } + sort.Strings(gone) + _ = i.save() + i.changed() + return removedKeys(gone) +} + +func removedKeys(keys []string) []Removed { + out := make([]Removed, 0, len(keys)) + for _, key := range keys { + raw, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + continue + } + out = append(out, Removed{ID: deviceID(key), Key: raw}) + } + return out +} + +// ErrAgentRow is a phone's operation aimed at an agent row, or the other +// way round. +var ErrAgentRow = errors.New("appenroll: that is an agent row") + +// DeriveKey is a key for label, derived from the box's static secret: the +// same for the life of the box, and useless for finding the secret. +func (i *Identity) DeriveKey(label string) []byte { + i.mu.Lock() + defer i.mu.Unlock() + mac := hmac.New(sha256.New, i.staticSecret) + mac.Write([]byte(label)) + return mac.Sum(nil) +} diff --git a/go/internal/appenroll/agents_test.go b/go/internal/appenroll/agents_test.go new file mode 100644 index 00000000..f2ade888 --- /dev/null +++ b/go/internal/appenroll/agents_test.go @@ -0,0 +1,480 @@ +package appenroll + +import ( + "crypto/elliptic" + "errors" + "math/big" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll/approvaltest" + "github.com/srcfl/ftw/go/protocol" +) + +// agentBox is a box with one owner phone that has registered its approval +// key, and a wall clock the test moves. +type agentBox struct { + id *Identity + owner Grant + signer *approvaltest.Signer + now time.Time + synced bool +} + +func newAgentBox(t *testing.T) *agentBox { + t.Helper() + id, _ := newIdentity(t) + b := &agentBox{id: id, now: time.UnixMilli(1_792_000_000_000), synced: true} + id.SetClock(func() (time.Time, bool) { return b.now, b.synced }) + b.owner = pair(t, id, apiauth.RoleOwner) + b.signer = approvaltest.New() + if _, err := id.SetApprovalKey(b.owner.DeviceID, b.signer.KeySet(b.signedFor(b.owner.DeviceID), b.ms())); err != nil { + t.Fatalf("SetApprovalKey: %v", err) + } + return b +} + +func (b *agentBox) ms() int64 { return b.now.UnixMilli() } + +func (b *agentBox) signedFor(device string) protocol.SignedFor { + return protocol.SignedFor{BoxKey: b.id.StaticKey().Public(), DeviceID: device} +} + +// readGrant is an assistant that reads live data and the plan for 30 days. +func (b *agentBox) readGrant(key []byte) protocol.AgentAdd { + return protocol.AgentAdd{ + Key: key, Role: apiauth.RoleAgent, + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopePlanRead}, + ExpiresAtMs: b.ms() + protocol.AgentMaxMs, + Principal: protocol.Principal{Client: "Claude"}, + AtMs: b.ms(), + } +} + +func (b *agentBox) add(t *testing.T, m protocol.AgentAdd) protocol.AgentRow { + t.Helper() + row, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, b.signer.Add(b.signedFor(b.owner.DeviceID), m)) + if err != nil { + t.Fatalf("AddAgent: %v", err) + } + return row +} + +func refusalCode(err error) string { + var r *protocol.Refusal + if errors.As(err, &r) { + return r.Code + } + return "" +} + +func TestAnAgentNeedsAnOwnersSignature(t *testing.T) { + b := newAgentBox(t) + key := appKey(t) + m := b.readGrant(key) + + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, m); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("unsigned: %v", err) + } + other := approvaltest.New() + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, other.Add(b.signedFor(b.owner.DeviceID), m)); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("signed by another key: %v", err) + } + signed := b.signer.Add(b.signedFor(b.owner.DeviceID), m) + wider := signed + wider.Scopes = []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead, protocol.ScopePlanRead} + wider.Caps = protocol.Caps{HoldS: 600, MaxW: 3000} + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, wider); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("a signature for reading passed for control: %v", err) + } + stale := b.signer.Add(b.signedFor(b.owner.DeviceID), func() protocol.AgentAdd { m := m; m.AtMs -= 11 * 60 * 1000; return m }()) + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, stale); refusalCode(err) != protocol.ErrClockSkew { + t.Errorf("an old signature: %v", err) + } + + row, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, signed) + if err != nil { + t.Fatal(err) + } + if row.Role != apiauth.RoleAgent || row.ApprovedBy != "app:"+b.owner.DeviceID || row.Caps.PerMin != protocol.PerMinDefault { + t.Errorf("row %+v", row) + } + + // The same signed bytes, sent again after a revoke, add nothing. + if _, err := b.id.Revoke(row.ID, OverASession); err != nil { + t.Fatal(err) + } + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, signed); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("a replayed approval: %v", err) + } +} + +func TestOnlyAnOwnerWithAKeyApproves(t *testing.T) { + b := newAgentBox(t) + viewer := pair(t, b.id, apiauth.RoleViewer) + if _, err := b.id.SetApprovalKey(viewer.DeviceID, b.signer.KeySet(b.signedFor(viewer.DeviceID), b.ms())); refusalCode(err) != protocol.ErrScopeDenied { + t.Errorf("a viewer registered a key: %v", err) + } + if _, err := b.id.AddAgent(Approver{DeviceID: viewer.DeviceID}, b.signer.Add(b.signedFor(viewer.DeviceID), b.readGrant(appKey(t)))); refusalCode(err) != protocol.ErrScopeDenied { + t.Errorf("a viewer approved: %v", err) + } + second := pair(t, b.id, apiauth.RoleOwner) + if _, err := b.id.AddAgent(Approver{DeviceID: second.DeviceID}, b.signer.Add(b.signedFor(second.DeviceID), b.readGrant(appKey(t)))); refusalCode(err) != protocol.ErrNoApprovalKey { + t.Errorf("an owner with no key approved: %v", err) + } + if _, err := b.id.AddAgent(AtTheBoxPage, b.readGrant(appKey(t))); err != nil { + t.Errorf("the box page needs no signature: %v", err) + } +} + +func TestAGrantStaysWithinTheBoxsLimits(t *testing.T) { + b := newAgentBox(t) + cases := map[string]struct { + edit func(*protocol.AgentAdd) + code string + }{ + "hold past its cap": {func(m *protocol.AgentAdd) { + m.Scopes = []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead} + m.Ops = []string{protocol.OpLoadpointHold} + m.Caps = protocol.Caps{HoldS: protocol.HoldSMax + 1, MaxW: 3000} + }, protocol.ErrCapExceeded}, + "a hold scope with no power cap": {func(m *protocol.AgentAdd) { + m.Scopes = []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead} + m.Caps = protocol.Caps{HoldS: 600} + }, protocol.ErrCapExceeded}, + "longer than an assistant may have": {func(m *protocol.AgentAdd) { m.ExpiresAtMs = b.ms() + protocol.AgentMaxMs + 1 }, protocol.ErrCapExceeded}, + "already over": {func(m *protocol.AgentAdd) { m.ExpiresAtMs = b.ms() }, protocol.ErrUnknownOp}, + "an op its scopes do not open": {func(m *protocol.AgentAdd) { + m.Ops = []string{protocol.OpLoadpointHold} + }, protocol.ErrOpNotGranted}, + "an op no agent may name": {func(m *protocol.AgentAdd) { + m.Scopes = []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead} + m.Caps = protocol.Caps{HoldS: 600, MaxW: 3000} + m.Ops = []string{"members.invite"} + }, protocol.ErrOpNotGranted}, + "a scope beyond the role": {func(m *protocol.AgentAdd) { + m.Scopes = []string{protocol.ScopeLiveRead, protocol.ScopeModeWrite} + }, protocol.ErrScopeDenied}, + "unsorted scopes": {func(m *protocol.AgentAdd) { + m.Scopes = []string{protocol.ScopePlanRead, protocol.ScopeLiveRead} + }, protocol.ErrUnknownOp}, + "support without a ticket": {func(m *protocol.AgentAdd) { + m.Role = apiauth.RoleSupport + m.ExpiresAtMs = b.ms() + protocol.SupportMaxMs + m.Principal.ClientID = protocol.SupportClientID + }, protocol.ErrUnknownOp}, + "a release check that reads more than health": {func(m *protocol.AgentAdd) { + m.Role = apiauth.RoleRelease + }, protocol.ErrScopeDenied}, + "a phone's role": {func(m *protocol.AgentAdd) { m.Role = apiauth.RoleOwner }, protocol.ErrUnknownOp}, + } + for name, c := range cases { + m := b.readGrant(appKey(t)) + c.edit(&m) + if _, err := b.id.AddAgent(AtTheBoxPage, m); refusalCode(err) != c.code { + t.Errorf("%s: got %v, want %s", name, err, c.code) + } + } + if len(b.id.Agents()) != 0 { + t.Errorf("a refused grant left a row: %+v", b.id.Agents()) + } +} + +func TestNoClockNoAgents(t *testing.T) { + b := newAgentBox(t) + key := appKey(t) + row := b.add(t, b.readGrant(key)) + + b.synced = false + if _, err := b.id.AddAgent(AtTheBoxPage, b.readGrant(appKey(t))); refusalCode(err) != protocol.ErrUnavailable { + t.Errorf("added without a clock: %v", err) + } + if _, _, err := b.id.CheckTunnel(key, nil); !errors.Is(err, ErrNoPairing) { + t.Errorf("admitted without a clock: %v", err) + } + g, ok := b.id.GrantFor(row.ID) + if !ok || g.Agent == nil || !g.Agent.NoClock { + t.Errorf("grant without a clock: %+v %v", g, ok) + } + if gone := b.id.ExpireAgents(); len(gone) != 0 { + t.Errorf("swept without a clock: %+v", gone) + } +} + +func TestAnAgentComesOnlyThroughTheTunnel(t *testing.T) { + b := newAgentBox(t) + key := appKey(t) + row := b.add(t, b.readGrant(key)) + + if _, _, err := b.id.Check(key, nil); !errors.Is(err, ErrNoPairing) { + t.Errorf("the relay admitted an agent: %v", err) + } + if _, err := b.id.AuthoriseWatch(key, nil); !errors.Is(err, ErrNoPairing) { + t.Errorf("the watch door admitted an agent: %v", err) + } + grant, commit, err := b.id.CheckTunnel(key, nil) + if err != nil || grant.Agent == nil || grant.DeviceID != row.ID { + t.Fatalf("the tunnel refused an agent: %+v %v", grant, err) + } + if _, err := commit(); err != nil { + t.Fatal(err) + } +} + +func TestAnExpiredAgentIsRefusedAndSwept(t *testing.T) { + b := newAgentBox(t) + key := appKey(t) + m := b.readGrant(key) + m.ExpiresAtMs = b.ms() + 60_000 + row := b.add(t, m) + + b.now = b.now.Add(time.Minute) + if _, _, err := b.id.CheckTunnel(key, nil); !errors.Is(err, ErrNoPairing) { + t.Errorf("admitted after expiry: %v", err) + } + if g, ok := b.id.GrantFor(row.ID); !ok || !g.Agent.Expired { + t.Errorf("grant after expiry: %+v %v", g, ok) + } + for _, e := range b.id.Roster() { + if e.ID == row.ID { + t.Error("the roster still lists an expired agent") + } + } + gone := b.id.ExpireAgents() + if len(gone) != 1 || gone[0].ID != row.ID { + t.Fatalf("swept %+v", gone) + } + if _, ok := b.id.GrantFor(row.ID); ok { + t.Error("an expired agent outlived the sweep") + } +} + +func TestARevokeBitesAtOnce(t *testing.T) { + b := newAgentBox(t) + row := b.add(t, b.readGrant(appKey(t))) + if _, ok := b.id.GrantFor(row.ID); !ok { + t.Fatal("no grant") + } + if _, err := b.id.Revoke(row.ID, OverASession); err != nil { + t.Fatal(err) + } + if _, ok := b.id.GrantFor(row.ID); ok { + t.Error("a revoked agent still has a grant") + } +} + +func TestAnAgentHoldsOnlyWhatItsApproverHoldsNow(t *testing.T) { + b := newAgentBox(t) + second := pair(t, b.id, apiauth.RoleOwner) + row := b.add(t, b.readGrant(appKey(t))) + + if err := b.id.SetRole(b.owner.DeviceID, apiauth.RoleViewer); err != nil { + t.Fatal(err) + } + g, ok := b.id.GrantFor(row.ID) + if !ok || g.ParentRole != apiauth.RoleViewer { + t.Errorf("after the approver was demoted: %+v", g) + } + if err := b.id.SetRole(row.ID, apiauth.RoleOwner); !errors.Is(err, ErrAgentRow) { + t.Errorf("an agent row took a phone's role: %v", err) + } + + if _, err := b.id.Revoke(b.owner.DeviceID, OverASession); err != nil { + t.Fatal(err) + } + if _, ok := b.id.GrantFor(row.ID); ok { + t.Error("an agent outlived the phone that approved it") + } + _ = second +} + +func TestASupportGrantNamesItsTicket(t *testing.T) { + b := newAgentBox(t) + m := b.readGrant(appKey(t)) + m.Role = apiauth.RoleSupport + m.Principal = protocol.Principal{Client: "Sourceful support", Ticket: "FTW-123"} + m.ExpiresAtMs = b.ms() + protocol.SupportMaxMs + if _, err := b.id.AddAgent(AtTheBoxPage, m); refusalCode(err) != protocol.ErrUnknownOp { + t.Errorf("support from another client: %v", err) + } + m.Principal.ClientID = protocol.SupportClientID + m.ExpiresAtMs = b.ms() + protocol.SupportMaxMs + 1 + if _, err := b.id.AddAgent(AtTheBoxPage, m); refusalCode(err) != protocol.ErrCapExceeded { + t.Errorf("support past 14 days: %v", err) + } + m.ExpiresAtMs = b.ms() + protocol.SupportMaxMs + row := b.add(t, m) + if row.Principal.Ticket != "FTW-123" { + t.Errorf("row %+v", row) + } +} + +func TestWideningIsSignedAndFollowsTheEpoch(t *testing.T) { + b := newAgentBox(t) + row := b.add(t, b.readGrant(appKey(t))) + w := protocol.AgentWiden{ + ID: row.ID, Epoch: row.Epoch, + Scopes: []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead, protocol.ScopePlanRead}, + Ops: []string{protocol.OpLoadpointHold}, + Caps: protocol.Caps{HoldS: 900, MaxW: 7400}, + ExpiresAtMs: row.ExpiresAtMs, AtMs: b.ms(), + } + by := Approver{DeviceID: b.owner.DeviceID} + if _, err := b.id.WidenAgent(by, w); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("unsigned widen: %v", err) + } + stale := w + stale.Epoch++ + if _, err := b.id.WidenAgent(by, b.signer.Widen(b.signedFor(b.owner.DeviceID), stale, row.Key)); refusalCode(err) != protocol.ErrConflict { + t.Errorf("stale epoch: %v", err) + } + other := w + if _, err := b.id.WidenAgent(by, b.signer.Widen(b.signedFor(b.owner.DeviceID), other, appKey(t))); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("a signature for another row's key: %v", err) + } + wider, err := b.id.WidenAgent(by, b.signer.Widen(b.signedFor(b.owner.DeviceID), w, row.Key)) + if err != nil { + t.Fatal(err) + } + if wider.Epoch != row.Epoch+1 || len(wider.Ops) != 1 { + t.Errorf("widened %+v", wider) + } + if _, err := b.id.WidenAgent(by, protocol.AgentWiden{ID: "nobody00"}); refusalCode(err) != protocol.ErrNotFound { + t.Errorf("unknown id: %v", err) + } +} + +func TestAgentsAndKeysSurviveARestart(t *testing.T) { + id, keyPath := newIdentity(t) + now := time.UnixMilli(1_792_000_000_000) + id.SetClock(func() (time.Time, bool) { return now, true }) + owner := pair(t, id, apiauth.RoleOwner) + signer := approvaltest.New() + f := protocol.SignedFor{BoxKey: id.StaticKey().Public(), DeviceID: owner.DeviceID} + if _, err := id.SetApprovalKey(owner.DeviceID, signer.KeySet(f, now.UnixMilli())); err != nil { + t.Fatal(err) + } + key := appKey(t) + m := protocol.AgentAdd{Key: key, Role: apiauth.RoleAgent, Scopes: []string{protocol.ScopeLiveRead}, + ExpiresAtMs: now.UnixMilli() + 1000, AtMs: now.UnixMilli()} + row, err := id.AddAgent(Approver{DeviceID: owner.DeviceID}, signer.Add(f, m)) + if err != nil { + t.Fatal(err) + } + + again, err := LoadOrCreate(keyPath) + if err != nil { + t.Fatal(err) + } + again.SetClock(func() (time.Time, bool) { return now, true }) + got, ok := again.Agent(row.ID) + if !ok || got.ExpiresAtMs != row.ExpiresAtMs || got.ApprovedBy != row.ApprovedBy { + t.Fatalf("after a restart: %+v %v", got, ok) + } + if keys := again.ApprovalKeys(); len(keys) != 1 || keys[0].DeviceID != owner.DeviceID { + t.Errorf("keys %+v", keys) + } + for _, d := range again.Devices() { + if d.ID == row.ID { + t.Error("an agent row is listed among the phones") + } + } + // The key still approves after the restart. + m2 := m + m2.Key = appKey(t) + if _, err := again.AddAgent(Approver{DeviceID: owner.DeviceID}, signer.Add(f, m2)); err != nil { + t.Errorf("approving after a restart: %v", err) + } +} + +// Replacing an approval key needs the old one, or the box page. +func TestReplacingAnApprovalKeyNeedsTheOldOne(t *testing.T) { + b := newAgentBox(t) + f := b.signedFor(b.owner.DeviceID) + thief := approvaltest.New() + if _, err := b.id.SetApprovalKey(b.owner.DeviceID, thief.KeySet(f, b.ms())); refusalCode(err) != protocol.ErrBadSignature { + t.Fatalf("a new key without the old one's signature: %v", err) + } + if _, err := b.id.SetApprovalKey(b.owner.DeviceID, thief.Replace(f, b.ms(), approvaltest.New())); refusalCode(err) != protocol.ErrBadSignature { + t.Fatalf("signed by a stranger as the old key: %v", err) + } + next := approvaltest.New() + if _, err := b.id.SetApprovalKey(b.owner.DeviceID, next.Replace(f, b.ms(), b.signer)); err != nil { + t.Fatalf("a replacement the old key signed: %v", err) + } + // The old key approves nothing now. + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, b.signer.Add(f, b.readGrant(appKey(t)))); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("the replaced key still approves: %v", err) + } + lost := approvaltest.New() + if _, err := b.id.ReplaceApprovalKey(b.owner.DeviceID, lost.Pub()); err != nil { + t.Fatal(err) + } + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, lost.Add(f, b.readGrant(appKey(t)))); err != nil { + t.Errorf("the key set on the box page: %v", err) + } +} + +// The replay memory keys on the signed bytes, so the other form of the same +// signature is refused too. +func TestTheSameBytesAreTakenOnce(t *testing.T) { + b := newAgentBox(t) + f := b.signedFor(b.owner.DeviceID) + m := b.signer.Add(f, b.readGrant(appKey(t))) + row, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, m) + if err != nil { + t.Fatal(err) + } + if _, err := b.id.Revoke(row.ID, OverASession); err != nil { + t.Fatal(err) + } + // (r, N - s) verifies as well as (r, s). + flipped := m + flipped.Sig = highS(m.Sig) + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, flipped); refusalCode(err) != protocol.ErrBadSignature { + t.Errorf("the other form of the same signature: %v", err) + } +} + +func highS(sig []byte) []byte { + n := elliptic.P256().Params().N + s := new(big.Int).SetBytes(sig[32:]) + s.Sub(n, s) + out := append([]byte(nil), sig[:32]...) + return append(out, s.FillBytes(make([]byte, 32))...) +} + +// An approval signed before the box last started is refused: the memory of +// what it took starts then. +func TestAnApprovalFromBeforeTheStartIsRefused(t *testing.T) { + b := newAgentBox(t) + b.now = b.now.Add(time.Hour) + f := b.signedFor(b.owner.DeviceID) + m := b.readGrant(appKey(t)) + m.AtMs = b.ms() - 61*60*1000 + if _, err := b.id.AddAgent(Approver{DeviceID: b.owner.DeviceID}, b.signer.Add(f, m)); refusalCode(err) != protocol.ErrClockSkew { + t.Errorf("from before the start: %v", err) + } +} + +func TestOneIDOneRow(t *testing.T) { + b := newAgentBox(t) + row := b.add(t, b.readGrant(appKey(t))) + twin := append([]byte(nil), row.Key...) + twin[31] ^= 0xff // the same first eight characters, another key + if _, err := b.id.AddAgent(AtTheBoxPage, b.readGrant(twin)); refusalCode(err) != protocol.ErrUnknownOp { + t.Errorf("a second row under one id: %v", err) + } +} + +func TestOwnersHearOfANewOwner(t *testing.T) { + b := newAgentBox(t) + heard := 0 + b.id.OnOwnerAdded(func() { heard++ }) + pair(t, b.id, apiauth.RoleViewer) + pair(t, b.id, apiauth.RoleOwner) + if heard != 1 { + t.Errorf("heard %d", heard) + } +} diff --git a/go/internal/appenroll/approvaltest/approvaltest.go b/go/internal/appenroll/approvaltest/approvaltest.go new file mode 100644 index 00000000..d7c7a75b --- /dev/null +++ b/go/internal/appenroll/approvaltest/approvaltest.go @@ -0,0 +1,97 @@ +// Package approvaltest signs approvals the way an owner phone's Secure +// Enclave key does, for tests of the box's side. +package approvaltest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + + "github.com/srcfl/ftw/go/protocol" +) + +// Signer is one phone's approval key. +type Signer struct { + key *ecdsa.PrivateKey +} + +// New draws a key. +func New() *Signer { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + return &Signer{key: key} +} + +// Pub is the key's public half, SEC1 uncompressed. +func (s *Signer) Pub() []byte { + return elliptic.Marshal(elliptic.P256(), s.key.X, s.key.Y) //nolint:staticcheck // SEC1 uncompressed is the wire form. +} + +// Sign signs msg, r ‖ s, over its SHA-256. +func (s *Signer) Sign(msg []byte) []byte { + digest := sha256.Sum256(msg) + r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:]) + if err != nil { + panic(err) + } + out := make([]byte, protocol.ApprovalSigBytes) + r.FillBytes(out[:32]) + ss.FillBytes(out[32:]) + return out +} + +// Replace is a signed approval.key.set that replaces old's key with this +// one. +func (s *Signer) Replace(f protocol.SignedFor, atMs int64, old *Signer) protocol.ApprovalKeySet { + m := s.KeySet(f, atMs) + raw, err := protocol.KeySetBytes(f, m) + if err != nil { + panic(err) + } + m.PrevSig = old.Sign(raw) + return m +} + +// KeySet is a signed approval.key.set. +func (s *Signer) KeySet(f protocol.SignedFor, atMs int64) protocol.ApprovalKeySet { + m := protocol.ApprovalKeySet{Pub: s.Pub(), AtMs: atMs} + raw, err := protocol.KeySetBytes(f, m) + if err != nil { + panic(err) + } + m.Sig = s.Sign(raw) + return m +} + +// Add signs m. +func (s *Signer) Add(f protocol.SignedFor, m protocol.AgentAdd) protocol.AgentAdd { + raw, err := protocol.AddBytes(f, m) + if err != nil { + panic(err) + } + m.Sig = s.Sign(raw) + return m +} + +// Widen signs m for the row with key. +func (s *Signer) Widen(f protocol.SignedFor, m protocol.AgentWiden, key []byte) protocol.AgentWiden { + raw, err := protocol.WidenBytes(f, m, key) + if err != nil { + panic(err) + } + m.Sig = s.Sign(raw) + return m +} + +// Confirm signs m for the request req. +func (s *Signer) Confirm(f protocol.SignedFor, m protocol.AgentConfirm, req protocol.Request) protocol.AgentConfirm { + raw, err := protocol.ConfirmBytes(f, m, req) + if err != nil { + panic(err) + } + m.Sig = s.Sign(raw) + return m +} diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 65d66068..90aa3fb9 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -138,6 +138,12 @@ type storedApp struct { // Parent is the full key of the phone that added this watch. Empty for a // phone. A watch without its phone is removed with it, never left behind. Parent string `json:"parent,omitempty"` + // Agent is an agent row's grant (agents.go). Nil for a phone or a watch. + Agent *storedAgent `json:"agent,omitempty"` + // ApprovalKey is an owner phone's P-256 approval key, SEC1 uncompressed + // in base64url, and ApprovalKeySetMs when it was registered. + ApprovalKey string `json:"approvalKey,omitempty"` + ApprovalKeySetMs int64 `json:"approvalKeySetMs,omitempty"` } // UnmarshalJSON accepts the legacy bare-string form beside the current one. @@ -218,6 +224,17 @@ type Identity struct { // home is the home id at FTW Cloud, for a QR that names it. See // SetHome. home func() string + // wall is the wall clock and whether NTP has set it. Agent rows expire + // by it and may do nothing without it. See SetClock. + wall func() (time.Time, bool) + // seenApprovals remembers the SHA-256 of each signed array it took + // until the ms it maps to, so none is taken twice. + seenApprovals map[[32]byte]int64 + // sinceMs is the earliest approval time this process takes: its first + // trusted wall time less the skew allowed. + sinceMs int64 + // onOwnerAdded hears about a phone paired as an owner. + onOwnerAdded []func() } // appMeta is what the box remembers about one paired phone. @@ -229,6 +246,11 @@ type appMeta struct { // parent is the full key of the phone that added a watch, and empty for // a phone. parent string + // agent is an agent row's grant, nil for a phone or a watch. + agent *agentMeta + // approvalKey is an owner phone's approval key, empty when it has none. + approvalKey []byte + approvalKeySetMs int64 } // firstEpoch is where a fresh enrolment starts. @@ -249,6 +271,10 @@ type Grant struct { // viewer narrows its watch at once. Parent string ParentRole string + // Agent is an agent row's grant now, nil for a phone or a watch. Its + // ParentRole is the role its approver holds now: owner for a grant + // made on the box page. + Agent *AgentGrant } type pairingCode struct { @@ -349,13 +375,19 @@ func (i *Identity) load(raw []byte) error { if epoch == 0 { epoch = firstEpoch } - i.authorised[app.Key] = &appMeta{ - addedAtMs: app.AddedAtMs, - lastSeenMs: app.LastSeenMs, - role: role, - epoch: epoch, - parent: app.Parent, + meta := &appMeta{ + addedAtMs: app.AddedAtMs, + lastSeenMs: app.LastSeenMs, + role: role, + epoch: epoch, + parent: app.Parent, + agent: app.Agent, + approvalKeySetMs: app.ApprovalKeySetMs, } + if app.ApprovalKey != "" { + meta.approvalKey, _ = base64.RawURLEncoding.DecodeString(app.ApprovalKey) + } + i.authorised[app.Key] = meta } // A watch whose phone is missing from the file was left behind by a // write that never finished. It holds nothing its phone could vouch for, @@ -365,6 +397,17 @@ func (i *Identity) load(raw []byte) error { delete(i.authorised, key) } } + // The same for an agent whose approving phone is gone, and for a row + // whose role says agent and carries no grant. + for key, meta := range i.authorised { + agentRole := apiauth.AgentRole(meta.role) + switch { + case agentRole != (meta.agent != nil): + delete(i.authorised, key) + case meta.agent != nil && meta.agent.ApprovedBy != LANApprover && i.authorised[meta.agent.ApprovedBy] == nil: + delete(i.authorised, key) + } + } return nil } @@ -402,10 +445,15 @@ func (i *Identity) save() error { AuthorisedApps: make([]storedApp, 0, len(i.authorised)), } for key, meta := range i.authorised { - s.AuthorisedApps = append(s.AuthorisedApps, storedApp{ + app := storedApp{ Key: key, AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs, - Role: meta.role, Epoch: meta.epoch, Parent: meta.parent, - }) + Role: meta.role, Epoch: meta.epoch, Parent: meta.parent, Agent: meta.agent, + ApprovalKeySetMs: meta.approvalKeySetMs, + } + if len(meta.approvalKey) > 0 { + app.ApprovalKey = base64.RawURLEncoding.EncodeToString(meta.approvalKey) + } + s.AuthorisedApps = append(s.AuthorisedApps, app) } // Deterministic on disk, so two saves of the same state are the same // bytes and a diff of applink.json says something. @@ -625,6 +673,17 @@ func (i *Identity) Authorise(appStatic, payload []byte) (Grant, error) { // code and takes no device's place. commit returns the grant as it stands // then, and fails if a revoke, another pairing or the code's end came first. func (i *Identity) Check(appStatic, payload []byte) (Grant, func() (Grant, error), error) { + return i.check(appStatic, payload, false) +} + +// CheckTunnel is Check for a session FTW Cloud carries. It also admits an +// agent row that has a clock to run by and has not expired: an agent reaches +// the box through the tunnel and nowhere else. +func (i *Identity) CheckTunnel(appStatic, payload []byte) (Grant, func() (Grant, error), error) { + return i.check(appStatic, payload, true) +} + +func (i *Identity) check(appStatic, payload []byte, tunnel bool) (Grant, func() (Grant, error), error) { key := base64.RawURLEncoding.EncodeToString(appStatic) i.mu.Lock() @@ -636,6 +695,15 @@ func (i *Identity) Check(appStatic, payload []byte) (Grant, func() (Grant, error i.mu.Unlock() return Grant{}, nil, ErrNoPairing } + if meta.agent != nil { + // An agent comes only through the tunnel, only with a + // clock, and only before its grant runs out. Anywhere else + // it is a stranger. + if g, _, ok := i.agentGrantLocked(meta); !tunnel || !ok || g.Expired || g.NoClock { + i.mu.Unlock() + return Grant{}, nil, ErrNoPairing + } + } grant := i.grantLocked(key, meta) i.mu.Unlock() // A reconnect. The stamp is what lets the settings page tell a @@ -720,10 +788,30 @@ func (i *Identity) Check(appStatic, payload []byte) (Grant, func() (Grant, error err := i.save() i.changed() + if role == apiauth.RoleOwner { + i.ownerAdded() + } return Grant{DeviceID: deviceID(key), Role: role, Epoch: firstEpoch}, err }, nil } +// OnOwnerAdded registers fn to run after a phone pairs as an owner: every +// owner hears of it. +func (i *Identity) OnOwnerAdded(fn func()) { + i.mu.Lock() + defer i.mu.Unlock() + i.onOwnerAdded = append(i.onOwnerAdded, fn) +} + +func (i *Identity) ownerAdded() { + i.mu.Lock() + fns := append([]func(){}, i.onOwnerAdded...) + i.mu.Unlock() + for _, fn := range fns { + fn() + } +} + // pairingRoleLocked is the role a code stamps. The role travels with the code // rather than with the handshake, so a code minted for a viewer stamps a // viewer, and nothing the app sends can change it. The caller holds i.mu. @@ -806,7 +894,12 @@ func (i *Identity) GrantFor(id string) (Grant, bool) { // guard: a watch never outlives the phone that vouches for it. return Grant{}, false } - return i.grantLocked(key, meta), true + grant := i.grantLocked(key, meta) + if meta.agent != nil && grant.Agent == nil { + // The same guard for an agent and the phone that approved it. + return Grant{}, false + } + return grant, true } // grantLocked is one enrolment as a Grant. The caller holds the mutex. @@ -816,6 +909,12 @@ func (i *Identity) grantLocked(key string, meta *appMeta) Grant { grant.Parent = deviceID(meta.parent) grant.ParentRole = parent.role } + if meta.agent != nil { + if g, approverRole, ok := i.agentGrantLocked(meta); ok { + grant.Agent = &g + grant.ParentRole = approverRole + } + } return grant } @@ -853,6 +952,11 @@ func (i *Identity) SetRole(id, role string) error { i.mu.Unlock() return ErrWatchRole } + if meta.agent != nil { + // An agent's grant changes only by agent.widen, with an approval. + i.mu.Unlock() + return ErrAgentRow + } if meta.role == role { // Nothing changed, so nothing is bumped. An epoch that moved for a // write that changed nothing would make every live session re-read a @@ -864,6 +968,7 @@ func (i *Identity) SetRole(id, role string) error { i.mu.Unlock() return ErrLastOwnerProtected } + promoted := role == apiauth.RoleOwner meta.role = role meta.epoch++ // The phone's watches hold what it holds, so their grants changed too. @@ -878,6 +983,11 @@ func (i *Identity) SetRole(id, role string) error { err := i.save() i.changed() + if promoted { + // A phone made an owner can do anything, give access away included: + // every owner hears of it, as of a new owner's pairing. + i.ownerAdded() + } return err } @@ -889,6 +999,10 @@ func (i *Identity) Devices() []DeviceInfo { var phones []DeviceInfo watches := map[string][]DeviceInfo{} for key, meta := range i.authorised { + if meta.agent != nil { + // Agents have their own list. See Agents. + continue + } d := DeviceInfo{ ID: deviceID(key), AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs, Role: meta.role, @@ -985,8 +1099,11 @@ func (i *Identity) Revoke(id string, from Presence) ([]Removed, error) { return nil, ErrLastOwnerProtected } gone := []string{fullKey} - for key, watch := range i.authorised { - if watch.parent == fullKey { + for key, other := range i.authorised { + // A phone's watches go with it, and so do the agents it approved: + // neither may hold more than the phone that vouches for it, and a + // phone that is gone vouches for nothing. + if other.parent == fullKey || (other.agent != nil && other.agent.ApprovedBy == fullKey) { gone = append(gone, key) } } @@ -1028,7 +1145,10 @@ type RosterEntry struct { Epoch uint64 // ParentRole is the role a watch's phone holds now, empty for a phone. // The scopes the roster carries for a watch are those both roles share. + // For an agent row it is the role its approver holds now. ParentRole string + // Agent is an agent row's grant, nil for a phone or a watch. + Agent *AgentGrant } // Roster lists every paired device, watches included, sorted by id. @@ -1044,8 +1164,13 @@ func (i *Identity) Roster() []RosterEntry { if meta.role == apiauth.RoleWatch && grant.Parent == "" { continue } + if meta.agent != nil && (grant.Agent == nil || grant.Agent.Expired) { + // FTW Cloud stops admitting an agent at its expiry by its + // own clock; a roster after that leaves it out as well. + continue + } out = append(out, RosterEntry{ID: grant.DeviceID, Key: raw, Role: meta.role, Epoch: meta.epoch, - ParentRole: grant.ParentRole}) + ParentRole: grant.ParentRole, Agent: grant.Agent}) } i.mu.Unlock() sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) diff --git a/go/internal/appproto/agentargs.go b/go/internal/appproto/agentargs.go new file mode 100644 index 00000000..7b987690 --- /dev/null +++ b/go/internal/appproto/agentargs.go @@ -0,0 +1,257 @@ +package appproto + +import ( + "maps" + "math" + "regexp" + "slices" + + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// What an agent may put in an op, from the registry's agent_args, checked as +// a cmd or an agent.request arrives. The box refuses any other key, a value +// of another kind and one outside its range, rather than passing it to a +// handler that would read it. + +func badArg(op, arg string, value any) *ErrorBody { + return &ErrorBody{Code: ErrUnknownOp, Args: map[string]any{"op": op, "arg": arg, "value": value}} +} + +// agentArgs checks an agent's args for op against the registry and the +// grant's caps, and returns them with every agent id resolved to the box's +// own name. The caller passes the resolved args on; the agent's stay as +// sent for its own view. +func (h *Handler) agentArgs(op string, args map[string]any, caps protocol.Caps) (map[string]any, *ErrorBody) { + specs, ok := protocol.RegistryAgentArgs[op] + if !ok { + return nil, &ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": op}} + } + out := make(map[string]any, len(args)) + for key, value := range args { + i := slices.IndexFunc(specs, func(a protocol.AgentArg) bool { return a.Name == key }) + if i < 0 { + return nil, badArg(op, key, nil) + } + resolved, refusal := h.agentArg(op, specs[i], value, caps) + if refusal != nil { + return nil, refusal + } + out[key] = resolved + } + for _, a := range specs { + if _, present := args[a.Name]; a.Required && !present { + return nil, badArg(op, a.Name, nil) + } + } + return out, nil +} + +func (h *Handler) agentArg(op string, a protocol.AgentArg, value any, caps protocol.Caps) (any, *ErrorBody) { + switch a.Kind { + case "bool": + if _, ok := value.(bool); !ok { + return nil, badArg(op, a.Name, value) + } + return value, nil + case "enum": + s, ok := value.(string) + if !ok || !slices.Contains(a.Values, s) { + return nil, badArg(op, a.Name, value) + } + return s, nil + case "mode": + s, ok := value.(string) + if !ok || !control.IsValidMode(control.Mode(s)) { + return nil, badArg(op, a.Name, value) + } + return s, nil + case "loadpoint": + s, ok := value.(string) + if !ok { + return nil, badArg(op, a.Name, value) + } + id, found := h.resolveLoadpoint(s) + if !found { + return nil, badArg(op, a.Name, s) + } + return id, nil + case "number", "power", "duration", "ratio": + n, ok := argNum(map[string]any{"v": value}, "v") + if !ok || (a.Min != nil && n < *a.Min) || (a.Max != nil && n > *a.Max) { + return nil, badArg(op, a.Name, value) + } + if a.Kind == "duration" && n != math.Trunc(n) { + return nil, badArg(op, a.Name, value) + } + var limit uint64 + switch a.Cap { + case "holdS": + limit = caps.HoldS + case "maxW": + limit = caps.MaxW + } + // maxW holds in either direction. + if a.Cap != "" && math.Abs(n) > float64(limit) { + return nil, capExceeded(a.Cap, limit) + } + return value, nil + } + // A kind this box does not know is one it cannot check. + return nil, badArg(op, a.Name, value) +} + +// opArgs runs op's own check of its args, the one its handler runs before +// it takes a cmd. An agent.request runs it as it arrives, so an owner is +// never asked to approve something that cannot run. An op without a check +// here is one the box cannot judge ahead, and no owner is asked about it. +func (h *Handler) opArgs(op string, args map[string]any) *ErrorBody { + switch op { + case OpSetMode: + _, bad := modeArg(op, args) + return bad + case OpPlannerPrefsSet: + if h.cfg.PlannerPrefs == nil { + return &ErrorBody{Code: ErrUnavailable, Retryable: ErrorRetryable[ErrUnavailable], Args: map[string]any{"op": op}} + } + _, _, bad := plannerPrefsArgs(op, args) + return bad + case OpLoadpointSoCSet: + if _, _, bad := h.loadpointArg(op, args); bad != nil { + return bad + } + _, bad := socArg(op, args) + return bad + case OpLoadpointSurplusOnlySet: + if _, _, bad := h.loadpointArg(op, args); bad != nil { + return bad + } + _, bad := surplusOnlyArg(op, args) + return bad + } + return &ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": op}} +} + +// agentLoadpointID is a charger's id as agents see it. +func (h *Handler) agentLoadpointID(id string) string { + return cloudwire.LoadpointID(h.cfg.AgentKey, id) +} + +// resolveLoadpoint finds the charger behind an agent's id. +func (h *Handler) resolveLoadpoint(agentID string) (string, bool) { + if h.cfg.Loadpoints == nil { + return "", false + } + for _, r := range h.cfg.Loadpoints.Readings() { + if h.agentLoadpointID(r.ID) == agentID { + return r.ID, true + } + } + return "", false +} + +// holdRules are the hold rules beyond the args: an agent's hold always ends, +// and an agent never touches a hold someone else set. +func (h *Handler) holdRules(cmd Cmd) *ErrorBody { + if cmd.Op != OpLoadpointHold { + return nil + } + clear, _ := cmd.Args["clear"].(bool) + if !clear { + if _, ok := cmd.Args["hold_s"]; !ok { + // No hold_s is the persistent operator hold, which no agent + // may set. + return badArg(cmd.Op, "hold_s", nil) + } + } + id, _ := cmd.Args["id"].(string) + if h.cfg.Loadpoints == nil || id == "" { + return nil + } + held, ok := h.cfg.Loadpoints.ObservedHold(id, h.cfg.Clock.Now()) + if !ok { + if clear { + // Nothing of its own to release. + return &ErrorBody{Code: protocol.ErrNotFound, Args: map[string]any{"op": cmd.Op}} + } + return nil + } + if held.SetBy != h.cfg.Caller.Subject { + return &ErrorBody{Code: protocol.ErrNeedsOwner, Args: map[string]any{"op": cmd.Op, "reason": "hold"}} + } + return nil +} + +// withArgs is cmd with its args replaced. +func withArgs(cmd Cmd, args map[string]any) Cmd { + cmd.Args = maps.Clone(args) + return cmd +} + +// safeArgKeys are the error args an agent may read. Any other key goes: a +// handler written for a phone may name a device, and FTW Cloud reads every +// answer an agent gets. +var safeArgKeys = map[string]bool{ + "op": true, "arg": true, "value": true, "cap": true, "max": true, "min": true, "use": true, + "needScope": true, "role": true, "reason": true, "field": true, "missing": true, "t": true, + "expected": true, "actual": true, "fid": true, "guards": true, "subsystem": true, + "dispatchBlockedBy": true, "id": true, "notValidAfterMs": true, "uptimeMs": true, "boxMs": true, + "agentId": true, "reqId": true, +} + +// forAgent is error args as an agent may read them: names become agent ids, +// and anything else that could name something goes. +func (h *Handler) forAgent(args map[string]any) map[string]any { + if args == nil { + return nil + } + out := make(map[string]any, len(args)) + for k, v := range args { + if !safeArgKeys[k] { + continue + } + switch k { + case "dispatchBlockedBy": + names, _ := v.([]string) + ids := make([]string, 0, len(names)) + for _, n := range names { + ids = append(ids, h.srcID(n)) + } + out[k] = ids + continue + case "id": + if s, ok := v.(string); ok { + out[k] = h.agentLoadpointID(s) + } + continue + case "value": + if s, ok := v.(string); ok { + if args["arg"] == "id" { + out[k] = h.agentLoadpointID(s) + } else if codeWord.MatchString(s) { + out[k] = s + } + continue + } + } + if s, ok := v.(string); ok && !codeWord.MatchString(s) { + continue + } + out[k] = v + } + return out +} + +// forAgentResult is a command's outcome as an agent may read it. +func (h *Handler) forAgentResult(res CmdResult) CmdResult { + if res.Error != nil { + e := *res.Error + e.Args = h.forAgent(e.Args) + res.Error = &e + } + return res +} + +var codeWord = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,48}$`) diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go new file mode 100644 index 00000000..9dd0f342 --- /dev/null +++ b/go/internal/appproto/agents.go @@ -0,0 +1,1098 @@ +package appproto + +// Agents on the app session (docs/agents.md). +// +// An agent's session is the app session: the same frames, the same Noise, +// the same handler. What differs is the door it went through — FTW Cloud's +// tunnel and nowhere else — and the checks every call meets. An agent's +// frames go through dispatchAgent, which knows a short list of messages and +// asks, on each one, whether the grant still exists, whether the clock says +// it has run out, and whether its scopes open the message. A command then +// meets the per-grant op allowlist, the rate and the caps in runCmd, in +// Core's usual order. +// +// Owner phones manage agents through the same session: they add, widen, +// revoke, list and confirm, each signed by the phone's approval key where +// docs/agents.md says so. The checks of a grant and of a signature live in +// appenroll, behind Access; this file decides who may ask. + +import ( + "context" + "errors" + "regexp" + "slices" + "strings" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/redact" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Access is the box's agent grants and the owners' approval keys. +type Access interface { + AddAgent(deviceID string, m protocol.AgentAdd) (protocol.AgentRow, error) + WidenAgent(deviceID string, m protocol.AgentWiden) (protocol.AgentRow, error) + // RevokeAgent removes an agent row and ends its sessions. + RevokeAgent(id string) error + Agents() []protocol.AgentRow + ApprovalKeys() []protocol.ApprovalKeyInfo + SetApprovalKey(deviceID string, m protocol.ApprovalKeySet) (int64, error) + CheckConfirm(deviceID string, m protocol.AgentConfirm, req protocol.Request) error + // EndHolds releases every hold the agent set. + EndHolds(agentID string) + // Wall is the wall clock in ms, and whether NTP has set it. + Wall() (int64, bool) + // Notify pushes a catalogue kind to every owner. + Notify(kind string) +} + +// AgentViews builds the typed reads' bodies: FTW Cloud's class bodies, with +// every name cut into an id under key. +type AgentViews interface { + Live(key []byte) cloudwire.Live + Loadpoints(key []byte) (cloudwire.LP, bool) +} + +// AuditLog keeps the box's audit rows. +type AuditLog interface { + Append(protocol.AuditRow) + Page(beforeSeq uint64, n int) ([]protocol.AuditRow, bool, error) +} + +// LogSource is the box's recent log lines, oldest first. +type LogSource interface { + Recent(n int) []redact.Line +} + +// AgentGrant is an agent row's grant as the box holds it this second. +type AgentGrant struct { + Scopes []string + Ops []string + Caps protocol.Caps + ExpiresAtMs int64 + Principal protocol.Principal + // Expired is true once the wall clock passed ExpiresAtMs; NoClock while + // the box cannot tell. Either way the agent may do nothing. + Expired bool + NoClock bool +} + +// AgentScopes is what an agent holds now: the scopes its grant lists that +// its role allows and its approver holds this moment. +func AgentScopes(grant []string, role, approverRole string) []string { + own := ScopesForRole(role) + approver := ScopesForRole(approverRole) + out := make([]string, 0, len(grant)) + for _, s := range grant { + if own.Has(s) && approver.Has(s) { + out = append(out, s) + } + } + return out +} + +// ScopesForEnrolment is what an enrolment holds now: a phone's role, a +// watch's role within its phone's, or an agent's grant within its approver's. +func ScopesForEnrolment(e Enrolment) apiauth.ScopeSet { + if e.Agent != nil { + return apiauth.NewScopeSet(AgentScopes(e.Agent.Scopes, e.Role, e.ParentRole)...) + } + return ScopesForGrant(e.Role, e.ParentRole) +} + +// agentReads maps each read an agent may send to the scope it needs: the +// registry's reads table. sub is not one: its source table names devices. +var agentReads = protocol.RegistryReads + +// agentOther is every other message an agent may send. Its own checks run +// in the handler. +var agentOther = map[string]bool{ + MsgHello: true, + MsgCmd: true, + protocol.MsgAgentRequest: true, + protocol.MsgAgentList: true, + protocol.MsgAgentRevoke: true, +} + +func (h *Handler) isAgent() bool { return h.cfg.Caller.Kind == apiauth.KindAgent } + +// selfID is this session's own row id: an agent's or a phone's. +func (h *Handler) selfID() string { + _, id, _ := strings.Cut(h.cfg.Caller.Subject, ":") + return id +} + +func (h *Handler) refuse(id *uint32, code string, args map[string]any) error { + return h.sendError(id, ErrorBody{Code: code, Retryable: ErrorRetryable[code], Args: args}) +} + +// refuseErr answers a refusal from Access, or an internal failure as one +// the caller may retry. +func (h *Handler) refuseErr(id *uint32, err error) error { + var r *protocol.Refusal + if errors.As(err, &r) { + return h.refuse(id, r.Code, r.Args) + } + h.log.Warn("agent request failed", "err", err) + return h.refuse(id, ErrUnavailable, map[string]any{"subsystem": "access"}) +} + +func codeOf(err error) string { + var r *protocol.Refusal + if errors.As(err, &r) { + return r.Code + } + return ErrUnavailable +} + +func (h *Handler) agentOK(id *uint32, body any) error { + return h.sendBulk(protocol.MsgAgentOK, id, body) +} + +// wall is the wall clock in ms and whether it can be trusted. +func (h *Handler) wall() (int64, bool) { + if h.cfg.Access != nil { + return h.cfg.Access.Wall() + } + return h.cfg.Clock.Now().UnixMilli(), true +} + +// audit writes one row naming this session's caller. An agent's refusal is +// folded by the minute like its reads, so junk cannot crowd out real rows. +func (h *Handler) audit(by, what, target, result string) { + if h.cfg.Audit == nil { + return + } + if h.isAgent() && result != "ok" && !isState(result) { + h.foldAudit(h.auditRow(by, what, "", result)) + return + } + h.cfg.Audit.Append(h.auditRow(by, what, target, result)) +} + +// isState is a command's or a request's outcome, which gets its own row. +func isState(result string) bool { + switch result { + case CmdApplied, CmdRejected, CmdExpired, CmdSuperseded, CmdUnconfirmed, + protocol.RequestApproved, protocol.RequestDeclined: + return true + } + return false +} + +func (h *Handler) foldAudit(row protocol.AuditRow) { + if done := h.cfg.Agents.foldRead(row, h.cfg.Clock.Now().UnixMilli()); done != nil { + h.cfg.Audit.Append(*done) + } +} + +// auditRead counts an agent's read into its minute's row; the row is +// written when the minute ends. +func (h *Handler) auditRead(by, what string) { + if h.cfg.Audit == nil { + return + } + h.foldAudit(h.auditRow(by, what, "", "ok")) +} + +func (h *Handler) auditRow(by, what, target, result string) protocol.AuditRow { + at, ok := h.wall() + if !ok { + at = 0 + } + row := protocol.AuditRow{AtMs: at, Actor: h.cfg.Caller.Subject, Role: h.cfg.Caller.Role, By: auditText(by), + What: auditWord(what), Target: auditText(target), Result: auditWord(result)} + if h.isAgent() { + if grant, ok := h.cfg.Grants.Grant(); ok && grant.Agent != nil { + row.Principal = grant.Agent.Principal.Client + if grant.Agent.Principal.Ticket != "" { + row.Principal = grant.Agent.Principal.Ticket + } + } + } + return row +} + +func resultOf(err error) string { + if err == nil { + return "ok" + } + return codeOf(err) +} + +// -------------------------------------------------------------------------- +// An agent's session +// -------------------------------------------------------------------------- + +// agentGate is the live grant's answer for an agent: nil to go on, or the +// refusal and, when the session must end, why. +func (h *Handler) agentGate() (Enrolment, *ErrorBody, string) { + grant, ok := h.cfg.Grants.Grant() + switch { + case !ok || grant.Agent == nil: + return grant, &ErrorBody{Code: ErrGrantRevoked, Retryable: ErrorRetryable[ErrGrantRevoked]}, TerminateRevoked + case grant.Agent.NoClock: + return grant, &ErrorBody{Code: ErrUnavailable, Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"subsystem": "clock"}}, "" + case grant.Agent.Expired: + return grant, &ErrorBody{Code: protocol.ErrGrantExpired, Retryable: ErrorRetryable[protocol.ErrGrantExpired]}, + protocol.TerminateExpired + } + return grant, nil, "" +} + +// dispatchAgent is dispatch for an agent's session. +func (h *Handler) dispatchAgent(ctx context.Context, env Envelope) error { + if env.T == MsgCmd { + // A command answers in cmd.result under its own id, so runCmd + // makes every check, the grant's included. + return h.onCmd(ctx, env, env.By) + } + grant, refusal, end := h.agentGate() + if refusal != nil { + h.audit(env.By, env.T, "", refusal.Code) + if err := h.sendError(env.ID, *refusal); err != nil { + return err + } + if end != "" { + return h.Terminate(end) + } + return nil + } + if refusal := h.supportBy(env.By); refusal != nil { + h.audit("", env.T, "", refusal.Code) + return h.sendError(env.ID, *refusal) + } + need, read := agentReads[env.T] + if !read && !agentOther[env.T] { + h.audit(env.By, env.T, "", ErrScopeDenied) + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T}) + } + if read { + if !ScopesForEnrolment(grant).Has(need) { + h.audit(env.By, env.T, "", ErrScopeDenied) + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T, "needScope": need, "role": grant.Role}) + } + if ok, wait := h.cfg.Agents.allowRead(h.selfID(), h.cfg.Clock.UptimeMs()); !ok { + h.audit(env.By, env.T, "", protocol.ErrRateLimited) + return h.sendError(env.ID, ErrorBody{Code: protocol.ErrRateLimited, + Retryable: ErrorRetryable[protocol.ErrRateLimited], RetryAfterMs: &wait}) + } + h.auditRead(env.By, env.T) + } + + switch env.T { + case MsgHello: + return h.onHello(env) + case MsgSub: + return h.onSub(env) + case MsgPlanGet: + return h.onPlanGet(env.ID) + case MsgPriceGet: + return h.onPriceGet(ctx, env) + case MsgHistQuery: + return h.onHistQuery(ctx, env) + case protocol.MsgAgentRequest: + return h.onAgentRequest(env, grant) + case protocol.MsgAgentList: + return h.onAgentList(env) + case protocol.MsgAgentRevoke: + return h.onAgentRevoke(env) + default: + return h.onRead(env) + } +} + +// supportBy refuses a support call that names nobody. +// A support call must name who made it, as staff: or bot:, and +// no other session may name anyone. +func (h *Handler) supportBy(by string) *ErrorBody { + support := h.cfg.Caller.Role == apiauth.RoleSupport + switch { + case support && by == "": + return &ErrorBody{Code: ErrUnknownOp, Retryable: false, Args: map[string]any{"missing": "by"}} + case support && !protocol.ValidBy(by), !support && by != "": + return &ErrorBody{Code: ErrUnknownOp, Retryable: false, Args: map[string]any{"field": "by"}} + } + return nil +} + +// agentTick ends an agent's session once its grant is gone or has run out, +// without waiting for its next call. +func (h *Handler) agentTick() error { + _, refusal, end := h.agentGate() + if refusal == nil || end == "" { + return nil + } + h.audit("", "session", "", refusal.Code) + // Its holds end with it, whatever they had left. + h.cfg.Access.EndHolds(h.selfID()) + _ = h.Terminate(end) + return errAgentEnded +} + +var errAgentEnded = errors.New("appproto: the agent's grant ended") + +// cmdCaller is liveCaller for a command, with an agent's own checks: the +// grant, the clock, the allowlist, the rate and the caps. end names why the +// session must end, or is empty. +func (h *Handler) cmdCaller(cmd Cmd, by string) (apiauth.Caller, Cmd, *ErrorBody, string) { + if !h.isAgent() { + caller, enrolled := h.liveCaller() + if !enrolled { + return caller, cmd, &ErrorBody{Code: ErrGrantRevoked, Retryable: ErrorRetryable[ErrGrantRevoked]}, TerminateRevoked + } + return caller, cmd, nil, "" + } + grant, refusal, end := h.agentGate() + if refusal != nil { + return apiauth.Caller{}, cmd, refusal, end + } + if refusal := h.supportBy(by); refusal != nil { + return apiauth.Caller{}, cmd, refusal, "" + } + caller, enrolled := h.liveCaller() + if !enrolled { + return caller, cmd, &ErrorBody{Code: ErrGrantRevoked, Retryable: ErrorRetryable[ErrGrantRevoked]}, TerminateRevoked + } + need, ok := protocol.RegistryAgentOps[cmd.Op] + switch { + case !ok: + return caller, cmd, &ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": cmd.Op}}, "" + case need == protocol.ScopeGoalsRequest: + return caller, cmd, &ErrorBody{Code: protocol.ErrNeedsOwner, + Args: map[string]any{"op": cmd.Op, "use": protocol.MsgAgentRequest}}, "" + case !caller.Scopes.Has(need): + return caller, cmd, &ErrorBody{Code: ErrScopeDenied, + Args: map[string]any{"op": cmd.Op, "needScope": need, "role": caller.Role}}, "" + case !slices.Contains(grant.Agent.Ops, cmd.Op): + return caller, cmd, &ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": cmd.Op}}, "" + } + args, refusal := h.agentArgs(cmd.Op, cmd.Args, grant.Agent.Caps) + if refusal != nil { + return caller, cmd, refusal, "" + } + if ok, wait := h.cfg.Agents.allowCmd(h.selfID(), grant.Agent.Caps.PerMin, h.cfg.Clock.UptimeMs()); !ok { + return caller, cmd, &ErrorBody{Code: protocol.ErrRateLimited, Retryable: true, RetryAfterMs: &wait}, "" + } + if len(cmd.Expect.Guards) > 0 && !caller.Scopes.Has(ScopeLiveRead) { + // A guard's refusal says what a field reads; without live.read an + // agent may not ask. + return caller, cmd, &ErrorBody{Code: ErrScopeDenied, + Args: map[string]any{"op": cmd.Op, "needScope": ScopeLiveRead, "field": "guards"}}, "" + } + cmd = withArgs(cmd, args) + if refusal := h.holdRules(cmd); refusal != nil { + return caller, cmd, refusal, "" + } + if id, _ := cmd.Args["id"].(string); cmd.Op == OpLoadpointHold && id != "" { + if ok, wait := h.cfg.Agents.allowHold(h.selfID(), id, h.cfg.Clock.UptimeMs()); !ok { + return caller, cmd, &ErrorBody{Code: protocol.ErrRateLimited, Retryable: true, RetryAfterMs: &wait, + Args: map[string]any{"op": cmd.Op, "reason": "hold_gap"}}, "" + } + } + return caller, cmd, nil, "" +} + +func capExceeded(cap string, max uint64) *ErrorBody { + return &ErrorBody{Code: protocol.ErrCapExceeded, Args: map[string]any{"cap": cap, "max": max}} +} + +// noteCmd remembers what an audited command was, so its result can name it. +func (h *Handler) noteCmd(cmdID string, row cmdAudit) { + h.mu.Lock() + defer h.mu.Unlock() + if h.cmdAudits == nil { + h.cmdAudits = map[string]cmdAudit{} + } + h.cmdAudits[cmdID] = row +} + +type cmdAudit struct{ by, what, target string } + +// auditCmdResult writes the audit row for a command's outcome. +func (h *Handler) auditCmdResult(res CmdResult) { + h.mu.Lock() + row, ok := h.cmdAudits[res.CmdID] + delete(h.cmdAudits, res.CmdID) + h.mu.Unlock() + if !ok { + return + } + result := res.State + if res.Error != nil && res.State != CmdRejected { + result = res.Error.Code + } + if res.Error != nil && res.State == CmdRejected && h.isAgent() { + // A refused command folds by the minute, with its code. + h.foldAudit(h.auditRow(row.by, row.what, "", res.Error.Code)) + return + } + if res.Error != nil { + result = res.Error.Code + } + h.audit(row.by, row.what, row.target, result) +} + +// onAgentRequest takes a durable change an agent asks for. It waits for an +// owner; nothing here acts. +func (h *Handler) onAgentRequest(env Envelope, grant Enrolment) error { + var m protocol.AgentRequest + if err := Unmarshal(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + refuse := func(code string, args map[string]any) error { + h.audit(env.By, env.T, m.Op, code) + return h.refuse(env.ID, code, args) + } + need, ok := protocol.RegistryAgentOps[m.Op] + if _, known := h.ops[m.Op]; !known { + return refuse(ErrUnknownOp, map[string]any{"op": m.Op}) + } + switch { + case !ok || need != protocol.ScopeGoalsRequest: + return refuse(protocol.ErrOpNotGranted, map[string]any{"op": m.Op}) + case !ScopesForEnrolment(grant).Has(need): + return refuse(ErrScopeDenied, map[string]any{"op": m.Op, "needScope": need}) + case !slices.Contains(grant.Agent.Ops, m.Op): + return refuse(protocol.ErrOpNotGranted, map[string]any{"op": m.Op}) + case len(m.Why) > protocol.RequestWhyMax: + return refuse(ErrUnknownOp, map[string]any{"field": "why"}) + } + if raw, err := Marshal(m.Args); err != nil || len(raw) > protocol.RequestArgsMax { + return refuse(ErrUnknownOp, map[string]any{"field": "args"}) + } + // The args meet the registry's allowlist and the op's own checks now, + // so the owner is never asked to approve something that cannot run. + resolved, bad := h.agentArgs(m.Op, m.Args, grant.Agent.Caps) + if bad == nil { + bad = h.opArgs(m.Op, resolved) + } + if bad != nil { + h.audit(env.By, env.T, m.Op, bad.Code) + return h.sendError(env.ID, *bad) + } + if ok, wait := h.cfg.Agents.allowCmd(h.selfID(), grant.Agent.Caps.PerMin, h.cfg.Clock.UptimeMs()); !ok { + h.audit(env.By, env.T, m.Op, protocol.ErrRateLimited) + return h.sendError(env.ID, ErrorBody{Code: protocol.ErrRateLimited, Retryable: true, RetryAfterMs: &wait}) + } + now, _ := h.wall() + req, refusal := h.cfg.Agents.add(h.selfID(), m.Op, resolved, m.Args, m.Why, now) + if refusal != nil { + return refuse(refusal.Code, refusal.Args) + } + h.audit(env.By, env.T, m.Op+" "+req.ReqID, "ok") + h.cfg.Access.Notify(protocol.PushAgentRequest) + return h.agentOK(env.ID, map[string]any{"reqId": req.ReqID, "expiresAtMs": req.ExpiresAtMs}) +} + +// -------------------------------------------------------------------------- +// Owners managing agents, and anyone listing or revoking +// -------------------------------------------------------------------------- + +// ownerPhone is the caller if it is an owner phone now, or the refusal. +func (h *Handler) ownerPhone(id *uint32) (apiauth.Caller, bool, error) { + if h.isAgent() || h.cfg.Access == nil { + return apiauth.Caller{}, false, h.refuse(id, ErrScopeDenied, map[string]any{"needRole": apiauth.RoleOwner}) + } + caller, enrolled := h.liveCaller() + if !enrolled { + if err := h.refuse(id, ErrGrantRevoked, nil); err != nil { + return caller, false, err + } + return caller, false, h.Terminate(TerminateRevoked) + } + if caller.Role != apiauth.RoleOwner { + return caller, false, h.refuse(id, ErrScopeDenied, map[string]any{"needRole": apiauth.RoleOwner, "role": caller.Role}) + } + return caller, true, nil +} + +func (h *Handler) onApprovalKeySet(env Envelope) error { + if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + var m protocol.ApprovalKeySet + if err := protocol.UnmarshalSigned(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T, "field": "body"}) + } + setAt, err := h.cfg.Access.SetApprovalKey(h.selfID(), m) + h.audit("", env.T, h.selfID(), resultOf(err)) + if err != nil { + return h.refuseErr(env.ID, err) + } + // A new key is a new way to let others in: every owner hears of it. + h.cfg.Access.Notify(protocol.PushApprovalKeySet) + return h.agentOK(env.ID, map[string]any{"deviceId": h.selfID(), "setAtMs": setAt}) +} + +func (h *Handler) onAgentAdd(env Envelope) error { + if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + var m protocol.AgentAdd + if err := protocol.UnmarshalSigned(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T, "field": "body"}) + } + row, err := h.cfg.Access.AddAgent(h.selfID(), m) + h.audit("", env.T, row.ID, resultOf(err)) + if err != nil { + return h.refuseErr(env.ID, err) + } + return h.agentOK(env.ID, map[string]any{"agent": row}) +} + +func (h *Handler) onAgentWiden(env Envelope) error { + if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + var m protocol.AgentWiden + if err := protocol.UnmarshalSigned(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T, "field": "body"}) + } + row, err := h.cfg.Access.WidenAgent(h.selfID(), m) + h.audit("", env.T, m.ID, resultOf(err)) + var r *protocol.Refusal + if errors.As(err, &r) && r.Code == ErrConflict { + // A stale epoch: the owner reads the row again before signing again, + // so the same request would only fail again. + return h.sendError(env.ID, ErrorBody{Code: ErrConflict, Retryable: false, Args: r.Args}) + } + if err != nil { + return h.refuseErr(env.ID, err) + } + // A narrower grant ends any hold the old one set; every owner hears of + // a change to someone's access. + if !slices.Contains(row.Ops, OpLoadpointHold) || !slices.Contains(row.Scopes, protocol.ScopeDispatchHold) { + h.cfg.Access.EndHolds(row.ID) + } + h.cfg.Access.Notify(protocol.PushAgentAdded) + return h.agentOK(env.ID, map[string]any{"agent": row}) +} + +// onAgentRevoke removes a grant: any, for an owner; its own, for an agent. +func (h *Handler) onAgentRevoke(env Envelope) error { + var m protocol.AgentRevoke + if err := Unmarshal(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + if h.isAgent() { + if m.ID != h.selfID() { + h.audit(env.By, env.T, m.ID, ErrScopeDenied) + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"agentId": m.ID}) + } + } else if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + if !h.agentExists(m.ID) { + h.audit(env.By, env.T, m.ID, protocol.ErrNotFound) + return h.refuse(env.ID, protocol.ErrNotFound, map[string]any{"agentId": m.ID}) + } + err := h.cfg.Access.RevokeAgent(m.ID) + h.audit(env.By, env.T, m.ID, resultOf(err)) + if err != nil { + return h.refuseErr(env.ID, err) + } + h.cfg.Agents.Forget(m.ID) + return h.agentOK(env.ID, map[string]any{"id": m.ID}) +} + +func (h *Handler) agentExists(id string) bool { + for _, row := range h.cfg.Access.Agents() { + if row.ID == id { + return true + } + } + return false +} + +// liveAgents is whether an agent still has a row, for pruning requests. +func (h *Handler) liveAgents() func(string) bool { + rows := h.cfg.Access.Agents() + return func(id string) bool { + return slices.ContainsFunc(rows, func(r protocol.AgentRow) bool { return r.ID == id }) + } +} + +// onAgentList answers an owner with every row and an agent with its own. +func (h *Handler) onAgentList(env Envelope) error { + if h.cfg.Access == nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + defer h.cfg.Agents.tell("", protocol.AgentDecided{}) + now, _ := h.wall() + out := protocol.AgentList{Agents: []protocol.AgentRow{}, Keys: []protocol.ApprovalKeyInfo{}} + if h.isAgent() { + for _, row := range h.cfg.Access.Agents() { + if row.ID == h.selfID() { + out.Agents = append(out.Agents, row) + } + } + out.Requests = h.cfg.Agents.list(h.selfID(), now, h.liveAgents()) + return h.agentOK(env.ID, out) + } + caller, enrolled := h.liveCaller() + if !enrolled || !caller.Scopes.Has(ScopeMembersRead) { + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"needScope": ScopeMembersRead}) + } + out.Agents = append(out.Agents, h.cfg.Access.Agents()...) + out.Requests = h.cfg.Agents.list("", now, h.liveAgents()) + out.Keys = append(out.Keys, h.cfg.Access.ApprovalKeys()...) + return h.agentOK(env.ID, out) +} + +// onAgentConfirm answers a request. Approving runs the op here, on the +// owner's own session and under the owner's scopes, as a command. +func (h *Handler) onAgentConfirm(ctx context.Context, env Envelope) error { + if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + var m protocol.AgentConfirm + if err := protocol.UnmarshalSigned(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T, "field": "body"}) + } + now, synced := h.wall() + if m.Approve && !synced { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "clock"}) + } + live := h.liveAgents() + req, ok := h.cfg.Agents.pending(m.ReqID, now, live) + if !ok { + h.audit("", env.T, m.ReqID, protocol.ErrNotFound) + return h.refuse(env.ID, protocol.ErrNotFound, map[string]any{"reqId": m.ReqID}) + } + defer h.cfg.Agents.tell("", protocol.AgentDecided{}) + if !m.Approve { + h.cfg.Agents.decide(m.ReqID, protocol.RequestDeclined, now) + h.audit("", env.T, m.ReqID, protocol.RequestDeclined) + h.cfg.Agents.tell(req.Agent, protocol.AgentDecided{ReqID: m.ReqID, State: protocol.RequestDeclined}) + return h.agentOK(env.ID, map[string]any{"reqId": m.ReqID, "state": protocol.RequestDeclined}) + } + if !h.stillGranted(req) { + h.cfg.Agents.decide(m.ReqID, protocol.RequestExpired, now) + h.audit("", env.T, m.ReqID, protocol.ErrNotFound) + return h.refuse(env.ID, protocol.ErrNotFound, map[string]any{"reqId": m.ReqID}) + } + if err := h.cfg.Access.CheckConfirm(h.selfID(), m, req); err != nil { + h.audit("", env.T, m.ReqID, codeOf(err)) + return h.refuseErr(env.ID, err) + } + if !h.cfg.Agents.decide(m.ReqID, protocol.RequestApproved, now) { + return h.refuse(env.ID, protocol.ErrNotFound, map[string]any{"reqId": m.ReqID}) + } + h.audit("", env.T, m.ReqID, protocol.RequestApproved) + cmdID := "req:" + m.ReqID + if err := h.agentOK(env.ID, map[string]any{"reqId": m.ReqID, "state": protocol.RequestApproved, "cmdId": cmdID}); err != nil { + return err + } + return h.runRequest(ctx, req) +} + +// runRequest runs an approved request as a command on this session, under +// its caller's scopes, and tells the agent how it went. +func (h *Handler) runRequest(ctx context.Context, req protocol.Request) error { + cmdID := "req:" + req.ReqID + h.noteCmd(cmdID, cmdAudit{what: "agent.request.run", target: req.Op + " " + req.ReqID}) + snap := h.cfg.Site.Snapshot() + err := h.runCmd(ctx, Cmd{ + CmdID: cmdID, Op: req.Op, Args: req.Args, + NotValidAfterMs: h.cfg.Clock.UptimeMs() + confirmRunMs, + Expect: Expect{Rev: snap.ControlRev}, + }, "") + d := protocol.AgentDecided{ReqID: req.ReqID, State: protocol.RequestApproved, CmdID: cmdID} + if rec, ok := h.cfg.Commands.lookup(h.cmdRoot(), cmdID); ok { + d.Result = rec.result + } + h.cfg.Agents.tell(req.Agent, d) + return err +} + +// confirmRunMs is how long a confirmed request's command stays valid: it +// runs at once, so this only bounds a stall. +const confirmRunMs = 30_000 + +// stillGranted says whether the agent behind a request may still ask for it. +func (h *Handler) stillGranted(req protocol.Request) bool { + for _, row := range h.cfg.Access.Agents() { + if row.ID == req.Agent { + return slices.Contains(row.Ops, req.Op) && slices.Contains(row.Scopes, protocol.ScopeGoalsRequest) + } + } + return false +} + +func (h *Handler) onAuditGet(env Envelope) error { + if h.isAgent() || h.cfg.Audit == nil { + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T}) + } + caller, enrolled := h.liveCaller() + if !enrolled || !caller.Scopes.Has(ScopeMembersRead) { + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"needScope": ScopeMembersRead}) + } + var m protocol.AuditGet + if len(env.B) > 0 { + if err := Unmarshal(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + } + n := int(m.N) + if n <= 0 || n > protocol.AuditPageMax { + n = protocol.AuditPageMax + } + rows, more, err := h.cfg.Audit.Page(m.BeforeSeq, n) + if err != nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "audit"}) + } + page := protocol.AuditPage{Rows: rows, More: more} + if page.Rows == nil { + page.Rows = []protocol.AuditRow{} + } + // One bulk frame holds the page; a long one gives up its oldest rows + // and says more remain. + for !h.fits(protocol.MsgAgentOK, env.ID, page) && len(page.Rows) > 1 { + page.Rows = page.Rows[:len(page.Rows)*3/4] + page.More = true + } + return h.agentOK(env.ID, page) +} + +// fits says whether a body goes in one bulk frame. +func (h *Handler) fits(t string, id *uint32, body any) bool { + env, err := newEnvelope(t, id, body) + if err != nil { + return false + } + raw, err := EncodeEnvelope(env) + return err == nil && bulkBucketFor(len(raw)) != 0 +} + +// -------------------------------------------------------------------------- +// Reads built for agents +// -------------------------------------------------------------------------- + +// onRead answers the reads docs/agents.md adds. A phone meets the same scope +// check an agent does; dispatchAgent has already made it for an agent. +func (h *Handler) onRead(env Envelope) error { + if !h.isAgent() { + caller, enrolled := h.liveCaller() + if need := agentReads[env.T]; !enrolled || !caller.Scopes.Has(need) { + return h.refuse(env.ID, ErrScopeDenied, map[string]any{"t": env.T, "needScope": need}) + } + } + switch env.T { + case protocol.MsgHealthGet: + return h.onHealthGet(env) + case protocol.MsgDriversGet: + return h.onDriversGet(env) + case protocol.MsgLiveGet: + return h.onLiveGet(env) + case protocol.MsgLoadpointsGet: + return h.onLoadpointsGet(env) + case protocol.MsgLogsGet: + return h.onLogsGet(env) + case protocol.MsgConfigGet: + return h.onConfigGet(env) + default: + // events.get, diag.run and cars.get are in the contract and not + // built yet; cars.get waits for the car contract. + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } +} + +// srcID is a source's id as agents see it. +func (h *Handler) srcID(name string) string { return cloudwire.SourceID(h.cfg.AgentKey, name) } + +func (h *Handler) onLiveGet(env Envelope) error { + if h.cfg.Views == nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "live"}) + } + return h.sendBulk(protocol.MsgLive, env.ID, h.cfg.Views.Live(h.cfg.AgentKey)) +} + +func (h *Handler) onLoadpointsGet(env Envelope) error { + if h.cfg.Views == nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "loadpoints"}) + } + lp, ok := h.cfg.Views.Loadpoints(h.cfg.AgentKey) + if !ok { + lp = cloudwire.LP{} + } + return h.sendBulk(protocol.MsgLoadpoints, env.ID, lp) +} + +func (h *Handler) onDriversGet(env Envelope) error { + snap := h.cfg.Site.Snapshot() + up := h.cfg.Clock.UptimeMs() + out := protocol.Drivers{Drivers: []protocol.DriverState{}} + for _, s := range snap.Sources { + age := int64(-1) + if s.LastOkUptimeMs >= 0 { + age = up - s.LastOkUptimeMs + } + out.Drivers = append(out.Drivers, protocol.DriverState{ + Src: h.srcID(s.ID), Kind: s.Kind, State: string(sourceState(s, up)), AgeMs: age, + }) + } + return h.sendBulk(protocol.MsgDrivers, env.ID, out) +} + +// Health is the health summary: no readings and no names. +type Health struct { + Version string `cbor:"version"` + UptimeS int64 `cbor:"uptimeS"` + Clock string `cbor:"clock"` + Drivers map[string]int `cbor:"drivers"` + Meter *HealthMeter `cbor:"meter,omitempty"` + DispatchBlockedBy []string `cbor:"dispatchBlockedBy"` +} + +// HealthMeter is the site meter's freshness. +type HealthMeter struct { + SrcState string `cbor:"srcState"` + AgeMs int64 `cbor:"ageMs"` +} + +func (h *Handler) onHealthGet(env Envelope) error { + snap := h.cfg.Site.Snapshot() + up := h.cfg.Clock.UptimeMs() + clock, _ := h.cfg.Clock.Sync() + if _, synced := h.wall(); !synced { + clock = "none" + } + out := Health{ + Version: h.cfg.Info.Identity().Build, UptimeS: up / 1000, Clock: clock, + Drivers: map[string]int{"ok": 0, "degraded": 0, "faulted": 0, "offline": 0}, + DispatchBlockedBy: []string{}, + } + for _, s := range snap.Sources { + state := sourceState(s, up) + switch state { + case SourceLive: + out.Drivers["ok"]++ + case SourceLagging: + out.Drivers["degraded"]++ + case SourceStale: + out.Drivers["faulted"]++ + default: + out.Drivers["offline"]++ + } + if s.ID == h.cfg.SrcGrid { + m := &HealthMeter{SrcState: string(state), AgeMs: -1} + if s.LastOkUptimeMs >= 0 { + m.AgeMs = up - s.LastOkUptimeMs + } + out.Meter = m + } + } + for _, name := range snap.DispatchBlockedBy { + // Names stay home: a blocked source goes by its agent id. + out.DispatchBlockedBy = append(out.DispatchBlockedBy, h.srcID(name)) + } + return h.sendBulk(protocol.MsgHealth, env.ID, out) +} + +func (h *Handler) onLogsGet(env Envelope) error { + if h.cfg.Logs == nil || h.cfg.Redactor == nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "logs"}) + } + var m protocol.LogsGet + if len(env.B) > 0 { + if err := Unmarshal(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + } + n := int(m.N) + if n <= 0 || n > protocol.LogsLinesMax { + n = protocol.LogsLinesMax + } + floor := levelRank(m.Level) + if m.Level == "" { + floor = levelRank("WARN") + } + if m.BeforeMs < 0 || m.AfterMs < 0 || (m.BeforeMs > 0 && m.AfterMs >= m.BeforeMs) { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T, "field": "beforeMs"}) + } + red := h.cfg.Redactor() + // The whole ring, every level: the filter and the cursors pick from it. + var picked []redact.Line + for _, l := range h.cfg.Logs.Recent(logsScan) { + if levelRank(l.Level) < floor || (m.BeforeMs > 0 && l.TsMs >= m.BeforeMs) || (m.AfterMs > 0 && l.TsMs <= m.AfterMs) { + continue + } + picked = append(picked, l) + } + // afterMs pages forward from the cursor: the oldest lines after it. + // Otherwise the page is the newest lines before beforeMs, or now. + forward := m.AfterMs > 0 && m.BeforeMs == 0 + out := protocol.Logs{Lines: []protocol.LogLine{}} + if len(picked) > n { + out.More = true + if forward { + picked = picked[:n] + } else { + picked = picked[len(picked)-n:] + } + } + for _, l := range picked { + r := red.Log(l) + out.Lines = append(out.Lines, protocol.LogLine{Ts: r.TsMs, Level: r.Level, Src: r.Src, Msg: r.Msg}) + } + // One bulk frame holds the answer. The lines furthest from the cursor + // give way, and the next page reaches them. + for !h.fits(protocol.MsgLogs, env.ID, out) && len(out.Lines) > 0 { + drop := max(1, len(out.Lines)/4) + out.Dropped += uint64(drop) + out.More = true + if forward { + out.Lines = out.Lines[:len(out.Lines)-drop] + } else { + out.Lines = out.Lines[drop:] + } + } + return h.sendBulk(protocol.MsgLogs, env.ID, out) +} + +// logsScan is how far back logs.get looks: the box's whole log ring. +const logsScan = 2000 + +func levelRank(level string) int { + switch strings.ToUpper(level) { + case "DEBUG": + return 0 + case "INFO": + return 1 + case "WARN", "WARNING": + return 2 + case "ERROR": + return 3 + } + return 1 +} + +// ConfigGet asks for one top-level section of the configuration, or for the +// list of sections when Section is empty. +type ConfigGet struct { + Section string `cbor:"section,omitempty"` +} + +func (h *Handler) onConfigGet(env Envelope) error { + if h.cfg.ConfigDoc == nil || h.cfg.Redactor == nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "config"}) + } + var m ConfigGet + if len(env.B) > 0 { + if err := Unmarshal(env.B, &m); err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + } + doc, err := h.cfg.ConfigDoc() + if err != nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "config"}) + } + top, _ := h.cfg.Redactor().Config(doc).(map[string]any) + if m.Section == "" { + sections := make([]string, 0, len(top)) + for k := range top { + sections = append(sections, k) + } + slices.Sort(sections) + return h.sendBulk(protocol.MsgConfig, env.ID, map[string]any{"sections": sections}) + } + section, ok := top[m.Section] + if !ok { + return h.refuse(env.ID, protocol.ErrNotFound, map[string]any{"section": m.Section}) + } + body := map[string]any{"section": m.Section, "doc": section} + if !h.fits(protocol.MsgConfig, env.ID, body) { + return h.refuse(env.ID, ErrRangeTooLarge, map[string]any{"section": m.Section}) + } + return h.sendBulk(protocol.MsgConfig, env.ID, body) +} + +// Decide answers a pending request from the box's own page, where the owner +// signed in on the home network and the box is itself the authority, so no +// signature is asked. h is a handler made for the box page, with a LAN +// owner's caller. Approving runs the op on it, as a command under the +// owner's scopes, and returns the command's outcome. +func (h *Handler) Decide(ctx context.Context, reqID string, approve bool) (string, *CmdResult, error) { + if h.cfg.Access == nil { + return "", nil, protocol.Refuse(ErrUnavailable, map[string]any{"subsystem": "access"}) + } + now, synced := h.wall() + if approve && !synced { + // A request's life is wall time. Without a clock to trust, the box + // cannot tell one that has ended from one that has not. + return "", nil, protocol.Refuse(ErrUnavailable, map[string]any{"subsystem": "clock"}) + } + req, ok := h.cfg.Agents.pending(reqID, now, h.liveAgents()) + if !ok { + h.audit("", protocol.MsgAgentConfirm, reqID, protocol.ErrNotFound) + return "", nil, protocol.Refuse(protocol.ErrNotFound, map[string]any{"reqId": reqID}) + } + defer h.cfg.Agents.tell("", protocol.AgentDecided{}) + if !approve { + h.cfg.Agents.decide(reqID, protocol.RequestDeclined, now) + h.audit("", protocol.MsgAgentConfirm, reqID, protocol.RequestDeclined) + h.cfg.Agents.tell(req.Agent, protocol.AgentDecided{ReqID: reqID, State: protocol.RequestDeclined}) + return protocol.RequestDeclined, nil, nil + } + if !h.stillGranted(req) { + h.cfg.Agents.decide(reqID, protocol.RequestExpired, now) + return "", nil, protocol.Refuse(protocol.ErrNotFound, map[string]any{"reqId": reqID}) + } + if !h.cfg.Agents.decide(reqID, protocol.RequestApproved, now) { + return "", nil, protocol.Refuse(protocol.ErrNotFound, map[string]any{"reqId": reqID}) + } + h.audit("", protocol.MsgAgentConfirm, reqID, protocol.RequestApproved) + if err := h.runRequest(ctx, req); err != nil { + return protocol.RequestApproved, nil, err + } + var res *CmdResult + if rec, ok := h.cfg.Commands.lookup(h.cmdRoot(), "req:"+reqID); ok { + res = rec.result + } + return protocol.RequestApproved, res, nil +} + +// Requests lists every request the box holds, for the box page. +func (h *Handler) Requests() []protocol.Request { + now, _ := h.wall() + return h.cfg.Agents.list("", now, h.liveAgents()) +} + +// sendDecided tells this agent's session how one of its requests ended. +func (h *Handler) sendDecided(d protocol.AgentDecided) { + if d.Result != nil { + // The owner's outcome, retold to the agent: names become agent ids. + res := h.forAgentResult(*d.Result) + d.Result = &res + } + if err := h.sendBulk(protocol.MsgAgentDecided, nil, d); err != nil { + h.log.Debug("could not tell an agent how its request ended", "err", err) + } +} + +var ( + reAuditWord = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:-]{0,47}$`) + reAuditText = regexp.MustCompile(`^[A-Za-z0-9_.:@ -]{0,96}$`) +) + +// auditWord keeps a message type, op or code the sender chose only when it +// is short and plain; anything else is "other". The log is the owner's, not +// a place an agent can write into. +func auditWord(s string) string { + if reAuditWord.MatchString(s) { + return s + } + return "other" +} + +// auditText is auditWord for a target or a staff id: a little longer, and +// cut rather than replaced. +func auditText(s string) string { + if reAuditText.MatchString(s) { + return s + } + return "[omitted]" +} diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go new file mode 100644 index 00000000..87ae2141 --- /dev/null +++ b/go/internal/appproto/agents_test.go @@ -0,0 +1,999 @@ +package appproto + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appenroll/approvaltest" + "github.com/srcfl/ftw/go/internal/redact" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// The agent tests run against a real enrolment, so a signature, an expiry and +// a revoke are the box's own answers and not a fake's. + +type testAccess struct { + id *appenroll.Identity + wall func() (int64, bool) + lp *fakeLoadpoints + ended []string + notified []string + revoked []string +} + +func (a *testAccess) AddAgent(dev string, m protocol.AgentAdd) (protocol.AgentRow, error) { + return a.id.AddAgent(appenroll.Approver{DeviceID: dev}, m) +} +func (a *testAccess) WidenAgent(dev string, m protocol.AgentWiden) (protocol.AgentRow, error) { + return a.id.WidenAgent(appenroll.Approver{DeviceID: dev}, m) +} +func (a *testAccess) RevokeAgent(id string) error { + a.revoked = append(a.revoked, id) + _, err := a.id.Revoke(id, appenroll.OverASession) + if err == nil { + a.EndHolds(id) + } + return err +} +func (a *testAccess) Agents() []protocol.AgentRow { return a.id.Agents() } +func (a *testAccess) ApprovalKeys() []protocol.ApprovalKeyInfo { return a.id.ApprovalKeys() } +func (a *testAccess) CheckConfirm(d string, m protocol.AgentConfirm, req protocol.Request) error { + return a.id.CheckConfirm(d, m, req) +} + +// EndHolds releases the agent's hold, as the box's wiring does. +func (a *testAccess) EndHolds(agentID string) { + a.ended = append(a.ended, agentID) + if a.lp != nil && a.lp.holdSet && a.lp.hold.SetBy == apiauth.KindAgent+":"+agentID { + a.lp.holdSet = false + } +} +func (a *testAccess) SetApprovalKey(d string, m protocol.ApprovalKeySet) (int64, error) { + return a.id.SetApprovalKey(d, m) +} +func (a *testAccess) Wall() (int64, bool) { return a.wall() } +func (a *testAccess) Notify(kind string) { a.notified = append(a.notified, kind) } + +// enrolGrants reads one row the way appuplink's liveGrant does. +type enrolGrants struct { + id *appenroll.Identity + dev string +} + +func (g enrolGrants) Grant() (Enrolment, bool) { + grant, ok := g.id.GrantFor(g.dev) + if !ok { + return Enrolment{}, false + } + e := Enrolment{Role: grant.Role, Epoch: grant.Epoch, ParentRole: grant.ParentRole} + if grant.Agent != nil { + e.Agent = &AgentGrant{Scopes: grant.Agent.Scopes, Ops: grant.Agent.Ops, Caps: grant.Agent.Caps, + ExpiresAtMs: grant.Agent.ExpiresAtMs, Principal: grant.Agent.Principal, + Expired: grant.Agent.Expired, NoClock: grant.Agent.NoClock} + } + return e, true +} + +type memAudit struct { + mu sync.Mutex + rows []protocol.AuditRow +} + +func (a *memAudit) Append(r protocol.AuditRow) { + a.mu.Lock() + defer a.mu.Unlock() + r.Seq = uint64(len(a.rows) + 1) + a.rows = append(a.rows, r) +} + +func (a *memAudit) Page(before uint64, n int) ([]protocol.AuditRow, bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + var out []protocol.AuditRow + for i := len(a.rows) - 1; i >= 0 && len(out) < n; i-- { + if before == 0 || a.rows[i].Seq < before { + out = append(out, a.rows[i]) + } + } + return out, false, nil +} + +func (a *memAudit) find(what string) []protocol.AuditRow { + a.mu.Lock() + defer a.mu.Unlock() + var out []protocol.AuditRow + for _, r := range a.rows { + if r.What == what { + out = append(out, r) + } + } + return out +} + +type agentRig struct { + t *testing.T + id *appenroll.Identity + now time.Time + synced bool + access *testAccess + agents *AgentState + audit *memAudit + box *fakeBox + clock *fakeClock + lp *fakeLoadpoints + owner appenroll.Grant + signer *approvaltest.Signer + logs []redact.Line +} + +func (r *agentRig) ms() int64 { return r.now.UnixMilli() } + +func (r *agentRig) signedFor() protocol.SignedFor { + return protocol.SignedFor{BoxKey: r.id.StaticKey().Public(), DeviceID: r.owner.DeviceID} +} + +func (r *agentRig) Recent(n int) []redact.Line { return r.logs } + +func newAgentRig(t *testing.T) *agentRig { + t.Helper() + id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + r := &agentRig{t: t, id: id, now: time.UnixMilli(1_792_000_000_000), synced: true, + agents: NewAgentState(), audit: &memAudit{}} + id.SetClock(func() (time.Time, bool) { return r.now, r.synced }) + r.access = &testAccess{id: id, wall: func() (int64, bool) { return r.ms(), r.synced }} + + code, _, err := id.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + phone, _ := appwire.GenerateKeyPair() + if r.owner, err = id.Authorise(phone.Public(), code); err != nil { + t.Fatal(err) + } + r.signer = approvaltest.New() + if _, err := id.SetApprovalKey(r.owner.DeviceID, r.signer.KeySet(r.signedFor(), r.ms())); err != nil { + t.Fatal(err) + } + + _, r.box, _, r.clock = newRig(t) + r.lp = &fakeLoadpoints{ids: map[string]bool{"lp1": true}, plugged: true} + r.access.lp = r.lp + return r +} + +var testAgentKey = []byte("agent key") + +// lp1 is the charger's id as an agent reads it. +func lp1() string { return cloudwire.LoadpointID(testAgentKey, "lp1") } + +type testViews struct{} + +func (testViews) Live(key []byte) cloudwire.Live { + return cloudwire.Live{Fields: map[string]int64{"2": 1200}, + Sources: map[string]cloudwire.Source{cloudwire.SourceID(key, "meter.p1"): {Kind: "meter"}}} +} + +func (testViews) Loadpoints(key []byte) (cloudwire.LP, bool) { + return cloudwire.LP{Loadpoints: []cloudwire.Loadpoint{{ID: cloudwire.LoadpointID(key, "lp1")}}}, true +} + +// handler builds a session for one enrolled row, as the uplink would. +func (r *agentRig) handler(kind, dev, role string) (*Handler, *recorder) { + r.t.Helper() + rec := &recorder{} + grants := enrolGrants{id: r.id, dev: dev} + g, _ := grants.Grant() + caller := apiauth.Caller{Subject: kind + ":" + dev, Kind: kind, Role: role, Scopes: ScopesForEnrolment(g)} + h, err := New(Config{ + Clock: r.clock, Site: r.box, Info: r.box, Modes: r.box, Plans: r.box, + Codec: testCodec{}, Sender: rec, Caller: caller, Grants: grants, Commands: NewCmdLog(), + Loadpoints: r.lp, Access: r.access, Agents: r.agents, Audit: r.audit, + Logs: r, Redactor: func() *redact.Redactor { return redact.New(testAgentKey, []string{"Easee carport"}) }, + Views: testViews{}, AgentKey: testAgentKey, + SrcGrid: "meter.p1", SrcPV: "inverter.sungrow", SrcBattery: "battery.sungrow", + NewLeaseID: func() string { return "lease-test" }, + }) + if err != nil { + r.t.Fatalf("New: %v", err) + } + return h, rec +} + +func (r *agentRig) ownerSession() (*Handler, *recorder) { + return r.handler(apiauth.KindApp, r.owner.DeviceID, apiauth.RoleOwner) +} + +// grant adds an agent row with scopes and ops, signed by the owner phone. +func (r *agentRig) grant(role string, scopes, ops []string, caps protocol.Caps, ttl time.Duration, p protocol.Principal) (protocol.AgentRow, []byte) { + r.t.Helper() + key, _ := appwire.GenerateKeyPair() + m := protocol.AgentAdd{Key: key.Public(), Role: role, Scopes: scopes, Ops: ops, Caps: caps, + ExpiresAtMs: r.ms() + ttl.Milliseconds(), Principal: p, AtMs: r.ms()} + row, err := r.id.AddAgent(appenroll.Approver{DeviceID: r.owner.DeviceID}, r.signer.Add(r.signedFor(), m)) + if err != nil { + r.t.Fatalf("AddAgent: %v", err) + } + return row, key.Public() +} + +func (r *agentRig) controlAgent() protocol.AgentRow { + row, _ := r.grant(apiauth.RoleAgent, + []string{protocol.ScopeDispatchHold, protocol.ScopeGoalsRequest, protocol.ScopeLiveRead, protocol.ScopePlanRead}, + []string{OpLoadpointHold, OpSetMode}, + protocol.Caps{HoldS: 900, MaxW: 7400, PerMin: 6}, 24*time.Hour, protocol.Principal{Client: "Claude"}) + return row +} + +func (r *agentRig) agentSession(row protocol.AgentRow) (*Handler, *recorder) { + return r.handler(apiauth.KindAgent, row.ID, row.Role) +} + +func cmdFor(id, op string, args map[string]any, rev uint64) Cmd { + return Cmd{CmdID: id, Op: op, Args: args, NotValidAfterMs: 1 << 40, Expect: Expect{Rev: rev}} +} + +func errCode(t *testing.T, rec *recorder) string { + t.Helper() + return body[ErrorBody](t, rec.last(t, MsgError)).Code +} + +func resultCode(t *testing.T, rec *recorder) string { + t.Helper() + res := body[CmdResult](t, rec.last(t, MsgCmdResult)) + if res.Error == nil { + return res.State + } + return res.Error.Code +} + +func TestAnAgentReadsWhatItsScopesOpenAndNothingElse(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeLiveRead}, nil, protocol.Caps{}, time.Hour, protocol.Principal{Client: "ChatGPT"}) + h, rec := r.agentSession(row) + + deliver(t, h, MsgPlanGet, ptrU32(1), struct{}{}) + if got := errCode(t, rec); got != ErrScopeDenied { + t.Errorf("plan without plan.read: %s", got) + } + deliver(t, h, MsgAPIReq, ptrU32(2), APIReq{Method: APIGet, Path: "/api/status"}) + if got := errCode(t, rec); got != ErrScopeDenied { + t.Errorf("the passthrough: %s", got) + } + deliver(t, h, protocol.MsgAgentAdd, ptrU32(3), protocol.AgentAdd{}) + if got := errCode(t, rec); got != ErrScopeDenied { + t.Errorf("an agent adding an agent: %s", got) + } + deliver(t, h, MsgSub, ptrU32(4), Sub{Bucket: 512, Hz: 1}) + if got := errCode(t, rec); got != ErrScopeDenied { + t.Errorf("sub, whose source table names devices: %s", got) + } + rec.reset() + deliver(t, h, protocol.MsgLiveGet, ptrU32(5), struct{}{}) + deliver(t, h, protocol.MsgLiveGet, ptrU32(6), struct{}{}) + live := body[cloudwire.Live](t, rec.last(t, protocol.MsgLive)) + if _, ok := live.Sources[cloudwire.SourceID(testAgentKey, "meter.p1")]; !ok || live.Fields["2"] != 1200 { + t.Errorf("live %+v", live) + } + // Reads fold into one audit row per minute, with a count. + r.agents.FlushAudit(r.audit, r.clock.now.Add(time.Minute).UnixMilli()) + rows := r.audit.find(protocol.MsgLiveGet) + if len(rows) != 1 || rows[0].Count != 2 || rows[0].Principal != "ChatGPT" { + t.Errorf("audit %+v", rows) + } +} + +func TestARevokeFailsTheNextCall(t *testing.T) { + r := newAgentRig(t) + row := r.controlAgent() + h, rec := r.agentSession(row) + deliver(t, h, MsgPlanGet, ptrU32(1), struct{}{}) + if rec.has(MsgError) { + t.Fatalf("before the revoke: %s", rec.types()) + } + + owner, orec := r.ownerSession() + deliver(t, owner, protocol.MsgAgentRevoke, ptrU32(9), protocol.AgentRevoke{ID: row.ID}) + if orec.has(MsgError) { + t.Fatalf("revoke: %s", orec.types()) + } + + rec.reset() + deliver(t, h, MsgPlanGet, ptrU32(2), struct{}{}) + if got := errCode(t, rec); got != ErrGrantRevoked { + t.Errorf("after the revoke: %s", got) + } + if body[SessionTerminate](t, rec.last(t, MsgSessionTerminate)).Reason != TerminateRevoked { + t.Error("the session did not end") + } + rec.reset() + deliver(t, h, MsgCmd, nil, cmdFor("c1", OpLoadpointHold, map[string]any{"id": lp1(), "power_w": 3000, "hold_s": 600}, 7)) + if got := resultCode(t, rec); got != ErrGrantRevoked || r.lp.holdCalls != 0 { + t.Errorf("a command after the revoke: %s, holds %d", got, r.lp.holdCalls) + } +} + +// FTW Cloud may still list the agent or have its session open; the box asks +// its own clock on every call. +func TestAnExpiredAgentFailsAtTheBoxEvenWhenTheCloudAdmitsIt(t *testing.T) { + r := newAgentRig(t) + row := r.controlAgent() + h, rec := r.agentSession(row) + + r.now = r.now.Add(25 * time.Hour) + deliver(t, h, MsgPlanGet, ptrU32(1), struct{}{}) + if got := errCode(t, rec); got != protocol.ErrGrantExpired { + t.Errorf("a read after expiry: %s", got) + } + if body[SessionTerminate](t, rec.last(t, MsgSessionTerminate)).Reason != protocol.TerminateExpired { + t.Error("the session did not end as expired") + } + rec.reset() + deliver(t, h, MsgCmd, nil, cmdFor("c1", OpLoadpointHold, map[string]any{"id": lp1(), "power_w": 3000, "hold_s": 600}, 7)) + if got := resultCode(t, rec); got != protocol.ErrGrantExpired || r.lp.holdCalls != 0 { + t.Errorf("a command after expiry: %s, holds %d", got, r.lp.holdCalls) + } + + // An open stream ends on the next tick, without waiting for a call. + r.now = r.now.Add(-25 * time.Hour) + h2, _ := r.agentSession(row) + r.now = r.now.Add(25 * time.Hour) + if err := h2.Tick(); !errors.Is(err, errAgentEnded) { + t.Errorf("tick after expiry: %v", err) + } + + // No clock: nothing, and nothing ends. + r.now = r.now.Add(-25 * time.Hour) + r.synced = false + h3, rec3 := r.agentSession(row) + deliver(t, h3, MsgPlanGet, ptrU32(1), struct{}{}) + if e := body[ErrorBody](t, rec3.last(t, MsgError)); e.Code != ErrUnavailable || e.Args["subsystem"] != "clock" { + t.Errorf("without a clock: %+v", e) + } +} + +func TestAnOpOutsideTheAllowlistIsRefused(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, + []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead}, + []string{OpLoadpointHold}, protocol.Caps{HoldS: 900, MaxW: 7400}, time.Hour, protocol.Principal{}) + h, rec := r.agentSession(row) + + deliver(t, h, MsgCmd, nil, cmdFor("c1", OpLoadpointBoost, map[string]any{"id": lp1(), "duration_s": 60}, 7)) + if got := resultCode(t, rec); got != protocol.ErrOpNotGranted { + t.Errorf("boost, not in the grant: %s", got) + } + deliver(t, h, MsgCmd, nil, cmdFor("c2", OpSetMode, map[string]any{"mode": "idle"}, 7)) + if got := resultCode(t, rec); got != protocol.ErrNeedsOwner { + t.Errorf("a durable change as a command: %s", got) + } + if r.box.setModeCalls != 0 { + t.Error("the mode changed") + } + // Refusals fold by agent, message and code into a row a minute. + r.agents.FlushAudit(r.audit, r.clock.now.Add(time.Minute).UnixMilli()) + rows := r.audit.find(MsgCmd) + codes := map[string]uint64{} + for _, row := range rows { + codes[row.Result] += row.Count + } + if len(rows) != 2 || codes[protocol.ErrOpNotGranted] != 1 || codes[protocol.ErrNeedsOwner] != 1 { + t.Errorf("audit %+v", rows) + } +} + +// An agent cannot flood the owner's log: refusals fold, and what it names +// in a message type or an op is cut to a plain word. +func TestAnAgentCannotWriteIntoTheAuditLog(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeLiveRead}, nil, protocol.Caps{}, time.Hour, protocol.Principal{}) + h, _ := r.agentSession(row) + junk := strings.Repeat("