diff --git a/.changeset/agents-contract.md b/.changeset/agents-contract.md new file mode 100644 index 00000000..d660f69d --- /dev/null +++ b/.changeset/agents-contract.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The registry names agents: an assistant, Sourceful support for one ticket and the release check, with their roles, scopes, the scope that opens each command to an agent, their refusals and two push kinds. `docs/agents.md` specifies how the box will enforce them; this release only names them, and no pairing code or role change can hand an agent's role to a phone. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8473df44..4c7f3b9f 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -60,3 +60,29 @@ events: # To every owner, when FTW Cloud is turned off at either door. title: FTW Cloud is off body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy." + - kind: agent.added + # To every owner, when an owner gives an assistant, Sourceful support or + # the release check access to the box. Access from outside the house is + # news every owner should hear, as with cloud.granted. + title: New access to your box + body: "You gave an assistant or Sourceful access. See who has access in the app." + - kind: agent.request + # To every owner, when an agent asks for a durable change. It waits for + # one of them to confirm it, and runs out after twelve hours. + title: Waiting for your answer + body: "An assistant asks to change a setting. Open FTW to confirm or decline." + - kind: agent.expiring + # To every owner, a day before an agent's grant runs out, so access that + # should go on is renewed and access that should end can be left to end. + title: Access ends tomorrow + body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on." + - kind: approval.key.set + # To every owner, when a phone registers or replaces the key that + # approves access. A new key is a new way to let others in. + title: A phone can now approve access + body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app." + - kind: owner.added + # To every owner, when a phone pairs as an owner. An owner can do + # anything, including give others access. + title: A new owner + body: "A phone was paired as an owner of your box. If that was not you, remove it in the app." diff --git a/contract/registry.yaml b/contract/registry.yaml index 63b4de55..dfa8a852 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -111,9 +111,16 @@ capabilities: # watch codes. Absent means the phone offers no Add watch. The wire is # docs/watch-exchange.md in srcfl/ftw-webapp. - cmd.exchange + # The box takes agents: assistants, Sourceful support and the release + # check, each an owner's grant with scopes, ops, caps and an expiry, and it + # answers the agent.* messages and approval.key.set (docs/agents.md in + # srcfl/ftw). Absent means the app offers no Access list. + - access.agents # --------------------------------------------------------------------------- -# Scopes. One object axis, two verb axes: .. +# Scopes. One object axis and a verb: .. +# read and write are a phone's. run changes nothing, hold acts for a capped +# time and ends unless renewed, and request waits for an owner to confirm. # Roles are a UI projection over these, never a replacement for them. # --------------------------------------------------------------------------- scopes: @@ -127,6 +134,13 @@ scopes: - { name: ftw.mode.write, desc: Change site operating mode } - { name: ftw.members.read, desc: See who has access } - { name: ftw.members.write, desc: Invite and revoke } + - { name: ftw.logs.read, desc: Log excerpts the box redacts } + - { name: ftw.config.read, desc: Configuration the box redacts } + - { name: ftw.diag.run, desc: Checks that change nothing } + - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } + - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } + # Reserved: no op asks for an update yet, and no role but owner carries it. + - { name: ftw.update.request, desc: Ask an owner to install a version } roles: owner: @@ -142,6 +156,23 @@ roles: watch: label: Watch scopes: [ftw.live.read, ftw.plan.read, ftw.events.read, ftw.dispatch.write] + # Agents: grants an owner gives to something other than a paired device. + # A role here is the most a grant of that kind may carry. Each grant names + # a subset, and each check keeps only what the approving owner holds at + # that moment, as for a watch. An agent reaches the box only through FTW + # Cloud's tunnel. See docs/agents.md in srcfl/ftw. + # + # agent is a personal assistant. support is Sourceful for one ticket. release + # is the release check, which reads a health summary and nothing else. + agent: + label: Assistant + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + support: + label: Sourceful support + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + release: + label: Release check + scopes: [ftw.health.read] # --------------------------------------------------------------------------- # FTW Cloud. What an owner can let the box share with FTW Cloud, and why. @@ -170,15 +201,23 @@ cloud_purposes: [serve, stats] # does not implement is answered with E_UNKNOWN_OP like any other stranger — # battery.hold is the app's vocabulary today and no shipping box acts on it # yet. +# +# agent is the scope that opens the op to an agent, and its verb says how. +# ftw.dispatch.hold runs now as a cmd, within the grant's caps, and must end +# by itself. ftw.goals.request never runs as a cmd: it waits as an +# agent.request until an owner confirms it. An op without agent is never +# open to an agent. battery.hold and loadpoint.boost have none until the box +# can bound them: battery.hold has no handler yet, and a boost has no power +# of its own to hold to maxW. agent_args below says what an agent may send. # --------------------------------------------------------------------------- ops: - - { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode } - - { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences } + - { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode } + - { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences } - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - - { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current } + - { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current } - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } - - { name: loadpoint.soc.set, scope: ftw.dispatch.write, desc: Correct the car's current charge level } - - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, desc: Charge the car from surplus PV only } + - { name: loadpoint.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Correct the car's current charge level } + - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Charge the car from surplus PV only } # --------------------------------------------------------------------------- # Dispatch modes. @@ -240,6 +279,25 @@ errors: # or doing it needs somebody standing at the box. Not a permission the owner # is missing, so neither a role nor a ceremony changes the answer. - { code: E_LOCAL_ONLY, retryable: false, desc: Route is served only on the box's own page } + # Agents' refusals (docs/agents.md in srcfl/ftw). Each is a different + # sentence to an owner reading the audit log or an assistant reading its + # answer, so none reuses a code above. + # + # E_RATE_LIMITED is retryable because the same command goes through once + # the grant's minute has room. E_CAP_EXCEEDED is not: the box refuses an + # argument past a cap rather than clipping it, and the same argument gets + # the same answer. + - { code: E_GRANT_EXPIRED, retryable: false, desc: Access ran out at its expiry } + - { code: E_OP_NOT_GRANTED, retryable: false, desc: The grant does not list this operation } + - { code: E_CAP_EXCEEDED, retryable: false, desc: An argument or a count is past its cap } + - { code: E_RATE_LIMITED, retryable: true, desc: Too many commands for the grant's rate } + - { code: E_NEEDS_OWNER, retryable: false, desc: A durable change waits for an owner as agent.request } + - { code: E_BAD_SIGNATURE, retryable: false, desc: The approval signature is missing or does not verify } + - { code: E_NO_APPROVAL_KEY, retryable: false, desc: This phone has no approval key on the box } + - { code: E_NOT_FOUND, retryable: false, desc: Nothing by that id or name } + # An approval whose atMs is too far from the box's wall clock. args.boxMs + # is the box's clock, so the phone can sign again with the right time. + - { code: E_CLOCK_SKEW, retryable: true, desc: The approval's time is too far from the box's } # --------------------------------------------------------------------------- # Error codes the client raises for itself. @@ -288,3 +346,56 @@ carrier_states: [webrtc, relay, tunnel, cloud, cache, none] resolutions: '5m': { tile_span_ms: 43200000, retention_days: 30, source: energy_ledger } '1h': { tile_span_ms: 604800000, retention_days: 730, source: hourly_rollup } + +# --------------------------------------------------------------------------- +# What an agent may put in each op it can name (docs/agents.md in srcfl/ftw, +# Operations). The box refuses any other key, a value of another kind and one +# outside its range, in a cmd and in an agent.request, as it arrives. label is +# the words the app shows an owner for the value. cap names the grant's cap +# that bounds it as well. kind says how to read the value: +# loadpoint the opaque charger id the agent read from loadpoints.get +# mode a key in modes +# enum one of values +# number, power, duration a number in unit +# ratio a fraction from 0 to 1 +# bool true or false +# --------------------------------------------------------------------------- +agent_args: + site.mode.set: + - { name: mode, kind: mode, required: true, label: Operating mode } + planner.prefs.set: + - { name: safety_k, kind: number, min: 0, max: 2, label: How cautious the plan is } + - { name: battery_export, kind: enum, values: [unknown, not_allowed, allowed], label: Whether the battery may sell to the grid } + loadpoint.hold: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: power_w, kind: power, unit: W, min: 0, cap: maxW, label: Charging power } + - { name: hold_s, kind: duration, unit: s, min: 1, cap: holdS, label: For how long } + - { name: phase_mode, kind: enum, values: [auto, 1p, 3p], label: Phases } + - { name: clear, kind: bool, label: Stop the agent's own hold } + loadpoint.soc.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: soc, kind: ratio, required: true, min: 0, max: 1, label: The car's charge level } + loadpoint.surplus_only.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: surplus_only, kind: bool, required: true, label: Charge from solar only } + +# --------------------------------------------------------------------------- +# Reads an agent may send, and the scope each needs (docs/agents.md in +# srcfl/ftw, Reads). Their answers name nothing: sources, chargers and cars +# go by opaque ids the box cuts with a key of its own. An agent has no sub; +# live.get is its view of now. A phone may send these too, under the same +# scope. +# --------------------------------------------------------------------------- +reads: + - { msg: live.get, scope: ftw.live.read, desc: Readings and freshness now } + - { msg: loadpoints.get, scope: ftw.live.read, desc: Each charger and the car on it } + - { msg: cars.get, scope: ftw.live.read, desc: Each car's level and goal } + - { msg: plan.get, scope: ftw.plan.read, desc: The plan and its reasons } + - { msg: price.get, scope: ftw.plan.read, desc: Electricity prices } + - { msg: hist.query, scope: ftw.history.read, desc: Stored history } + - { msg: health.get, scope: ftw.health.read, desc: The health summary } + - { msg: drivers.get, scope: ftw.health.read, desc: Each driver's state } + - { msg: events.get, scope: ftw.events.read, desc: Recent events } + - { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts } + - { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts } + - { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing } diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..5b774cc9 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,669 @@ +# Agents and support access + +Proposed. Nothing here has shipped. This document is normative: "must" is a +requirement on the box, FTW Cloud or the app. It extends the app protocol +([srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md)) +and the [FTW Cloud protocol](protocol-cloud.md). Names in `code` that are +scopes, roles, operations, error codes, capabilities, push kinds, agent +arguments or reads live in `contract/registry.yaml` and +`contract/push-catalogue.yaml`. The message shapes, the approval signature +and the signed offer live in `go/protocol/agents.go`, with golden vectors in +`go/protocol/testdata/approval-vectors.json`. + +## What it is for + +An owner can let something other than a paired phone reach the box: + +- a personal assistant (ChatGPT, Claude, Grok) through MCP at + `https://cloud.ftw.energy/mcp`; +- Sourceful support, for one ticket; +- the release check, which reads a health summary while the box runs a beta. + +FTW Cloud's `keys` part holds the agent's private key and alone opens its +session, for a call through MCP that the agent's OAuth token allows. The box +decides everything: the role, the scopes, the operations, the arguments, the +caps and the expiry. FTW Cloud can admit an agent; only the box can let it +act. Each check runs again on every call, so a revoke or an expiry bites at +the next call whatever FTW Cloud does. + +**What FTW Cloud sees.** It holds the agent's key, so it can read every +answer an agent gets. Answers to agents therefore name nothing: sources, +chargers and cars go by opaque ids the box cuts with a key of its own, logs +and configuration are redacted on the box, and an agent has no `sub` stream, +whose source table carries names. + +## Principals + +| Role | Who | Granted by | Longest grant | Ends | +|---|---|---|---|---| +| `agent` | A personal assistant | An owner, in the app or on the box page | 30 days, renewable | Expiry, revoke, or removal of the approving phone | +| `support` | Sourceful, for one ticket | An owner, from "Get help" | 14 days | Expiry, revoke, the ticket closing, or removal of the approving phone | +| `release` | The release check | An owner, on joining the beta | 90 days, renewable | Expiry, revoke, or removal of the approving phone | + +- Every agent row has a wall-clock expiry. There is no grant without one. +- **Scopes come from the approver at call time.** An agent holds the scopes + its row lists that its approver holds now: the approving phone's role + today, or every scope for a grant made on the box page. A phone demoted to + viewer narrows its agents at the next call. Removing the phone removes its + agents, as it removes its watches. When another owner widens a grant, that + owner becomes its approver. +- **A support call names who made it.** Each envelope a `support` session + sends carries `by`: `staff:` for a person at Sourceful or + `bot:` for Sourceful's own tooling, at most 64 bytes of letters, + digits and `._@-` after the colon. The box refuses a support call without + it, refuses `by` from every other session, and writes it to the audit log. + The box cannot check it; FTW Cloud signs the staff member in and asserts + it, and the app labels it "named by FTW Cloud". +- **The ticket closing ends the grant.** When a ticket closes, Sourceful's + tooling sends `agent.revoke` for its own row. A box offline at that moment + is revoked again when it next connects; the tooling keeps closed tickets + until their rows are gone. An agent may always revoke itself. The expiry + is the backstop. +- **The release check reads one thing.** Its role carries `ftw.health.read` + and nothing else, so it may send `hello`, `health.get`, `drivers.get`, and + `agent.list` and `agent.revoke` for its own row. +- **One session per row.** The box holds one session per agent row, as per + device. The `keys` part carries every MCP call for a row on that one + session; `by` names who made each support call. + +## Scopes + +Agent scopes reuse the phone scopes where they mean the same thing and add +these: + +| Scope | Lets an agent | +|---|---| +| `ftw.logs.read` | Read a log excerpt that the box redacts before it leaves | +| `ftw.config.read` | Read the configuration, redacted on the box | +| `ftw.diag.run` | Run checks that change nothing | +| `ftw.dispatch.hold` | Hold a charger for a capped time; the hold ends unless renewed | +| `ftw.goals.request` | Ask for a durable change, which waits for an owner | +| `ftw.update.request` | Reserved: no op uses it yet | + +The roles `agent` and `support` carry at most `ftw.live.read`, +`ftw.history.read`, `ftw.plan.read`, `ftw.health.read`, `ftw.events.read`, +`ftw.logs.read`, `ftw.config.read`, `ftw.diag.run`, `ftw.dispatch.hold` and +`ftw.goals.request`. A grant names a subset. Agents read by default; an owner +ticks control to add `ftw.dispatch.hold` or `ftw.goals.request`. + +## What an agent may send + +An agent session is the app session, `ftw.session.v1`, between the agent's +key and the box. It uses the messages below and no others. The box answers +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` | +| 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` | +| `agent.list` | — | `agent.ok` with the agent's own row and requests | +| `agent.revoke` | — | `agent.ok`; only for the agent's own id | + +`sub` and `api.req` are refused to every agent with `E_SCOPE_DENIED`: a +`sub` stream's source table names the devices, and the passthrough's routes +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. + +## Operations + +Each op in the registry may name an `agent` scope. An op without one is never +open to an agent. + +| Op | Agent scope | For an agent | +|---|---|---| +| `loadpoint.hold` | `ftw.dispatch.hold` | `cmd`, under the caps and the hold rules below | +| `site.mode.set` | `ftw.goals.request` | `agent.request` only | +| `planner.prefs.set` | `ftw.goals.request` | `agent.request` only | +| `loadpoint.soc.set` | `ftw.goals.request` | `agent.request` only | +| `loadpoint.surplus_only.set` | `ftw.goals.request` | `agent.request` only | +| `battery.hold`, `loadpoint.boost` | none | Never. They return when the box can bound them: `battery.hold` has no handler, and a boost has no power of its own to hold to `maxW` | + +- **A per-grant allowlist.** A grant lists `ops`, a subset of the ops whose + agent scope the grant holds. The box refuses an op the grant does not list + with `E_OP_NOT_GRANTED`, even when the agent holds its scope. +- **An allowlist of arguments.** The registry's `agent_args` says, per op, + which keys an agent may send, of which kind, in which range, which are + required, and which grant cap bounds them, with a label the app shows the + owner. The box refuses any other key, kind or value, in a `cmd` and in an + `agent.request`, as it arrives, with `E_UNKNOWN_OP {op, arg}`. For + `loadpoint.hold` that is `id`, `power_w`, `hold_s`, `phase_mode` and + `clear`; `voltage`, `max_amps_per_phase`, `site_phases`, `phase_split_w` + and `min_phase_hold_s` stay with owners. +- **Ids are the agent's.** A `loadpoint` argument is the opaque charger id + the agent read from `loadpoints.get`. The box resolves it with its own + agent key, so an id from another box, or from the readable copy, names + nothing (`E_UNKNOWN_OP {arg: "id"}`). +- **A durable change waits.** An agent that sends a `ftw.goals.request` op as + `cmd` gets `E_NEEDS_OWNER`, with `{use: "agent.request"}`. +- **The checks run in Core's order:** the command's expiry, a known op, the + live grant (revoke, expiry and clock), the op's agent scope, the + allowlist, the arguments, the rate, the caps and the hold rules, then + `expect.rev`, the dispatch gate and the guards. +- **Its own command log.** An agent's command ids live under its own root, + `agent:`, never an owner's. + +### Holds + +- **A hold ends unless renewed.** `hold_s` runs from 1 to `caps.holdS`. A + `loadpoint.hold` with `hold_s` 0 or none is the persistent operator hold, + and an agent may never send one. +- **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. +- **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 + in the car contract. + +## Caps + +| Cap | Meaning | Box limits | +|---|---|---| +| `holdS` | The longest hold, in seconds | 60 to 1,800. Required with `ftw.dispatch.hold` | +| `maxW` | The largest power a hold may set, in W, in either direction | 1 to 100,000. Required with `ftw.dispatch.hold` | +| `perMin` | `cmd` and `agent.request` per rolling minute | 1 to 60. 0 in a grant means the default, 6 | + +- A command past `holdS` or `maxW` gets `E_CAP_EXCEEDED`, with + `{cap, max}`. The box never clips an argument; it refuses, so the agent + knows what happened. +- A command past `perMin` gets `E_RATE_LIMITED`, with `retryAfterMs` in the + error body. The minute is the agent's across all its sessions. +- Reads are limited to 60 a minute per agent, by the box, whatever the caps + say. + +## Durable changes + +1. The agent sends `agent.request {op, args, why}`. `why` is at most 280 + bytes of the agent's own words, shown to the owner as the agent's, never + as the box's. +2. The box checks the op as for a `cmd` (known, granted, scope, rate) and + checks `args` against `agent_args` and the op's own validation, now, as it + arrives. It stores the request with a 128-bit `reqId` and answers + `agent.ok {reqId, expiresAtMs}`. +3. The box pushes `agent.request` to every owner through its event path, and + writes an audit row. +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. +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 + agent sent decides whether it may act; the owner's confirmation does. +6. When a request is decided or expires, the box sends the agent's session, + if one is open, `agent.decided {reqId, state, cmdId, result}`. A request + no owner answers within 12 hours expires. The box keeps a decided or + expired request for 24 hours, so the agent can read its state with + `agent.list`. + +Pending requests live in memory. A restart drops them, and the agent asks +again. + +## Messages + +All of these ride in the app session. Each request carries an envelope `id` +and gets `agent.ok` with that id, or `error` with that id and a code. The +box decodes a signed message strictly: a key it does not know, or a key +twice, is `E_UNKNOWN_OP {field}`. + +### `approval.key.set` — an owner phone registers its approval key + +| Field | Type | Meaning | +|---|---|---| +| `pub` | bstr, 65 | P-256 public key, SEC1 uncompressed | +| `atMs` | int | The phone's wall clock | +| `sig` | bstr, 64 | The new key's signature: proof the phone holds it | +| `prevSig` | bstr, 64, optional | The phone's current key's signature over the same bytes | + +Answer `{deviceId, setAtMs}`. Only an owner phone may send it, and only for +itself. + +- A phone with no key yet registers one with `sig` alone. +- Replacing a key needs `prevSig` from the key it replaces. A phone that lost + its key (a new Face ID enrolment voids it) replaces it on the box page + with the house password. +- The box pushes `approval.key.set` to every owner and writes an audit row. + +Errors: `E_SCOPE_DENIED` (not an owner), `E_BAD_SIGNATURE` (`sig` or a +missing or wrong `prevSig`), `E_CLOCK_SKEW`, `E_UNKNOWN_OP` with `{field}`. + +### `agent.add` — an owner grants access + +| Field | Type | Meaning | +|---|---|---| +| `key` | bstr, 32 | The agent's X25519 Noise static key, from the signed offer | +| `role` | text | `agent`, `support` or `release` | +| `scopes` | [text] | Sorted, no repeats, within the role | +| `ops` | [text] | Sorted, no repeats, each with an agent scope in `scopes` | +| `caps` | `{holdS, maxW, perMin}` | Uints; 0 where not used | +| `expiresAtMs` | int | Wall clock, within the role's longest grant | +| `principal` | `{client, clientId, ticket}` | Text, each at most 256 bytes, empty where not used. `ticket` is required for `support` | +| `atMs` | int | The phone's wall clock | +| `sig` | bstr, 64 | Approval signature | + +Answer `{agent}`, the new [agent row](#agent-row). The box signs a new +roster that lists it, pushes `agent.added` to every owner and writes an audit +row. Errors: `E_SCOPE_DENIED` (not an owner phone), `E_NO_APPROVAL_KEY`, +`E_BAD_SIGNATURE`, `E_CLOCK_SKEW`, `E_CAP_EXCEEDED` (`{cap, max}`, for a +cap, the expiry, or 16 agent rows on the box), `E_UNAVAILABLE` +(`{subsystem: "clock"}`), `E_UNKNOWN_OP` (`{field}`). A key whose row id is +already taken, by a phone or an agent, is `E_UNKNOWN_OP` with +`{field: "key"}`. + +### `agent.widen` — an owner changes or renews a grant + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | The agent row's id | +| `epoch` | uint | The row's epoch as the owner saw it | +| `scopes`, `ops`, `caps`, `expiresAtMs` | | The whole new grant, as in `agent.add` | +| `atMs`, `sig` | | As in `agent.add`; the signature covers the row's key too | + +Any change other than a revoke needs a signature, narrowing included; one +rule is easier to check than two. The epoch rises, and the widening owner +becomes the approver. Errors as `agent.add`, plus `E_NOT_FOUND`, and +`E_CONFLICT` (`{expected, actual}`) for a stale epoch, sent with +`retryable: false`: the owner reads the row again before signing again. + +### `agent.revoke` — remove a grant + +`{id}`. From an owner, any agent row; from an agent, its own. No signature. +The row goes at once, its sessions end with `session.terminate {reason: +"revoked"}`, its holds end, its pending requests are dropped, and the next +roster leaves it out. Answer `{id}`. Error: `E_NOT_FOUND`. + +### `agent.list` — who has access + +`{}`. To an owner: `{agents: [agent row], requests: [request], keys: +[{deviceId, setAtMs}]}`, every row on the box. To an agent: its own row and +its own requests. A viewer gets `E_SCOPE_DENIED`. + +### `agent.request` — an agent asks for a durable change + +`{op, args, why}`. `args` is the op's argument map, at most 1 KiB encoded, +within `agent_args`. Answer `{reqId, expiresAtMs}`. Errors: +`E_OP_NOT_GRANTED`, `E_SCOPE_DENIED`, `E_RATE_LIMITED`, `E_CAP_EXCEEDED` +(`{cap: "pending", max: 4}` per agent, 16 per box), `E_UNKNOWN_OP` +(`{op, arg}` for an argument). + +### `agent.confirm` — an owner answers a request + +| Field | Type | Meaning | +|---|---|---| +| `reqId` | text | The request | +| `approve` | bool | Run it, or decline it | +| `atMs`, `sig` | | Required to approve. Absent to decline | + +Answer `{reqId, state, cmdId}`: `state` is `approved` or `declined`, and +`cmdId` is the command the box now runs on this session. Errors: +`E_NOT_FOUND` (unknown, decided or expired), `E_NO_APPROVAL_KEY`, +`E_BAD_SIGNATURE`, `E_CLOCK_SKEW`, `E_SCOPE_DENIED`. + +### `agent.decided` — the box tells an agent how a request ended + +Box to agent, unsolicited, when its session is open: `{reqId, state, cmdId, +result}`. `result` is the command's `cmd.result` body when the request ran. + +### `audit.get` — what agents and owners did + +`{beforeSeq, n}`; `n` at most 200. To an owner phone: +`{rows: [audit row], more}`, newest first. Agents and viewers get +`E_SCOPE_DENIED`. + +### Agent row + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | First 8 characters of the key in base64url, like a phone's device id | +| `key` | bstr, 32 | X25519 Noise static key | +| `role` | text | `agent`, `support` or `release` | +| `scopes`, `ops` | [text] | As granted | +| `caps` | `{holdS, maxW, perMin}` | `perMin` as the box applies it | +| `expiresAtMs` | int | Wall clock | +| `principal` | `{client, clientId, ticket}` | | +| `approvedBy` | text | `app:` or `lan` | +| `addedAtMs`, `lastSeenMs` | int | Wall clock, 0 when unknown | +| `epoch` | uint | Rises with every change | + +### Request + +`{reqId, agent, op, args, why, atMs, expiresAtMs, state}`. `state` is +`pending`, `approved`, `declined` or `expired`. `reqId` is 16 random bytes in +base64url. + +### Audit row + +`{seq, atMs, actor, role, principal, by, what, target, result, count}`. +`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. + +### Reads + +The registry's `reads` table names each read and the scope it needs. + +| Message | Request | Answer | +|---|---|---| +| `live.get` | `{}` | `live`: FTW Cloud's `live` body, with agent ids | +| `loadpoints.get` | `{}` | `loadpoints`: FTW Cloud's `lp` body, each charger and its car by agent id | +| `cars.get` | `{}` | `cars`: FTW Cloud's car class body, each car's level, source, age and goal by agent id | +| `plan.get`, `price.get` | As for a phone | `plan`, `price` | +| `hist.query` | As for a phone | `hist.chunk`, `hist.end` | +| `health.get` | `{}` | `health {version, uptimeS, clock, drivers: {ok, degraded, faulted, offline}, meter: {srcState, ageMs}, dispatchBlockedBy}` | +| `drivers.get` | `{}` | `drivers {drivers: [{src, kind, state, ageMs}]}` | +| `events.get` | `{n, beforeMs, afterMs}`, `n` at most 50 | `events {events, more}`, each FTW Cloud's `event` body with agent ids | +| `logs.get` | `{n, level, beforeMs, afterMs}`; `n` at most 200, `level` `WARN` by default | `logs {lines: [{ts, level, src, msg}], dropped, more}` | +| `config.get` | `{section}` | `config {sections}` without a section; `config {section, doc}` with one | +| `diag.run` | `{check, src}`; `check` is `clock`, `link`, `meter`, `planner` or `driver` | `diag {check, ok, facts}` | + +- **Agent ids.** Every id in an answer to an agent, for a source, a charger + or a car, is cut the way the readable copy cuts its ids + (`cloudwire.SourceID`, `cloudwire.LoadpointID`, and the car contract's car + id), under the box's agent key instead of `grantKey`: HMAC-SHA-256 under a + key the box derives from its own static secret. It is stable on one box, + says nothing on another, and does not depend on a readable copy that may + be off. A log line's `src` is the same id. The box resolves an agent id in + a `cmd` or an `agent.request` with the same key. +- **Cursors.** `beforeMs` asks for lines or events older than that wall time, + `afterMs` for newer ones; the answer is oldest first, and `more` says more + remain on the asked side. +- Each answer fits one bulk frame (16 KiB). `logs.get` drops its oldest + lines to fit and counts them in `dropped`. A config section that does not + fit gets `E_RANGE_TOO_LARGE`; an unknown one `E_NOT_FOUND`. +- `health` carries no readings and no names: drivers are counted, and a + source that blocks dispatch goes by its agent id. Planner solve times, + 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. + +## Redaction on the box + +`logs.get` and `config.get` build their answer on the box. Nothing leaves it +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. +- **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 + allowlist marks numeric. So coordinates, phone numbers and energy totals + under any other key do not leave. +- **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. +- **Vectors.** `go/protocol/testdata/redaction-vectors.json` holds lines in + and out, so a change to the rules shows. + +Redaction is a list of patterns, so text in a shape none of them matches can +pass. That is why driver free text stays home. + +## The agent's roster entry + +The roster the box signs for FTW Cloud lists each agent row as a `Device` +with one extra map, `agent`, and carries the box's Noise key in `noise`: + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | The row's id | +| `key` | bstr, 32 | X25519 Noise static key | +| `role` | text | `agent`, `support` or `release` | +| `scopes` | [text] | Empty: an agent reads the box, never the readable copy | +| `epoch` | uint | | +| `tun` | bool | False: no door admits an agent row; only the `keys` part opens its session | +| `agent.scopes`, `agent.ops` | [text] | The grant, at the approver's scopes now | +| `agent.caps` | `{holdS, maxW, perMin}` | | +| `agent.expiresAtMs` | int | Wall clock | +| `agent.principal` | `{client, clientId, ticket}` | | + +- The box leaves out a row that has expired by its clock. While its clock is + `none`, it lists rows as they are and refuses their calls. +- FTW Cloud must stop admitting a row at `expiresAtMs` by its own clock, + without waiting for a roster. It must never admit an agent row on + `/v1/app`, `/v1/app/once` or the relay, and it may tighten caps but never + loosen them. + +## Agents use the tunnel only + +An agent's session reaches the box through FTW Cloud as `tun`, opened by the +`keys` part. The relay refuses it: a handshake over the relay from an agent's +key ends as from a stranger. The watch exchange refuses it too. + +## Expiry, revoke and the clock + +- **Expiry is wall clock.** The box checks it at the handshake, on every + call and once a second for an open session. An expired row ends its + session with `session.terminate {reason: "expired"}`, answers its next + call with `E_GRANT_EXPIRED`, loses its holds and leaves the roster. A sweep + removes it and writes an audit row. +- **No clock, no agents.** While the box clock is `none` (NTP has not + synchronised it), every call from an agent gets `E_UNAVAILABLE` with + `{subsystem: "clock"}`, and `agent.add` and `agent.widen` are refused the + same way. Owners' phones are not affected. +- **No going back.** The box keeps the highest wall time it has seen on disk. + A clock below it counts as `none` for agents, so setting the clock back + cannot revive an expired grant. +- **Revoke is immediate.** The row goes, the sessions end, the holds end, + and the next call fails with `E_GRANT_REVOKED`, whatever the cloud admits. +- **A warning first.** A day before a grant runs out, the box pushes + `agent.expiring` to every owner. + +## The approval signature + +The passkey is the web app's (`app.ftw.energy`) and the box cannot check it, +so `stepUp` is only the app's word. Agent grants need something the box can +check. + +- **The key.** Each owner phone makes a P-256 key in the Secure Enclave, + usable only after Face ID or Touch ID with the current enrolment + (`biometryCurrentSet`). It cannot leave the phone. The phone registers the + public half with `approval.key.set` over its owner session. +- **What it signs.** `agent.add`, `agent.widen`, `agent.confirm` to approve, + and `approval.key.set` itself as proof of possession. `agent.revoke` and a + decline need no signature. +- **The bytes.** The signature is ECDSA P-256 with SHA-256 over the + deterministic CBOR (RFC 8949, section 4.2.1, floats in their shortest + exact form) of one array, encoded `r ‖ s` in 64 bytes. CryptoKit's + `signature(for:)` hashes the bytes itself, so the phone passes the array's + bytes, not their digest. + + ``` + ["ftw.approval.v1", what, boxKey, deviceId, atMs, ...fields] + ``` + + | `what` | Fields after `atMs` | + |---|---| + | `approval.key.set` | `pub` | + | `agent.add` | `key, role, scopes, ops, [holdS, maxW, perMin], expiresAtMs, client, clientId, ticket` | + | `agent.widen` | `id, key, epoch, scopes, ops, [holdS, maxW, perMin], expiresAtMs` | + | `agent.confirm` | `reqId, agent, op, argsHash` | + + `boxKey` is the box's X25519 static key (32 bytes, as in the QR code), so a + signature for one box is worth nothing at another. `deviceId` is the + signing phone's id, and the box checks it is the session's. In + `agent.widen`, `key` is the row's whole key from `agent.list`. In + `agent.confirm`, `agent` and `op` are the request's and `argsHash` is + SHA-256 over the deterministic CBOR of the request's `args` as the phone + received and showed them. Lists are sorted with no repeats, empty text is + `""`, and integers take their shortest form. +- **Freshness.** `atMs` must be within 10 minutes of the box's wall clock; + outside it the box answers `E_CLOCK_SKEW {boxMs}`, and the phone may sign + again with the box's time. An `atMs` from before the box last started is + refused, because the box remembers what it took only since then. It keeps + the SHA-256 of each signed array it accepted for 20 minutes and refuses + the same bytes again, whatever the signature looks like. +- **What it proves.** Someone passed Face ID on that phone and approved + exactly these bytes. It does not stop a modified app on an enrolled owner + phone that registers a key of its own while the phone has none: that app + could already send commands. Once a phone has a key, replacing it needs the + old key or the house password. It stops a phone left unlocked on a table, + and it lets the box check approval without trusting the app or the cloud. + With `api.lan_auth` off, anyone on the home network can make an owner + code and pair an owner phone; the house password is what closes that. + +## Approval on the box page + +On the home network an owner manages agents at `/api/agents`. These routes +are `local`: the passthrough never carries them, so a phone uses the messages +above and its approval key, and an agent can never reach them. + +| Route | Who | +|---|---| +| `GET /api/agents` | A client on the LAN. Rows, requests and keys | +| `GET /api/agents/audit?before=&n=` | A client on the LAN | +| `POST /api/agents {offer, scopes, ops, caps, ttlS}` | A LAN client signed in with the house password | +| `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/agent-requests/{reqId} {approve: false}` | A client on the LAN | +| `DELETE /api/agents/{id}` | A client on the LAN | + +- `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: + the owner types the consent page's code, and the page, in the owner's + browser, fetches the signed offer from FTW Cloud's code lookup and posts + it here. The owner may grant less: `scopes`, `ops`, `caps` and `ttlS` + replace the offer's when given. +- **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. + +What the sign-in is worth today: + +- **Off by default.** With `api.lan_auth` off, which is the default, every + client on the home network is an owner with no sign-in. Granting a remote + agent from there would let any device on the Wi-Fi give the outside world + a way in, so those routes refuse with 403 until the house password is on. +- **With the house password on.** The password has at least 10 characters + and is stored as argon2id (64 MiB, 3 passes). Five wrong guesses lock every + sign-in for 30 seconds, box-wide. A sign-in sets a random 32-byte cookie, + `HttpOnly` and `SameSite=Strict`, that lasts 12 hours and dies with a + restart. Browser requests from other sites are refused by Origin and + `Sec-Fetch-Site` checks. +- **What it lacks.** The page is plain HTTP, so anyone who can read the home + network's traffic can read the password and the cookie. There is one + shared password and no person behind it, so the audit row names the + address. + +Narrowing stays open to any LAN client, as `DELETE /api/cloud/grant` does, +because removing access is safe. + +## The grant offer + +The MCP consent page shows a short code, a QR code and a universal link. All +three carry one signed offer, which the app shows the owner before Face ID. + +**The signed offer** is CBOR `{o, kid, sig}`, base64url without padding in +the link's fragment, and what FTW Cloud's code lookup returns for a code: + +- `o` is the deterministic CBOR of the offer below; +- `sig` is ECDSA P-256 with SHA-256 over the deterministic CBOR of + `["ftw.offer.v1", o]`, `r ‖ s`, by FTW Cloud's offer key named `kid`. + +The app and the box pin a list of offer keys, as they pin the service key, +and refuse an offer no pinned key signed. A build that pins none opens no +offer. A key's `kid` is the first 16 characters of base64url, without +padding, of SHA-256 over its SEC1 point (`protocol.OfferKid`); the box drops a +pinned entry whose `kid` does not match its key. FTW Cloud's `keys` part +makes the key on the host and keeps it sealed; FTW Cloud publishes `kid` and +the public key at `https://cloud.ftw.energy/.well-known/ftw-offer-key`, and a +release pins them. A new key sits beside the old one until every offer the +old one signed has expired. + +| Field | Type | Meaning | +|---|---|---| +| `v` | uint | 1 | +| `home` | text | Empty: the owner picks the home | +| `key` | bstr, 32 | The agent's X25519 key, made by FTW Cloud's `keys` part | +| `role` | text | `agent`, `support` or `release` | +| `client` | text | The name the client gives itself | +| `clientId` | text | The client id | +| `clientHost` | text | The host of the client's metadata document or redirect URI | +| `verified` | bool | FTW Cloud knows the client from its own list | +| `scopes`, `ops` | [text] | What the client asks for | +| `caps` | `{holdS, maxW, perMin}` | Proposed caps | +| `ttlS` | uint | Proposed length | +| `ticket` | text | The ticket id, for `support`; empty otherwise | +| `code` | text | The code on the consent page | +| `exp` | int | Wall-clock ms when the offer stops being good, at most 10 minutes after FTW Cloud made it | + +- **The app** shows `client`, `clientHost` and whether it is `verified`. On + the universal-link path the owner types the code the consent page shows, + so a link sent by someone else does not reach Face ID; on the QR path the + owner compares it. The app picks the home the owner chooses, asks when + there are several, and refuses a home it does not have. +- **Support** offers count only from `clientId` + `https://cloud.ftw.energy/clients/sourceful-support`, the support tooling's + 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. + +## Push kinds + +- `agent.added` goes to every owner when an agent row is added, as + `cloud.granted` does: access from outside is news every owner should hear. +- `agent.request` goes to every owner when an agent asks for a durable change. +- `agent.expiring` goes to every owner a day before a grant runs out. +- `approval.key.set` goes to every owner when a phone registers or replaces + 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. + +## 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 +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. + +## Numbers in one place + +| What | Value | +|---|---| +| Longest grant | `agent` 30 days, `support` 14 days, `release` 90 days | +| Agent rows per box | 16 | +| `holdS` | 60 to 1,800 s | +| `maxW` | 1 to 100,000 W, either direction | +| `perMin` | 1 to 60, default 6 | +| Reads per agent | 60 a minute | +| Pending requests | 4 per agent, 16 per box | +| Request id | 16 random bytes | +| Request lifetime | 12 hours; kept 24 hours after it ends | +| `agent.request` args, `why` | 1 KiB, 280 bytes | +| Approval `atMs` window | 10 minutes either way | +| Signature replay memory | 20 minutes, since the box started | +| Signed offer life | 10 minutes | +| `logs.get` | 200 lines, one 16 KiB frame | +| `events.get` | 50 events | +| `audit.get` | 200 rows a page; 10,000 rows and 90 days kept | +| `by` on a support call | 64 bytes | +| `agent.expiring` | A day before the expiry | diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 5a3341d3..ad2ffae0 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -16,7 +16,10 @@ specified in FTW Cloud reuses its frame layout, CBOR rules, field encoding and tile format. It adds the `cloud.link` capability and one optional field to the app protocol's `hello_ok`, `cloud` (see -[Session information](#session-information)), and changes nothing else. Lane 0 and the relay stay as they are. The Go types +[Session information](#session-information)), and changes nothing else. +Assistants, Sourceful support and the release check reach the box as agents +through the [tunnel](#tunnel); [Agents](agents.md) specifies their roles, +scopes, approval and messages. Lane 0 and the relay stay as they are. The Go types for both live in `go/protocol`, under Apache-2.0 ([#1588](https://github.com/srcfl/ftw/pull/1588)). @@ -431,6 +434,7 @@ The service never forwards these to a phone. | `devices` | [Device] | Every device paired with the box | | `grant` | Grant | What the owner granted | | `pending` | [Pending] | The pairing codes the box shows now, at most one | +| `noise` | bstr, 32 B | The box's X25519 Noise static key, the one in its pairing QR code. FTW Cloud's `keys` part opens an agent's session to it, and trusts it only as signed here | **Device** @@ -438,10 +442,45 @@ The service never forwards these to a phone. |---|---|---| | `id` | text | The box's device id | | `key` | bstr, 32 B | The device's X25519 Noise static key | -| `role` | text | `owner`, `viewer` or `watch` | -| `scopes` | [text] | Scopes the box derives from the role. For a watch, only those its phone's role also carries now. The service holds no role table | +| `role` | text | `owner`, `viewer` or `watch`; for an agent row `agent`, `support` or `release` | +| `scopes` | [text] | Scopes the box derives from the role. For a watch, only those its phone's role also carries now. Empty for an agent row: an agent reads the box, never the copy. The service holds no role table | | `epoch` | uint | The device's epoch on the box | -| `tun` | bool | The device may open a session to the box through the service. True for a phone, false for a watch. It needs no grant | +| `tun` | bool | The device may open a session to the box through the service's doors. True for a phone, false for a watch and for an agent row. It needs no grant | +| `agent` | map, optional | Only on an agent row: its grant, below. See [Agents](agents.md) | + +**Agent** + +| Field | Type | Meaning | +|---|---|---| +| `scopes` | [text] | The grant's scopes, within what its approving owner holds now | +| `ops` | [text] | The ops the grant allows | +| `caps` | map | `holdS`, `maxW` and `perMin`, below | +| `expiresAtMs` | int | Wall clock. The service stops admitting the row then by its own clock, without waiting for a roster | +| `principal` | map | `client`, `clientId` and `ticket`, below | + +**Who may open an agent's session.** Only the service's own `keys` part, the +one place that holds agent keys, opens a session for an agent row, and only +for a call through MCP that the agent's OAuth token allows. The service must +never admit an agent row on `/v1/app`, `/v1/app/once` or the relay, whatever +its `tun` says, and an agent row never reads the readable copy. It may +tighten the caps but never loosen them. The box checks everything again on +every call. + +**Caps** + +| Field | Type | Meaning | +|---|---|---| +| `holdS` | uint | The longest hold, in seconds; 0 without `ftw.dispatch.hold` | +| `maxW` | uint | The largest power a hold may set, in W | +| `perMin` | uint | `cmd` and `agent.request` per rolling minute | + +**Principal** + +| Field | Type | Meaning | +|---|---|---| +| `client` | text | The client's name, from FTW Cloud's consent page | +| `clientId` | text | The client id | +| `ticket` | text | The support ticket, for `support` | **Grant** @@ -1112,11 +1151,11 @@ owner must suspend again. ## Registry names -The contract registry does not hold these names yet. -[srcfl/ftw-webapp#81](https://github.com/srcfl/ftw-webapp/pull/81) adds them -there first; [#1595](https://github.com/srcfl/ftw/pull/1595) and -[srcfl/ftw-app#15](https://github.com/srcfl/ftw-app/pull/15) copy them here and -to the app. +These names live in the contract registry. srcfl/ftw-webapp added them there +first ([#81](https://github.com/srcfl/ftw-webapp/pull/81), +[#88](https://github.com/srcfl/ftw-webapp/pull/88) and, for agents, +[#90](https://github.com/srcfl/ftw-webapp/pull/90)), and paired pull requests +copied them here and to the app. | Name | Kind | Goes into | |---|---|---| @@ -1128,15 +1167,21 @@ to the app. | `cloud.tun` | Capability: the box takes its app session through the service as `tun`. Absent means the phone uses the relay, even when it knows the home id | `registry.yaml`, `capabilities` | | `tunnel` | Carrier state, between `relay` and `cloud`: the box's own session, carried by the service | `registry.yaml`, `carrier_states` | | `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when FTW Cloud is turned on or off | `push-catalogue.yaml` | +| `access.agents` | Capability: the box takes agents and answers the `agent.*` messages. See [Agents](agents.md) | `registry.yaml`, `capabilities` | +| `agent`, `support`, `release` | Roles for agent rows | `registry.yaml`, `roles` | +| `ftw.logs.read`, `ftw.config.read`, `ftw.diag.run`, `ftw.dispatch.hold`, `ftw.goals.request`, `ftw.update.request` | Agent scopes | `registry.yaml`, `scopes` | +| `agent` on each op | The scope that opens an op to an agent | `registry.yaml`, `ops` | +| `agent_args` | What an agent may put in each op, with a label for each argument | `registry.yaml`, new block | +| `reads` | The reads an agent may send and the scope each needs | `registry.yaml`, new block | +| `E_GRANT_EXPIRED`, `E_OP_NOT_GRANTED`, `E_CAP_EXCEEDED`, `E_RATE_LIMITED`, `E_NEEDS_OWNER`, `E_BAD_SIGNATURE`, `E_NO_APPROVAL_KEY`, `E_NOT_FOUND`, `E_CLOCK_SKEW` | Agents' refusals | `registry.yaml`, `errors` | +| `agent.added`, `agent.request`, `agent.expiring`, `approval.key.set`, `owner.added` | Push kinds, sent to every owner | `push-catalogue.yaml` | Class names, field names, close codes and the signing tags are not registry names. They live here and in `go/protocol`, held by golden vectors in both repositories. -Role names, `owner`, `viewer` and `watch`, are registry names, but -`go/protocol` does not generate them yet, so `srcfl/ftw-cloud` spells `owner` -by hand. Generating -the registry's roles would remove that copy. +Role names are registry names. `go/protocol` generates them as `Role*` +constants, so `srcfl/ftw-cloud` need not spell them by hand. ## Numbers in one place diff --git a/go/internal/apiauth/apiauth.go b/go/internal/apiauth/apiauth.go index 67f5fc88..ae10d350 100644 --- a/go/internal/apiauth/apiauth.go +++ b/go/internal/apiauth/apiauth.go @@ -69,6 +69,13 @@ const ( KindLAN = "lan" ) +// AgentRole reports whether a role is an agent's: a grant an owner gave to an +// assistant, to Sourceful support or to the release check, never to a paired +// device. Pairing codes and role changes refuse these roles. +func AgentRole(role string) bool { + return role == RoleAgent || role == RoleSupport || role == RoleRelease +} + // Tier is how much a route costs before it runs. // // It comes from what the handler DOES, declared beside the handler, and never diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go index 4eb336e0..50c9fc96 100644 --- a/go/internal/apiauth/contract_gen.go +++ b/go/internal/apiauth/contract_gen.go @@ -6,8 +6,14 @@ package apiauth // what the registry exists to stop, and a role is the one name here // that decides whether a phone may change anything. const ( + // RoleAgent — Assistant. + RoleAgent = "agent" // RoleOwner — Owner. RoleOwner = "owner" + // RoleRelease — Release check. + RoleRelease = "release" + // RoleSupport — Sourceful support. + RoleSupport = "support" // RoleViewer — Viewer. RoleViewer = "viewer" // RoleWatch — Watch. @@ -18,6 +24,18 @@ const ( // A role is a projection over scopes and never a replacement for them, // so this is the only place the projection is written down. var RoleScopes = map[string][]string{ + RoleAgent: { + "ftw.live.read", + "ftw.history.read", + "ftw.plan.read", + "ftw.health.read", + "ftw.events.read", + "ftw.logs.read", + "ftw.config.read", + "ftw.diag.run", + "ftw.dispatch.hold", + "ftw.goals.request", + }, RoleOwner: { "ftw.live.read", "ftw.history.read", @@ -29,6 +47,27 @@ var RoleScopes = map[string][]string{ "ftw.mode.write", "ftw.members.read", "ftw.members.write", + "ftw.logs.read", + "ftw.config.read", + "ftw.diag.run", + "ftw.dispatch.hold", + "ftw.goals.request", + "ftw.update.request", + }, + RoleRelease: { + "ftw.health.read", + }, + RoleSupport: { + "ftw.live.read", + "ftw.history.read", + "ftw.plan.read", + "ftw.health.read", + "ftw.events.read", + "ftw.logs.read", + "ftw.config.read", + "ftw.diag.run", + "ftw.dispatch.hold", + "ftw.goals.request", }, RoleViewer: { "ftw.live.read", diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 46d55ddb..65d66068 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -581,7 +581,8 @@ func knownRole(role string) error { } // phoneRole refuses a role a phone cannot hold: one the registry does not -// define, and watch, which only MintWatchCode hands out. +// define, watch, which only MintWatchCode hands out, and an agent's, which +// only an owner's signed grant hands out. func phoneRole(role string) error { if err := knownRole(role); err != nil { return err @@ -589,6 +590,9 @@ func phoneRole(role string) error { if role == apiauth.RoleWatch { return ErrWatchRole } + if apiauth.AgentRole(role) { + return fmt.Errorf("%w: %q is an agent's role", ErrUnknownRole, role) + } return nil } diff --git a/go/internal/appenroll/roles_test.go b/go/internal/appenroll/roles_test.go index d1342d08..cfc6c2be 100644 --- a/go/internal/appenroll/roles_test.go +++ b/go/internal/appenroll/roles_test.go @@ -148,6 +148,30 @@ func TestAnUnknownRoleIsRefusedEverywhere(t *testing.T) { } } +// An agent's role comes only from an owner's signed grant. A pairing code or +// a role change that named one would hand a phone an agent's reach without +// any of an agent's limits. +func TestAnAgentsRoleIsNeverAPhones(t *testing.T) { + id, _ := newIdentity(t) + owner := pair(t, id, apiauth.RoleOwner) + pair(t, id, apiauth.RoleOwner) + + for _, role := range []string{apiauth.RoleAgent, apiauth.RoleSupport, apiauth.RoleRelease} { + if _, _, err := id.MintPairingCode(role, PairingTTL); !errors.Is(err, ErrUnknownRole) { + t.Errorf("minting a code for %s: %v", role, err) + } + if _, _, err := id.MintSpokenCode(role); !errors.Is(err, ErrUnknownRole) { + t.Errorf("minting a box code for %s: %v", role, err) + } + if err := id.SetRole(owner.DeviceID, role); !errors.Is(err, ErrUnknownRole) { + t.Errorf("setting %s: %v", role, err) + } + } + if got := roleOf(t, id, owner.DeviceID); got != apiauth.RoleOwner { + t.Fatalf("the row is now %q", got) + } +} + // -------------------------------------------------------------------------- // Whoever creates a home owns it // -------------------------------------------------------------------------- diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index 924dce64..8d1e8402 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -40,6 +40,9 @@ type Registry struct { Ops []struct { Name string `yaml:"name"` Scope string `yaml:"scope"` + // Agent is the scope that opens the op to an agent, empty when + // none does. + Agent string `yaml:"agent"` Desc string `yaml:"desc"` } `yaml:"ops"` Modes []struct { @@ -56,7 +59,25 @@ type Registry struct { // with FTW Cloud, and what for. CloudScopes []string `yaml:"cloud_scopes"` CloudPurposes []string `yaml:"cloud_purposes"` - Resolutions map[string]struct { + // AgentArgs is what an agent may put in each op it can name, and + // Reads the reads it may send with the scope each needs. + AgentArgs map[string][]struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Unit string `yaml:"unit"` + Min *float64 `yaml:"min"` + Max *float64 `yaml:"max"` + Values []string `yaml:"values"` + Required bool `yaml:"required"` + Cap string `yaml:"cap"` + Label string `yaml:"label"` + } `yaml:"agent_args"` + Reads []struct { + Msg string `yaml:"msg"` + Scope string `yaml:"scope"` + Desc string `yaml:"desc"` + } `yaml:"reads"` + Resolutions map[string]struct { TileSpanMs int64 `yaml:"tile_span_ms"` RetentionDays int `yaml:"retention_days"` Source string `yaml:"source"` @@ -92,8 +113,11 @@ func Generate(raw []byte) ([]byte, error) { writeFields(&b, reg) writeCaps(&b, reg) writeScopes(&b, reg) + writeRoleNames(&b, reg) writeCloud(&b, reg) writeOps(&b, reg) + writeAgentArgs(&b, reg) + writeReads(&b, reg) writeErrors(&b, reg) writeSourceStates(&b, reg) writeResolutions(&b, reg) @@ -222,8 +246,8 @@ func writeCaps(b *bytes.Buffer, reg Registry) { } func writeScopes(b *bytes.Buffer, reg Registry) { - b.WriteString("// Scopes are one object axis and two verb axes. Roles are a projection over\n") - b.WriteString("// these, never a replacement for them.\n") + b.WriteString("// Scopes are an object and a verb. Roles are a projection over these,\n") + b.WriteString("// never a replacement for them.\n") b.WriteString("const (\n") for _, s := range reg.Scopes { fmt.Fprintf(b, "\t// Scope%s — %s.\n", goName(strings.TrimPrefix(s.Name, "ftw.")), s.Desc) @@ -231,13 +255,16 @@ func writeScopes(b *bytes.Buffer, reg Registry) { } b.WriteString(")\n\n") - // The registry's own shape: one object axis and two verb axes. The suffix - // is the contract, so the write set is derived rather than listed — a - // hand-kept list is one a new scope gets left out of. - b.WriteString("// WriteScopes is every scope that changes something.\n") + // The registry's own shape: an object and a verb. The verb is the + // contract, so the write set is derived rather than listed — a hand-kept + // list is one a new scope gets left out of. A hold changes something + // now and a request once an owner confirms it; read and run change + // nothing. + b.WriteString("// WriteScopes is every scope that changes something, now or once an\n") + b.WriteString("// owner confirms it.\n") b.WriteString("var WriteScopes = []string{\n") for _, s := range reg.Scopes { - if !strings.HasSuffix(s.Name, ".write") { + if !changes(s.Name) { continue } fmt.Fprintf(b, "\tScope%s,\n", goName(strings.TrimPrefix(s.Name, "ftw."))) @@ -245,6 +272,34 @@ func writeScopes(b *bytes.Buffer, reg Registry) { b.WriteString("}\n\n") } +// changes reports whether a scope's verb changes something. +func changes(scope string) bool { + for _, verb := range []string{".write", ".hold", ".request"} { + if strings.HasSuffix(scope, verb) { + return true + } + } + return false +} + +// writeRoleNames emits the role names into package protocol as well, for FTW +// Cloud, which reads a roster's roles and cannot import apiauth. The role +// table itself stays in apiauth; this is only the words. +func writeRoleNames(b *bytes.Buffer, reg Registry) { + names := make([]string, 0, len(reg.Roles)) + for name := range reg.Roles { + names = append(names, name) + } + sort.Strings(names) + b.WriteString("// Roles, as the registry names them. apiauth holds what each carries.\n") + b.WriteString("const (\n") + for _, name := range names { + fmt.Fprintf(b, "\t// Role%s — %s.\n", goName(name), reg.Roles[name].Label) + fmt.Fprintf(b, "\tRole%s = %q\n", goName(name), name) + } + b.WriteString(")\n\n") +} + // writeCloud emits what an FTW Cloud grant may name. The scopes are listed // by their constants, so a cloud scope the registry does not declare as a // scope fails to compile rather than being shared under a misspelled name. @@ -286,6 +341,61 @@ func writeOps(b *bytes.Buffer, reg Registry) { fmt.Fprintf(b, "\t%q: %q,\n", o.Name, o.Scope) } b.WriteString("}\n\n") + + b.WriteString("// RegistryAgentOps maps each op an agent may name to the scope that opens\n") + b.WriteString("// it. ftw.dispatch.hold runs as a cmd within the grant's caps;\n") + b.WriteString("// ftw.goals.request waits as an agent.request for an owner. An op not\n") + b.WriteString("// listed is never open to an agent.\n") + b.WriteString("var RegistryAgentOps = map[string]string{\n") + for _, o := range reg.Ops { + if o.Agent == "" { + continue + } + fmt.Fprintf(b, "\t%q: %q,\n", o.Name, o.Agent) + } + b.WriteString("}\n\n") +} + +// writeAgentArgs emits what an agent may put in each op, as data. +func writeAgentArgs(b *bytes.Buffer, reg Registry) { + ops := make([]string, 0, len(reg.AgentArgs)) + for op := range reg.AgentArgs { + ops = append(ops, op) + } + sort.Strings(ops) + b.WriteString("// RegistryAgentArgs is what an agent may put in each op it can name. The\n") + b.WriteString("// box refuses any other key, kind or range, in a cmd and in an\n") + b.WriteString("// agent.request, as it arrives.\n") + b.WriteString("var RegistryAgentArgs = map[string][]AgentArg{\n") + for _, op := range ops { + fmt.Fprintf(b, "\t%q: {\n", op) + for _, a := range reg.AgentArgs[op] { + fmt.Fprintf(b, "\t\t{Name: %q, Kind: %q, Unit: %q", a.Name, a.Kind, a.Unit) + if a.Min != nil { + fmt.Fprintf(b, ", Min: bound(%v)", *a.Min) + } + if a.Max != nil { + fmt.Fprintf(b, ", Max: bound(%v)", *a.Max) + } + if len(a.Values) > 0 { + fmt.Fprintf(b, ", Values: %#v", a.Values) + } + fmt.Fprintf(b, ", Required: %t, Cap: %q, Label: %q},\n", a.Required, a.Cap, a.Label) + } + b.WriteString("\t},\n") + } + b.WriteString("}\n\n") +} + +// writeReads emits each read an agent may send with the scope it needs. +func writeReads(b *bytes.Buffer, reg Registry) { + b.WriteString("// RegistryReads maps each read an agent may send to the scope it needs.\n") + b.WriteString("var RegistryReads = map[string]string{\n") + for _, r := range reg.Reads { + fmt.Fprintf(b, "\t// %s — %s.\n", r.Msg, r.Desc) + fmt.Fprintf(b, "\t%q: %q,\n", r.Msg, r.Scope) + } + b.WriteString("}\n\n") } // writeRoles emits the role table with '*' expanded. diff --git a/go/internal/appuplink/tunnel_test.go b/go/internal/appuplink/tunnel_test.go index 2dc7e1c7..3a6ee608 100644 --- a/go/internal/appuplink/tunnel_test.go +++ b/go/internal/appuplink/tunnel_test.go @@ -384,22 +384,34 @@ func TestOneDeviceCannotStarveTheOthersOfHandshakes(t *testing.T) { r.tunnel.Deliver(cloudwire.Tun{Dev: busyDev, Sid: uint32(100 + i), D: m1}) } // Drain what the busy device got: its answers, and the ends of the - // sessions each new one replaced. - deadline := time.After(2 * time.Second) + // sessions each new one replaced. Each answer costs X25519 work, which a + // loaded CI runner does slowly, so wait for the budget to be spent and + // then a while longer for any answer past it. answers := 0 + quiet := 500 * time.Millisecond + deadline := time.After(10 * time.Second) drain: for { + wait := time.After(quiet) + if answers < tunOpensPerDevice { + wait = nil + } select { case m := <-r.out.ch: if !m.Ended() { answers++ } + case <-wait: + break drain case <-deadline: break drain } } - if answers != tunOpensPerDevice { - t.Fatalf("the busy device got %d handshakes answered, want %d", answers, tunOpensPerDevice) + // At most its budget. Handshakes run side by side, and a device holds at + // most tunPendingPerDevice unproved sessions, so one evicted before it + // answered is fair; what must never happen is an answer past the budget. + if answers > tunOpensPerDevice || answers < tunOpensPerDevice-tunPendingPerDevice { + t.Fatalf("the busy device got %d handshakes answered, want at most %d", answers, tunOpensPerDevice) } other := r.pair(t) r.open(t, other, deviceIDOf(other.staticPublic()), 1, nil) diff --git a/go/internal/cloudlink/tunnel_test.go b/go/internal/cloudlink/tunnel_test.go index ccae9688..7b471bc5 100644 --- a/go/internal/cloudlink/tunnel_test.go +++ b/go/internal/cloudlink/tunnel_test.go @@ -171,8 +171,17 @@ func TestTunDoesNotSpendTheCopysBudget(t *testing.T) { } svc.waitFor(cloudwire.MsgTun, 3*time.Second) } + // The last tun is counted when it leaves, a moment after the service + // sees it; and the copy's own heartbeat may go meanwhile on a slow + // runner. What must hold is that the tunnel's 40 KiB never lands on the + // copy's count. + deadline := time.Now().Add(3 * time.Second) st := h.link.Status() - if st.BytesLast24h != before || st.TunBytesLast24h < 40_960 { + for st.TunBytesLast24h < 40_960 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + st = h.link.Status() + } + if st.BytesLast24h-before >= 40_960 || st.TunBytesLast24h < 40_960 { t.Fatalf("copy %d → %d bytes, tunnel %d", before, st.BytesLast24h, st.TunBytesLast24h) } } diff --git a/go/protocol/agents.go b/go/protocol/agents.go new file mode 100644 index 00000000..a1b26681 --- /dev/null +++ b/go/protocol/agents.go @@ -0,0 +1,608 @@ +package protocol + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "math/big" + "slices" + "strings" + + "github.com/fxamacker/cbor/v2" +) + +// strictDec decodes a signed body: a key it does not know, or a key twice, +// is a body the box will not sign off on. +var strictDec, _ = cbor.DecOptions{ + DupMapKey: cbor.DupMapKeyEnforcedAPF, + ExtraReturnErrors: cbor.ExtraDecErrorUnknownField, +}.DecMode() + +// Agents: grants an owner gives to an assistant, to Sourceful support for one +// ticket, or to the release check. docs/agents.md is the normative text; this +// file holds the shapes and the approval signature's bytes, so FTW Cloud and +// the phone build them from the same code the box checks them with. + +// Agent message types. Each request gets MsgAgentOK or MsgError with its id. +const ( + MsgApprovalKeySet = "approval.key.set" + MsgAgentAdd = "agent.add" + MsgAgentWiden = "agent.widen" + MsgAgentRevoke = "agent.revoke" + MsgAgentList = "agent.list" + MsgAgentRequest = "agent.request" + MsgAgentConfirm = "agent.confirm" + MsgAgentOK = "agent.ok" + // MsgAgentDecided tells an agent's open session how its request ended. + MsgAgentDecided = "agent.decided" + MsgAuditGet = "audit.get" + + MsgHealthGet = "health.get" + MsgHealth = "health" + MsgEventsGet = "events.get" + MsgEvents = "events" + MsgLogsGet = "logs.get" + MsgLogs = "logs" + MsgConfigGet = "config.get" + MsgConfig = "config" + MsgDiagRun = "diag.run" + MsgDiag = "diag" + + // The typed reads, whose answers name nothing. live, loadpoints and + // cars carry FTW Cloud's class bodies (cloudwire.Live, LP and Cars) + // with the box's agent ids in place of the copy's. + MsgLiveGet = "live.get" + MsgLive = "live" + MsgLoadpointsGet = "loadpoints.get" + MsgLoadpoints = "loadpoints" + MsgCarsGet = "cars.get" + MsgCars = "cars" + MsgDriversGet = "drivers.get" + MsgDrivers = "drivers" +) + +// TerminateExpired ends an agent's session when its grant runs out. +const TerminateExpired = "expired" + +// Request states. +const ( + RequestPending = "pending" + RequestApproved = "approved" + RequestDeclined = "declined" + RequestExpired = "expired" +) + +// Limits the box enforces on every grant, whatever the grant says. +const ( + // AgentMaxMs, SupportMaxMs and ReleaseMaxMs are the longest grants. + AgentMaxMs int64 = 30 * 24 * 60 * 60 * 1000 + SupportMaxMs int64 = 14 * 24 * 60 * 60 * 1000 + ReleaseMaxMs int64 = 90 * 24 * 60 * 60 * 1000 + + // AgentRowsMax is how many agent rows one box holds. + AgentRowsMax = 16 + + HoldSMin = 60 + HoldSMax = 1800 + MaxWMin = 1 + MaxWMax = 100_000 + PerMinMin = 1 + PerMinMax = 60 + // PerMinDefault is the rate a grant that names none gets. + PerMinDefault = 6 + // ReadsPerMin is the box's own limit on an agent's reads. + ReadsPerMin = 60 + + // PendingPerAgent and PendingPerBox bound the requests waiting for an + // owner. A request lives RequestTTLMs and is kept RequestKeptMs after + // it ends, so the agent can read how it ended. + PendingPerAgent = 4 + PendingPerBox = 16 + RequestTTLMs int64 = 12 * 60 * 60 * 1000 + RequestKeptMs int64 = 24 * 60 * 60 * 1000 + RequestArgsMax = 1024 + RequestWhyMax = 280 + + // ApprovalSkewMs is how far an approval's atMs may stray from the box's + // wall clock, and ApprovalReplayMs how long the box remembers one it took. + ApprovalSkewMs int64 = 10 * 60 * 1000 + ApprovalReplayMs int64 = 20 * 60 * 1000 + + // LogsLinesMax bounds logs.get; the answer also fits one bulk frame. + LogsLinesMax = 200 + EventsMax = 50 + AuditPageMax = 200 + AuditRowsKept = 10_000 + AuditDaysKept = 90 + // ByMax bounds the staff id a support call carries. + ByMax = 64 + // ReqIDBytes is a request id's randomness: 128 bits. + ReqIDBytes = 16 + // OfferTTLMs is the longest a signed offer may live. + OfferTTLMs int64 = 10 * 60 * 1000 + // PrincipalFieldMax bounds each principal field. + PrincipalFieldMax = 256 +) + +// Caps bound what one grant may do. +type Caps struct { + // HoldS is the longest hold, in seconds. + HoldS uint64 `cbor:"holdS" json:"holdS"` + // MaxW is the largest power a hold may set, in W. + MaxW uint64 `cbor:"maxW" json:"maxW"` + // PerMin is cmd and agent.request per rolling minute. + PerMin uint64 `cbor:"perMin" json:"perMin"` +} + +// Principal says who an agent row is for. Display and audit only: the role +// decides what it may do. +type Principal struct { + Client string `cbor:"client" json:"client"` + ClientID string `cbor:"clientId" json:"clientId"` + Ticket string `cbor:"ticket" json:"ticket"` +} + +// AgentRow is one agent grant as the box holds it. +type AgentRow struct { + ID string `cbor:"id" json:"id"` + Key []byte `cbor:"key" json:"key"` + Role string `cbor:"role" json:"role"` + Scopes []string `cbor:"scopes" json:"scopes"` + Ops []string `cbor:"ops" json:"ops"` + Caps Caps `cbor:"caps" json:"caps"` + ExpiresAtMs int64 `cbor:"expiresAtMs" json:"expiresAtMs"` + Principal Principal `cbor:"principal" json:"principal"` + ApprovedBy string `cbor:"approvedBy" json:"approvedBy"` + AddedAtMs int64 `cbor:"addedAtMs" json:"addedAtMs"` + LastSeenMs int64 `cbor:"lastSeenMs" json:"lastSeenMs"` + Epoch uint64 `cbor:"epoch" json:"epoch"` +} + +// ApprovalKeySet registers an owner phone's approval key. +type ApprovalKeySet struct { + Pub []byte `cbor:"pub"` + AtMs int64 `cbor:"atMs"` + Sig []byte `cbor:"sig"` + // PrevSig is the phone's current key's signature over the same bytes. + // Replacing a key needs it. + PrevSig []byte `cbor:"prevSig,omitempty"` +} + +// AgentAdd grants access. +type AgentAdd struct { + Key []byte `cbor:"key"` + Role string `cbor:"role"` + Scopes []string `cbor:"scopes"` + Ops []string `cbor:"ops"` + Caps Caps `cbor:"caps"` + ExpiresAtMs int64 `cbor:"expiresAtMs"` + Principal Principal `cbor:"principal"` + AtMs int64 `cbor:"atMs"` + Sig []byte `cbor:"sig"` +} + +// AgentWiden replaces a grant's rights. Any change but a revoke is signed. +type AgentWiden struct { + ID string `cbor:"id"` + Epoch uint64 `cbor:"epoch"` + Scopes []string `cbor:"scopes"` + Ops []string `cbor:"ops"` + Caps Caps `cbor:"caps"` + ExpiresAtMs int64 `cbor:"expiresAtMs"` + AtMs int64 `cbor:"atMs"` + Sig []byte `cbor:"sig"` +} + +// GrantOffer is what FTW Cloud's consent page offers the owner: the agent's +// key and the rights its client asks for (docs/agents.md, The grant offer). +// It travels signed, as SignedOffer. The owner may grant less than it asks. +type GrantOffer struct { + V uint64 `cbor:"v"` + // Home is empty: the owner picks the home in the app or on the box page. + // A box takes an offer whose home is empty or its own. + Home string `cbor:"home"` + Key []byte `cbor:"key"` + Role string `cbor:"role"` + // Client is the name the client gives itself. ClientHost is the host of + // its metadata document or redirect URI, and Verified says FTW Cloud + // knows the client by its own list. The app shows all three. + Client string `cbor:"client"` + ClientID string `cbor:"clientId"` + ClientHost string `cbor:"clientHost"` + Verified bool `cbor:"verified"` + Scopes []string `cbor:"scopes"` + Ops []string `cbor:"ops"` + Caps Caps `cbor:"caps"` + TTLS uint64 `cbor:"ttlS"` + Ticket string `cbor:"ticket"` + // Code is what the consent page shows, for the owner to type or compare. + Code string `cbor:"code"` + // ExpMs is when the offer stops being good, wall clock, at most + // OfferTTLMs after FTW Cloud made it. + ExpMs int64 `cbor:"exp"` +} + +// SignedOffer is an offer as it travels: O is the offer's deterministic +// CBOR, Sig FTW Cloud's offer key's signature over OfferBytes(O), and Kid +// names that key. It goes base64url, without padding, in a link's fragment. +type SignedOffer struct { + O []byte `cbor:"o"` + Kid string `cbor:"kid"` + Sig []byte `cbor:"sig"` +} + +// OfferTag starts what an offer key signs. +const OfferTag = "ftw.offer.v1" + +// SupportClientID is the one client a support offer may come from: the +// support tooling's client at FTW Cloud. +const SupportClientID = "https://cloud.ftw.energy/clients/sourceful-support" + +// Errors from reading a signed offer. +var ( + ErrOfferSignature = errors.New("protocol: the offer is not signed by a pinned offer key") + ErrOfferExpired = errors.New("protocol: the offer has expired") + ErrOfferShape = errors.New("protocol: the offer is malformed") +) + +// OfferKid names an offer key: the first 16 characters of base64url, without +// padding, of SHA-256 over its SEC1 point. FTW Cloud publishes kid and key at +// /.well-known/ftw-offer-key; the box and the apps pin a list of them. +func OfferKid(pub []byte) string { + sum := sha256.Sum256(pub) + return base64.RawURLEncoding.EncodeToString(sum[:])[:16] +} + +// OfferBytes is what an offer key signs: ["ftw.offer.v1", o]. +func OfferBytes(o []byte) ([]byte, error) { + return encMode.Marshal([]any{OfferTag, o}) +} + +// OpenOffer checks a signed offer against pinned offer keys (kid → SEC1 +// P-256 point) and the wall clock, and returns the offer. A support offer +// must come from SupportClientID. +func OpenOffer(keys map[string][]byte, so SignedOffer, nowMs int64) (GrantOffer, error) { + pub, ok := keys[so.Kid] + if !ok { + return GrantOffer{}, ErrOfferSignature + } + msg, err := OfferBytes(so.O) + if err != nil { + return GrantOffer{}, ErrOfferShape + } + if VerifyApproval(pub, msg, so.Sig) != nil { + return GrantOffer{}, ErrOfferSignature + } + var o GrantOffer + if err := strictDec.Unmarshal(so.O, &o); err != nil || o.V != GrantOfferVersion || len(o.Key) != 32 { + return GrantOffer{}, ErrOfferShape + } + if nowMs >= o.ExpMs || o.ExpMs > nowMs+OfferTTLMs { + return GrantOffer{}, ErrOfferExpired + } + if o.Role == RoleSupport && o.ClientID != SupportClientID { + return GrantOffer{}, ErrOfferShape + } + return o, nil +} + +// GrantOfferVersion is GrantOffer.V. +const GrantOfferVersion = 1 + +// AgentRevoke removes a grant. No signature. +type AgentRevoke struct { + ID string `cbor:"id"` +} + +// AgentArg is one argument an agent may put in an op, from the registry's +// agent_args. Min and Max bound a number; Cap names the grant's cap that +// bounds it as well. +type AgentArg struct { + Name string + Kind string + Unit string + Min *float64 + Max *float64 + Values []string + Required bool + Cap string + Label string +} + +func bound(v float64) *float64 { return &v } + +// AgentRequest is an agent asking for a durable change. +type AgentRequest struct { + Op string `cbor:"op"` + Args map[string]any `cbor:"args"` + Why string `cbor:"why"` +} + +// AgentConfirm answers a request. Approving is signed; declining is not. +type AgentConfirm struct { + ReqID string `cbor:"reqId"` + Approve bool `cbor:"approve"` + AtMs int64 `cbor:"atMs,omitempty"` + Sig []byte `cbor:"sig,omitempty"` +} + +// Request is one durable change an agent asked for. +type Request struct { + ReqID string `cbor:"reqId" json:"reqId"` + Agent string `cbor:"agent" json:"agent"` + Op string `cbor:"op" json:"op"` + Args map[string]any `cbor:"args" json:"args"` + Why string `cbor:"why" json:"why"` + AtMs int64 `cbor:"atMs" json:"atMs"` + ExpiresAtMs int64 `cbor:"expiresAtMs" json:"expiresAtMs"` + State string `cbor:"state" json:"state"` +} + +// ApprovalKeyInfo is one owner phone's registered approval key. +type ApprovalKeyInfo struct { + DeviceID string `cbor:"deviceId" json:"deviceId"` + SetAtMs int64 `cbor:"setAtMs" json:"setAtMs"` +} + +// AgentDecided is how a request ended, sent to the agent's open session. +// Result is the command's outcome when the request ran. +type AgentDecided struct { + ReqID string `cbor:"reqId"` + State string `cbor:"state"` + CmdID string `cbor:"cmdId,omitempty"` + Result *CmdResult `cbor:"result,omitempty"` +} + +// AgentList is agent.ok's body for agent.list. An agent sees its own row. +type AgentList struct { + Agents []AgentRow `cbor:"agents" json:"agents"` + Requests []Request `cbor:"requests" json:"requests"` + Keys []ApprovalKeyInfo `cbor:"keys" json:"keys"` +} + +// AuditGet pages through the audit log, newest first. +type AuditGet struct { + BeforeSeq uint64 `cbor:"beforeSeq,omitempty"` + N uint64 `cbor:"n,omitempty"` +} + +// AuditRow is one line of the box's audit log. +type AuditRow struct { + Seq uint64 `cbor:"seq" json:"seq"` + AtMs int64 `cbor:"atMs" json:"atMs"` + Actor string `cbor:"actor" json:"actor"` + Role string `cbor:"role" json:"role"` + Principal string `cbor:"principal" json:"principal"` + By string `cbor:"by" json:"by"` + What string `cbor:"what" json:"what"` + Target string `cbor:"target" json:"target"` + Result string `cbor:"result" json:"result"` + // Count folds an agent's reads: how many of what in that minute. + Count uint64 `cbor:"count" json:"count"` +} + +// AuditPage is agent.ok's body for audit.get. +type AuditPage struct { + Rows []AuditRow `cbor:"rows" json:"rows"` + More bool `cbor:"more" json:"more"` +} + +// LogsGet asks for a redacted log excerpt: lines older than BeforeMs, or +// newer than AfterMs, newest first up to N. +type LogsGet struct { + N uint64 `cbor:"n,omitempty"` + Level string `cbor:"level,omitempty"` + BeforeMs int64 `cbor:"beforeMs,omitempty"` + AfterMs int64 `cbor:"afterMs,omitempty"` +} + +// EventsGet asks for recent events, with the same cursors as LogsGet. +type EventsGet struct { + N uint64 `cbor:"n,omitempty"` + BeforeMs int64 `cbor:"beforeMs,omitempty"` + AfterMs int64 `cbor:"afterMs,omitempty"` +} + +// Events is the answer to events.get: FTW Cloud's event bodies, oldest +// first. More says older events remain. +type Events struct { + Events []cbor.RawMessage `cbor:"events"` + More bool `cbor:"more"` +} + +// DriverState is one driver in drivers.get, by its opaque source id. +type DriverState struct { + Src string `cbor:"src"` + Kind string `cbor:"kind"` + State string `cbor:"state"` + // AgeMs is the time since its last good reading, or -1 for never. + AgeMs int64 `cbor:"ageMs"` +} + +// Drivers is the answer to drivers.get. +type Drivers struct { + Drivers []DriverState `cbor:"drivers"` +} + +// LogLine is one redacted line. Src is an opaque source id for a driver's +// line, empty for the box's own. +type LogLine struct { + Ts int64 `cbor:"ts"` + Level string `cbor:"level"` + Src string `cbor:"src,omitempty"` + Msg string `cbor:"msg"` +} + +// Logs is the answer to logs.get, oldest first. Dropped counts lines left +// out to fit one frame; More says older lines remain. +type Logs struct { + Lines []LogLine `cbor:"lines"` + Dropped uint64 `cbor:"dropped"` + More bool `cbor:"more"` +} + +// ValidBy says whether a support call's by is well formed: staff: for a +// person or bot: for Sourceful's own tooling. +func ValidBy(by string) bool { + kind, id, ok := strings.Cut(by, ":") + if !ok || (kind != "staff" && kind != "bot") || id == "" || len(by) > ByMax { + return false + } + for _, c := range id { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._@-", c)) { + return false + } + } + return true +} + +// -------------------------------------------------------------------------- +// The approval signature +// -------------------------------------------------------------------------- + +// ApprovalTag starts every signed array, so a signature for anything else is +// never one of these. +const ApprovalTag = "ftw.approval.v1" + +// ApprovalSigBytes is r ‖ s, 32 bytes each. +const ApprovalSigBytes = 64 + +// ApprovalPubBytes is a SEC1 uncompressed P-256 point. +const ApprovalPubBytes = 65 + +// Errors from checking a signed body's shape. A signature that does not +// verify is ErrApprovalSignature. +var ( + ErrApprovalUnsorted = errors.New("protocol: a signed list must be sorted with no repeats") + ErrApprovalPub = errors.New("protocol: approval key is not an uncompressed P-256 point") + ErrApprovalSignature = errors.New("protocol: approval signature does not verify") +) + +// SignedFor names the box and the phone an approval belongs to. +type SignedFor struct { + // BoxKey is the box's X25519 static key, as in the pairing QR code. + BoxKey []byte + // DeviceID is the signing phone's device id. + DeviceID string +} + +// ApprovalBytes is the deterministic CBOR array a phone signs: the tag, what +// it approves, the box, the phone, its clock, then the fields. +func ApprovalBytes(what string, f SignedFor, atMs int64, fields ...any) ([]byte, error) { + for _, v := range fields { + if list, ok := v.([]string); ok && !sortedUnique(list) { + return nil, ErrApprovalUnsorted + } + } + arr := append([]any{ApprovalTag, what, f.BoxKey, f.DeviceID, atMs}, fields...) + b, err := encMode.Marshal(arr) + if err != nil { + return nil, fmt.Errorf("protocol: encode approval: %w", err) + } + return b, nil +} + +func capsArray(c Caps) []uint64 { return []uint64{c.HoldS, c.MaxW, c.PerMin} } + +func emptyIfNil(list []string) []string { + if list == nil { + return []string{} + } + return list +} + +// KeySetBytes is what approval.key.set signs: the key itself, as proof the +// phone holds its private half. +func KeySetBytes(f SignedFor, m ApprovalKeySet) ([]byte, error) { + return ApprovalBytes(MsgApprovalKeySet, f, m.AtMs, m.Pub) +} + +// AddBytes is what agent.add signs. +func AddBytes(f SignedFor, m AgentAdd) ([]byte, error) { + return ApprovalBytes(MsgAgentAdd, f, m.AtMs, m.Key, m.Role, emptyIfNil(m.Scopes), emptyIfNil(m.Ops), + capsArray(m.Caps), m.ExpiresAtMs, m.Principal.Client, m.Principal.ClientID, m.Principal.Ticket) +} + +// WidenBytes is what agent.widen signs. key is the row's whole key, which +// the phone read from agent.list: an id and an epoch alone could name +// another row that took the same id. +func WidenBytes(f SignedFor, m AgentWiden, key []byte) ([]byte, error) { + return ApprovalBytes(MsgAgentWiden, f, m.AtMs, m.ID, key, m.Epoch, emptyIfNil(m.Scopes), emptyIfNil(m.Ops), + capsArray(m.Caps), m.ExpiresAtMs) +} + +// ConfirmBytes is what agent.confirm signs to approve: the request, the +// agent that asked, the op and the hash of its args, as the phone showed +// them. +func ConfirmBytes(f SignedFor, m AgentConfirm, req Request) ([]byte, error) { + sum, err := ArgsHash(req.Args) + if err != nil { + return nil, err + } + return ApprovalBytes(MsgAgentConfirm, f, m.AtMs, m.ReqID, req.Agent, req.Op, sum) +} + +// ArgsHash is SHA-256 over the deterministic CBOR of a request's args. +func ArgsHash(args map[string]any) ([]byte, error) { + if args == nil { + args = map[string]any{} + } + raw, err := encMode.Marshal(args) + if err != nil { + return nil, fmt.Errorf("protocol: encode args: %w", err) + } + sum := sha256.Sum256(raw) + return sum[:], nil +} + +// ParseApprovalKey reads a SEC1 uncompressed P-256 public key. +func ParseApprovalKey(pub []byte) (*ecdsa.PublicKey, error) { + if len(pub) != ApprovalPubBytes || pub[0] != 4 { + return nil, ErrApprovalPub + } + x := new(big.Int).SetBytes(pub[1:33]) + y := new(big.Int).SetBytes(pub[33:]) + curve := elliptic.P256() + if !curve.IsOnCurve(x, y) { + return nil, ErrApprovalPub + } + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil +} + +// VerifyApproval checks sig, r ‖ s, over SHA-256 of msg. +func VerifyApproval(pub []byte, msg, sig []byte) error { + key, err := ParseApprovalKey(pub) + if err != nil { + return err + } + if len(sig) != ApprovalSigBytes { + return ErrApprovalSignature + } + digest := sha256.Sum256(msg) + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(key, digest[:], r, s) { + return ErrApprovalSignature + } + return nil +} + +func sortedUnique(list []string) bool { + return slices.IsSorted(list) && len(slices.Compact(slices.Clone(list))) == len(list) +} + +// Refusal is a refusal on its way to the wire: a registry code and its +// machine-readable args. The box's layers return it so the one that answers +// the caller need not translate. +type Refusal struct { + Code string + Args map[string]any +} + +func (r *Refusal) Error() string { return fmt.Sprintf("protocol: refused %s %v", r.Code, r.Args) } + +// Refuse builds a Refusal. +func Refuse(code string, args map[string]any) *Refusal { return &Refusal{Code: code, Args: args} } diff --git a/go/protocol/agents_test.go b/go/protocol/agents_test.go new file mode 100644 index 00000000..f58c6bbb --- /dev/null +++ b/go/protocol/agents_test.go @@ -0,0 +1,540 @@ +package protocol + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "math/big" + "os" + "path/filepath" + "strings" + "testing" +) + +// The approval vectors are what the phone and FTW Cloud replay: for each +// signed message, its body as it travels, the exact bytes the phone signs +// and a signature by the vector key. Regenerate with +// FTW_WRITE_VECTORS=1 go test ./protocol -run TestApprovalVectors; the +// bytes must not change for an existing case. + +type approvalVectors struct { + Note string `json:"note"` + PubHex string `json:"pubHex"` + BoxKey string `json:"boxKeyHex"` + DeviceID string `json:"deviceId"` + Cases []approvalVector `json:"cases"` + Offer offerVector `json:"offer"` +} + +type approvalVector struct { + What string `json:"what"` + BodyHex string `json:"bodyHex"` + // ContextHex is what the box adds from its own records: the row's key + // for agent.widen, the stored request (CBOR) for agent.confirm. + ContextHex string `json:"contextHex,omitempty"` + SignedHex string `json:"signedHex"` + SHA256Hex string `json:"sha256Hex"` + SigHex string `json:"sigHex"` +} + +// offerVector is one signed offer: the link fragment's CBOR, what the offer +// key signed, and the key. +type offerVector struct { + Note string `json:"note"` + Kid string `json:"kid"` + PubHex string `json:"pubHex"` + NowMs int64 `json:"nowMs"` + SignedHex string `json:"signedOfferHex"` + OHex string `json:"oHex"` + BytesHex string `json:"signedBytesHex"` +} + +const approvalVectorsPath = "testdata/approval-vectors.json" + +// vectorKey is a fixed P-256 key, so the public half in the file never +// changes. It signs nothing outside these tests. +func vectorKey() *ecdsa.PrivateKey { + d, _ := new(big.Int).SetString("6b2c7e1f0a9d3c4b5e8f7a6d1c2b3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d", 16) + key := &ecdsa.PrivateKey{D: d} + key.Curve = elliptic.P256() + key.X, key.Y = key.Curve.ScalarBaseMult(d.Bytes()) + return key +} + +func vectorPub(key *ecdsa.PrivateKey) []byte { + return elliptic.Marshal(elliptic.P256(), key.X, key.Y) //nolint:staticcheck // SEC1 uncompressed is the wire form. +} + +func signRaw(t *testing.T, key *ecdsa.PrivateKey, msg []byte) []byte { + t.Helper() + digest := sha256.Sum256(msg) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + t.Fatal(err) + } + out := make([]byte, ApprovalSigBytes) + r.FillBytes(out[:32]) + s.FillBytes(out[32:]) + return out +} + +func vectorFor() SignedFor { + box := make([]byte, 32) + for i := range box { + box[i] = byte(i + 1) + } + return SignedFor{BoxKey: box, DeviceID: "ownerPh1"} +} + +func vectorAgentKey() []byte { + key := make([]byte, 32) + for i := range key { + key[i] = byte(0xa0 + i) + } + return key +} + +// contexts is what the box adds to each signed message from its records. +var contexts = map[string][]byte{} + +// vectorRequest is the stored request the confirmation vector approves. +func vectorRequest() Request { + return Request{ReqID: "AAECAwQFBgcICQoLDA0ODw", Agent: "oKGio6Sl", Op: OpSetMode, + Args: map[string]any{"mode": "self_consumption"}, Why: "Cheaper tonight", + AtMs: 1_792_000_000_000, ExpiresAtMs: 1_792_000_000_000 + RequestTTLMs, State: RequestPending} +} + +// vectorMessages builds every signed message, each with its signing bytes. +func vectorMessages(t *testing.T, key *ecdsa.PrivateKey) []struct { + what string + body any + sign func(sig []byte) any + raw []byte +} { + t.Helper() + f := vectorFor() + const atMs = 1_792_000_000_000 + + keySet := ApprovalKeySet{Pub: vectorPub(key), AtMs: atMs} + add := AgentAdd{ + Key: vectorAgentKey(), Role: RoleAgent, + Scopes: []string{ScopeDispatchHold, ScopeLiveRead, ScopePlanRead}, + Ops: []string{OpLoadpointHold}, + Caps: Caps{HoldS: 900, MaxW: 11_000, PerMin: 6}, + ExpiresAtMs: atMs + AgentMaxMs, + Principal: Principal{Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-client-metadata"}, + AtMs: atMs, + } + support := AgentAdd{ + Key: vectorAgentKey(), Role: RoleSupport, + Scopes: []string{ScopeHealthRead, ScopeLogsRead}, + ExpiresAtMs: atMs + SupportMaxMs, + Principal: Principal{Client: "Sourceful support", Ticket: "FTW-123"}, + AtMs: atMs, + } + widen := AgentWiden{ + ID: "oKGio6Sl", Epoch: 2, + Scopes: []string{ScopeLiveRead, ScopePlanRead}, + Caps: Caps{PerMin: 6}, + ExpiresAtMs: atMs + AgentMaxMs, AtMs: atMs, + } + confirm := AgentConfirm{ReqID: vectorRequest().ReqID, Approve: true, AtMs: atMs} + + keySetRaw, err := KeySetBytes(f, keySet) + if err != nil { + t.Fatal(err) + } + addRaw, err := AddBytes(f, add) + if err != nil { + t.Fatal(err) + } + supportRaw, err := AddBytes(f, support) + if err != nil { + t.Fatal(err) + } + widenRaw, err := WidenBytes(f, widen, vectorAgentKey()) + if err != nil { + t.Fatal(err) + } + confirmRaw, err := ConfirmBytes(f, confirm, vectorRequest()) + if err != nil { + t.Fatal(err) + } + type msg = struct { + what string + body any + sign func(sig []byte) any + raw []byte + } + reqRaw, err := Marshal(vectorRequest()) + if err != nil { + t.Fatal(err) + } + contexts[MsgAgentWiden] = vectorAgentKey() + contexts[MsgAgentConfirm] = reqRaw + return []msg{ + {MsgApprovalKeySet, keySet, func(sig []byte) any { m := keySet; m.Sig = sig; return m }, keySetRaw}, + {MsgAgentAdd, add, func(sig []byte) any { m := add; m.Sig = sig; return m }, addRaw}, + {MsgAgentAdd, support, func(sig []byte) any { m := support; m.Sig = sig; return m }, supportRaw}, + {MsgAgentWiden, widen, func(sig []byte) any { m := widen; m.Sig = sig; return m }, widenRaw}, + {MsgAgentConfirm, confirm, func(sig []byte) any { m := confirm; m.Sig = sig; return m }, confirmRaw}, + } +} + +func TestApprovalVectors(t *testing.T) { + key := vectorKey() + msgs := vectorMessages(t, key) + + if os.Getenv("FTW_WRITE_VECTORS") != "" { + f := vectorFor() + out := approvalVectors{ + Note: "docs/agents.md, The approval signature. signedHex is what the phone signs; " + + "sigHex is ECDSA P-256 over SHA-256 of it, r||s.", + PubHex: hex.EncodeToString(vectorPub(key)), BoxKey: hex.EncodeToString(f.BoxKey), DeviceID: f.DeviceID, + } + for _, m := range msgs { + sig := signRaw(t, key, m.raw) + body, err := Marshal(m.sign(sig)) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(m.raw) + out.Cases = append(out.Cases, approvalVector{ + What: m.what, BodyHex: hex.EncodeToString(body), ContextHex: hex.EncodeToString(contexts[m.what]), + SignedHex: hex.EncodeToString(m.raw), SHA256Hex: hex.EncodeToString(sum[:]), SigHex: hex.EncodeToString(sig), + }) + } + out.Offer = makeOfferVector(t) + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.FromSlash(approvalVectorsPath), append(raw, '\n'), 0o644); err != nil { + t.Fatal(err) + } + } + + raw, err := os.ReadFile(filepath.FromSlash(approvalVectorsPath)) + if err != nil { + t.Fatal(err) + } + var vec approvalVectors + if err := json.Unmarshal(raw, &vec); err != nil { + t.Fatal(err) + } + if vec.PubHex != hex.EncodeToString(vectorPub(key)) { + t.Fatal("the vector key changed") + } + if len(vec.Cases) != len(msgs) { + t.Fatalf("%d cases, want %d", len(vec.Cases), len(msgs)) + } + pub := vectorPub(key) + f := SignedFor{DeviceID: vec.DeviceID} + f.BoxKey, _ = hex.DecodeString(vec.BoxKey) + for i, c := range vec.Cases { + if c.What != msgs[i].what { + t.Fatalf("case %d is %s, want %s", i, c.What, msgs[i].what) + } + if got := hex.EncodeToString(msgs[i].raw); got != c.SignedHex { + t.Errorf("case %d (%s): signed bytes changed\n got %s\nwant %s", i, c.What, got, c.SignedHex) + } + // Rebuild the signed bytes from the body as it travels, which is + // what the box does. + body, _ := hex.DecodeString(c.BodyHex) + ctx, _ := hex.DecodeString(c.ContextHex) + signed, sig := rebuild(t, c.What, f, body, ctx) + if hex.EncodeToString(signed) != c.SignedHex { + t.Errorf("case %d (%s): the body does not rebuild the signed bytes", i, c.What) + } + if hex.EncodeToString(sig) != c.SigHex { + t.Errorf("case %d (%s): the body carries another signature", i, c.What) + } + if err := VerifyApproval(pub, signed, sig); err != nil { + t.Errorf("case %d (%s): %v", i, c.What, err) + } + sum := sha256.Sum256(signed) + if hex.EncodeToString(sum[:]) != c.SHA256Hex { + t.Errorf("case %d (%s): digest", i, c.What) + } + } +} + +func rebuild(t *testing.T, what string, f SignedFor, body, ctx []byte) ([]byte, []byte) { + t.Helper() + var signed []byte + var sig []byte + var err error + switch what { + case MsgApprovalKeySet: + var m ApprovalKeySet + if err = Unmarshal(body, &m); err == nil { + signed, err = KeySetBytes(f, m) + sig = m.Sig + } + case MsgAgentAdd: + var m AgentAdd + if err = Unmarshal(body, &m); err == nil { + signed, err = AddBytes(f, m) + sig = m.Sig + } + case MsgAgentWiden: + var m AgentWiden + if err = Unmarshal(body, &m); err == nil { + signed, err = WidenBytes(f, m, ctx) + sig = m.Sig + } + case MsgAgentConfirm: + var m AgentConfirm + var req Request + if err = Unmarshal(body, &m); err == nil { + if err = Unmarshal(ctx, &req); err == nil { + signed, err = ConfirmBytes(f, m, req) + sig = m.Sig + } + } + default: + t.Fatalf("no rebuild for %s", what) + } + if err != nil { + t.Fatal(err) + } + return signed, sig +} + +func TestApprovalRefusesWhatItDidNotSign(t *testing.T) { + key := vectorKey() + pub := vectorPub(key) + f := vectorFor() + add := AgentAdd{Key: vectorAgentKey(), Role: RoleAgent, Scopes: []string{ScopeLiveRead}, + ExpiresAtMs: 2_000, AtMs: 1_000} + raw, err := AddBytes(f, add) + if err != nil { + t.Fatal(err) + } + sig := signRaw(t, key, raw) + if err := VerifyApproval(pub, raw, sig); err != nil { + t.Fatal(err) + } + + wider := add + wider.Scopes = []string{ScopeDispatchHold, ScopeLiveRead} + widerRaw, _ := AddBytes(f, wider) + if !errors.Is(VerifyApproval(pub, widerRaw, sig), ErrApprovalSignature) { + t.Error("a signature over read access passed for control") + } + + other := f + other.BoxKey = bytes.Repeat([]byte{9}, 32) + otherRaw, _ := AddBytes(other, add) + if !errors.Is(VerifyApproval(pub, otherRaw, sig), ErrApprovalSignature) { + t.Error("a signature for one box passed at another") + } + + if !errors.Is(VerifyApproval(pub, raw, sig[:63]), ErrApprovalSignature) { + t.Error("a short signature passed") + } + if !errors.Is(VerifyApproval(pub[:64], raw, sig), ErrApprovalPub) { + t.Error("a short key passed") + } + bad := append([]byte(nil), pub...) + bad[64] ^= 1 + if !errors.Is(VerifyApproval(bad, raw, sig), ErrApprovalPub) { + t.Error("a point off the curve passed") + } +} + +func TestApprovalBytesRefuseAnUnsortedList(t *testing.T) { + f := vectorFor() + for _, scopes := range [][]string{ + {ScopePlanRead, ScopeLiveRead}, + {ScopeLiveRead, ScopeLiveRead}, + } { + _, err := AddBytes(f, AgentAdd{Key: vectorAgentKey(), Role: RoleAgent, Scopes: scopes}) + if !errors.Is(err, ErrApprovalUnsorted) { + t.Errorf("%v: %v", scopes, err) + } + } +} + +func TestEnvelopeCarriesByOnlyWhenSet(t *testing.T) { + id := uint32(7) + plain, err := EncodeEnvelope(Envelope{T: MsgPlanGet, ID: &id}) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(plain, []byte("by")) { + t.Error("an envelope without a staff id names one") + } + withBy, err := EncodeEnvelope(Envelope{T: MsgPlanGet, ID: &id, By: "staff-42"}) + if err != nil { + t.Fatal(err) + } + got, err := DecodeEnvelope(withBy) + if err != nil || got.By != "staff-42" { + t.Fatalf("by did not survive: %+v %v", got, err) + } +} + +// The offer vector's key: fixed like the approval key, and nobody's offer key. +func offerKey() *ecdsa.PrivateKey { + d, _ := new(big.Int).SetString("2f4e6a8c0b1d3e5f7a9c2b4d6e8f0a1c3e5b7d9f1a2c4e6b8d0f2a4c6e8b0d2f", 16) + key := &ecdsa.PrivateKey{D: d} + key.Curve = elliptic.P256() + key.X, key.Y = key.Curve.ScalarBaseMult(d.Bytes()) + return key +} + +const offerNowMs = 1_792_000_000_000 + +func vectorOffer() GrantOffer { + return GrantOffer{ + V: GrantOfferVersion, Key: vectorAgentKey(), Role: RoleAgent, + Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-client-metadata", ClientHost: "claude.ai", Verified: true, + Scopes: []string{ScopeLiveRead, ScopePlanRead}, Ops: []string{}, TTLS: 30 * 24 * 60 * 60, + Code: "K7QF-2MXD", ExpMs: offerNowMs + 5*60*1000, + } +} + +func makeOfferVector(t *testing.T) offerVector { + t.Helper() + key := offerKey() + o, err := Marshal(vectorOffer()) + if err != nil { + t.Fatal(err) + } + msg, err := OfferBytes(o) + if err != nil { + t.Fatal(err) + } + signed, err := Marshal(SignedOffer{O: o, Kid: OfferKid(vectorPub(key)), Sig: signRaw(t, key, msg)}) + if err != nil { + t.Fatal(err) + } + return offerVector{ + Note: "docs/agents.md, The grant offer. The link fragment is base64url of signedOfferHex.", + Kid: OfferKid(vectorPub(key)), PubHex: hex.EncodeToString(vectorPub(key)), NowMs: offerNowMs, + SignedHex: hex.EncodeToString(signed), OHex: hex.EncodeToString(o), BytesHex: hex.EncodeToString(msg), + } +} + +func TestTheOfferVectorOpens(t *testing.T) { + raw, err := os.ReadFile(filepath.FromSlash(approvalVectorsPath)) + if err != nil { + t.Fatal(err) + } + var vec approvalVectors + if err := json.Unmarshal(raw, &vec); err != nil { + t.Fatal(err) + } + pub, _ := hex.DecodeString(vec.Offer.PubHex) + keys := map[string][]byte{vec.Offer.Kid: pub} + signed, _ := hex.DecodeString(vec.Offer.SignedHex) + var so SignedOffer + if err := Unmarshal(signed, &so); err != nil { + t.Fatal(err) + } + o, err := OpenOffer(keys, so, vec.Offer.NowMs) + if err != nil { + t.Fatal(err) + } + if o.Client != "Claude" || o.Home != "" || !o.Verified || o.ClientHost != "claude.ai" { + t.Errorf("offer %+v", o) + } + if want, _ := hex.DecodeString(vec.Offer.BytesHex); !bytes.Equal(mustOfferBytes(t, so.O), want) { + t.Error("the signed bytes changed") + } + + if _, err := OpenOffer(keys, so, o.ExpMs); !errors.Is(err, ErrOfferExpired) { + t.Errorf("at its expiry: %v", err) + } + if _, err := OpenOffer(map[string][]byte{"other": pub}, so, vec.Offer.NowMs); !errors.Is(err, ErrOfferSignature) { + t.Errorf("an unknown kid: %v", err) + } + tampered := so + tampered.O = append([]byte(nil), so.O...) + tampered.O[len(tampered.O)-1] ^= 1 + if _, err := OpenOffer(keys, tampered, vec.Offer.NowMs); !errors.Is(err, ErrOfferSignature) { + t.Errorf("a changed offer: %v", err) + } + + // A support offer from any client but Sourceful's is refused, even signed. + sup := vectorOffer() + sup.Role = RoleSupport + sup.Ticket = "FTW-1" + so2 := signOffer(t, sup) + if _, err := OpenOffer(keys2(), so2, offerNowMs); !errors.Is(err, ErrOfferShape) { + t.Errorf("support from another client: %v", err) + } + sup.ClientID = SupportClientID + if _, err := OpenOffer(keys2(), signOffer(t, sup), offerNowMs); err != nil { + t.Errorf("support from Sourceful: %v", err) + } + // An offer that lives longer than ten minutes is refused. + long := vectorOffer() + long.ExpMs = offerNowMs + OfferTTLMs + 1 + if _, err := OpenOffer(keys2(), signOffer(t, long), offerNowMs); !errors.Is(err, ErrOfferExpired) { + t.Errorf("a long offer: %v", err) + } +} + +func keys2() map[string][]byte { + return map[string][]byte{OfferKid(vectorPub(offerKey())): vectorPub(offerKey())} +} + +func signOffer(t *testing.T, o GrantOffer) SignedOffer { + t.Helper() + raw, err := Marshal(o) + if err != nil { + t.Fatal(err) + } + return SignedOffer{O: raw, Kid: OfferKid(vectorPub(offerKey())), Sig: signRaw(t, offerKey(), mustOfferBytes(t, raw))} +} + +func mustOfferBytes(t *testing.T, o []byte) []byte { + t.Helper() + b, err := OfferBytes(o) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestConfirmBindsTheAgentTheOpAndTheArgs(t *testing.T) { + key := vectorKey() + pub := vectorPub(key) + f := vectorFor() + m := AgentConfirm{ReqID: vectorRequest().ReqID, Approve: true, AtMs: 1} + raw, err := ConfirmBytes(f, m, vectorRequest()) + if err != nil { + t.Fatal(err) + } + sig := signRaw(t, key, raw) + for name, edit := range map[string]func(*Request){ + "another agent": func(r *Request) { r.Agent = "someone" }, + "another op": func(r *Request) { r.Op = OpPlannerPrefsSet }, + "other args": func(r *Request) { r.Args = map[string]any{"mode": "idle"} }, + } { + req := vectorRequest() + edit(&req) + other, _ := ConfirmBytes(f, m, req) + if VerifyApproval(pub, other, sig) == nil { + t.Errorf("%s: the confirmation still verifies", name) + } + } +} + +func TestByNamesAPersonOrABot(t *testing.T) { + for by, want := range map[string]bool{ + "staff:anna.k": true, "bot:triage": true, "staff:": false, "admin:x": false, "anna": false, + "staff:a b": false, "staff:" + strings.Repeat("a", 60): false, + } { + if ValidBy(by) != want { + t.Errorf("%q: want %v", by, want) + } + } +} diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index a0e79a0b..82818c6c 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -33,10 +33,15 @@ var Fields = map[string][]string{ MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, - MsgRoster: {"rosterSeq", "ttlS", "devices", "grant", "pending"}, - "Device": {"id", "key", "role", "scopes", "epoch", "tun"}, - "Grant": {"id", "scopes", "purposes", "controller"}, - "Pending": {"hash", "ttlS"}, + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant", "pending", "noise"}, + "Device": {"id", "key", "role", "scopes", "epoch", "tun", "agent"}, + // An agent row's grant (docs/agents.md). The client's name and the + // ticket came from FTW Cloud's own consent page and support system. + "Agent": {"scopes", "ops", "caps", "expiresAtMs", "principal"}, + "Caps": {"holdS", "maxW", "perMin"}, + "Principal": {"client", "clientId", "ticket"}, + "Grant": {"id", "scopes", "purposes", "controller"}, + "Pending": {"hash", "ttlS"}, MsgBye: {"why", "purge"}, diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index ba4b7512..b9416ca0 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -274,6 +274,10 @@ type Roster struct { // first session through the service, for a phone the roster does not // list yet. Pending []Pending `cbor:"pending"` + // Noise is the box's X25519 Noise static key, the one in its pairing + // QR code. FTW Cloud's keys part opens an agent's session to it, and + // trusts it only as signed here, by the home's key. + Noise []byte `cbor:"noise"` } // Device is one paired phone. @@ -289,6 +293,21 @@ type Device struct { // Tun says the device may open a session to the box through the // service. It needs no grant: the readable copy is a purpose on top. Tun bool `cbor:"tun"` + // Agent is set on an agent row: an assistant, Sourceful support or the + // release check (docs/agents.md). Absent on a phone or a watch. + Agent *Agent `cbor:"agent,omitempty"` +} + +// Agent is an agent row's grant, as the box enforces it. The service must +// stop admitting the row at ExpiresAtMs by its own clock, never admit it +// through the relay, and may tighten the caps but never loosen them. +type Agent struct { + // Scopes and Ops are the grant at the approving owner's scopes now. + Scopes []string `cbor:"scopes"` + Ops []string `cbor:"ops"` + Caps protocol.Caps `cbor:"caps"` + ExpiresAtMs int64 `cbor:"expiresAtMs"` + Principal protocol.Principal `cbor:"principal"` } // Pending is one pairing code on the box's screen, by its PairingHash. diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 7a9dd780..22961460 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -66,11 +66,11 @@ "name": "roster", "signer": "box", "t": "roster", - "body": "a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104", - "signedInputSha256": "54a45d8ab064d1d9fda8dfd3d43708dca9294daefe67078b15ab17ae1c3c086a", - "sig": "26de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", - "envelope": "a361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", - "frame": "01010000021aa361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916" + "body": "aa637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "f47b850d31bfffb3f6352ab20c3ba7788cc95f0df22a38f1b8b44184a383399e", + "sig": "96a9ec9cde7b6b2b62bf55ed8d74aeaba0a8d0ded9e5ec32d1f868f758051c146e323ee9aca2c7a009b78190cde63c4b9c882ec29843f07fd15608ed9cf4dd11", + "envelope": "a361625901edaa637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584096a9ec9cde7b6b2b62bf55ed8d74aeaba0a8d0ded9e5ec32d1f868f758051c146e323ee9aca2c7a009b78190cde63c4b9c882ec29843f07fd15608ed9cf4dd11", + "frame": "010100000242a361625901edaa637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584096a9ec9cde7b6b2b62bf55ed8d74aeaba0a8d0ded9e5ec32d1f868f758051c146e323ee9aca2c7a009b78190cde63c4b9c882ec29843f07fd15608ed9cf4dd11" }, { "name": "live full", @@ -196,21 +196,21 @@ "name": "roster tunnel only", "signer": "box", "t": "roster", - "body": "a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105", - "signedInputSha256": "30ae9e4de78ae0f21421f8a7b172759f3b244da825c0072f2781cb49a73a907c", - "sig": "f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", - "envelope": "a36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", - "frame": "0101000001bda36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b" + "body": "aa637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105", + "signedInputSha256": "03e4ceaeb5d8abcbd48e0d4a30d53d4145df97c2986d8029e71b1823effd4d3c", + "sig": "729ecdf8ab1a41194cc3da99bf86d5e3593f9bb9e417e70512905e0a6c664f2bfa390316d794493370c9beb8da904e8a01f11af360503b1705cf463834502ac7", + "envelope": "a36162590190aa637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840729ecdf8ab1a41194cc3da99bf86d5e3593f9bb9e417e70512905e0a6c664f2bfa390316d794493370c9beb8da904e8a01f11af360503b1705cf463834502ac7", + "frame": "0101000001e5a36162590190aa637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840729ecdf8ab1a41194cc3da99bf86d5e3593f9bb9e417e70512905e0a6c664f2bfa390316d794493370c9beb8da904e8a01f11af360503b1705cf463834502ac7" }, { "name": "roster revoking", "signer": "box", "t": "roster", - "body": "a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106", - "signedInputSha256": "d8775b10218f6c69d9b7c9064d1cea8713058eaa9d91aeeba2e2f29419b7f9cc", - "sig": "4332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", - "envelope": "a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", - "frame": "010100000149a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f" + "body": "aa637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106", + "signedInputSha256": "322a030b3148bdb71722a8b7ddc7e54d4feff792bb06467125ea6a81d98e387c", + "sig": "c96e88f759f66bb580111e4d63cb45bb4b160d9cd17e211a394df3901b07c2e5317aab96aad398b1a1aca1827a68ba3fe7632ba3b12e886a9968d2c617b70d3c", + "envelope": "a3616259011daa637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f73746572637369675840c96e88f759f66bb580111e4d63cb45bb4b160d9cd17e211a394df3901b07c2e5317aab96aad398b1a1aca1827a68ba3fe7632ba3b12e886a9968d2c617b70d3c", + "frame": "010100000172a3616259011daa637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f73746572637369675840c96e88f759f66bb580111e4d63cb45bb4b160d9cd17e211a394df3901b07c2e5317aab96aad398b1a1aca1827a68ba3fe7632ba3b12e886a9968d2c617b70d3c" }, { "name": "purged copy", @@ -251,6 +251,16 @@ "sig": "", "envelope": "a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740", "frame": "010100000025a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740" + }, + { + "name": "roster with an agent", + "signer": "box", + "t": "roster", + "body": "aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107", + "signedInputSha256": "24ade7fd185b91e8c5c954c34805ebed51cbbf8d6e36097c7a903ebbfe5db7aa", + "sig": "f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7", + "envelope": "a36162590256aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107617466726f73746572637369675840f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7", + "frame": "0101000002aba36162590256aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107617466726f73746572637369675840f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 44837819..60e199a7 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -97,7 +97,7 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { Have: map[string]string{}, RosterSeq: 3, }}, {"roster", "box", MsgRoster, Roster{ - Header: h(2, 60_010), RosterSeq: 4, TTLS: 86_400, + Header: h(2, 60_010), RosterSeq: 4, TTLS: 86_400, Noise: bytes.Repeat([]byte{0x77}, 32), Devices: []Device{ {ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{protocol.ScopeLiveRead, protocol.ScopeHistoryRead, protocol.ScopePlanRead, @@ -177,7 +177,7 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { // The tunnel. A box linked at pairing, with the readable copy off: // two phones that may open a session, one QR code on the screen. {"roster tunnel only", "box", MsgRoster, Roster{ - Header: h(12, 74_000), RosterSeq: 5, TTLS: 86_400, + Header: h(12, 74_000), RosterSeq: 5, TTLS: 86_400, Noise: bytes.Repeat([]byte{0x77}, 32), Devices: []Device{ {ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{}, Epoch: 1, Tun: true}, {ID: "Zf09xQ2a", Key: bytes.Repeat([]byte{0x66}, 32), Role: "viewer", Scopes: []string{}, Epoch: 2, Tun: true}, @@ -188,7 +188,7 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { // A revoke that waits for its receipt: the grant keeps its id and // loses its scopes and purposes. {"roster revoking", "box", MsgRoster, Roster{ - Header: h(13, 75_000), RosterSeq: 6, TTLS: 86_400, + Header: h(13, 75_000), RosterSeq: 6, TTLS: 86_400, Noise: bytes.Repeat([]byte{0x77}, 32), Devices: []Device{ {ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{}, Epoch: 1, Tun: true}, }, @@ -200,6 +200,22 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"tun down", "", MsgTun, Tun{Dev: "q5Lw0Zb1", Sid: 7, D: bytes.Repeat([]byte{0x99}, 112)}}, {"tun up", "", MsgTun, Tun{Dev: "q5Lw0Zb1", Sid: 7, D: bytes.Repeat([]byte{0x9a}, 48)}}, {"tun end", "", MsgTun, Tun{Dev: "q5Lw0Zb1", Sid: 7, D: []byte{}}}, + // An agent row: no tun and no copy scopes, because only the keys + // part opens its session (docs/agents.md). + {"roster with an agent", "box", MsgRoster, Roster{ + Header: h(14, 76_000), RosterSeq: 7, TTLS: 86_400, Noise: bytes.Repeat([]byte{0x77}, 32), + Devices: []Device{ + {ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{}, Epoch: 1, Tun: true}, + {ID: "oKGio6Sl", Key: bytes.Repeat([]byte{0xa0}, 32), Role: protocol.RoleSupport, Scopes: []string{}, + Epoch: 1, Agent: &Agent{ + Scopes: []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead, protocol.ScopeLogsRead}, + Ops: []string{protocol.OpLoadpointHold}, Caps: protocol.Caps{HoldS: 900, MaxW: 7400, PerMin: 6}, + ExpiresAtMs: 1_793_000_000_000, + Principal: protocol.Principal{Client: "Sourceful support", ClientID: protocol.SupportClientID, Ticket: "FTW-123"}, + }}, + }, + Grant: Grant{Scopes: []string{}, Purposes: []string{}, Controller: ControllerSourceful}, + }}, } } diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 0dc9e9ef..02d6ee5f 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -127,6 +127,7 @@ const ( CapCloudLink = "cloud.link" CapCloudTun = "cloud.tun" CapCmdExchange = "cmd.exchange" + CapAccessAgents = "access.agents" ) // AllCapabilities is every capability name the registry defines. @@ -150,10 +151,11 @@ var AllCapabilities = []string{ CapCloudLink, CapCloudTun, CapCmdExchange, + CapAccessAgents, } -// Scopes are one object axis and two verb axes. Roles are a projection over -// these, never a replacement for them. +// Scopes are an object and a verb. Roles are a projection over these, +// never a replacement for them. const ( // ScopeLiveRead — Current readings and freshness. ScopeLiveRead = "ftw.live.read" @@ -175,15 +177,47 @@ const ( ScopeMembersRead = "ftw.members.read" // ScopeMembersWrite — Invite and revoke. ScopeMembersWrite = "ftw.members.write" + // ScopeLogsRead — Log excerpts the box redacts. + ScopeLogsRead = "ftw.logs.read" + // ScopeConfigRead — Configuration the box redacts. + ScopeConfigRead = "ftw.config.read" + // ScopeDiagRun — Checks that change nothing. + ScopeDiagRun = "ftw.diag.run" + // ScopeDispatchHold — Hold energy assets for a capped time. + ScopeDispatchHold = "ftw.dispatch.hold" + // ScopeGoalsRequest — Ask an owner to change a durable goal. + ScopeGoalsRequest = "ftw.goals.request" + // ScopeUpdateRequest — Ask an owner to install a version. + ScopeUpdateRequest = "ftw.update.request" ) -// WriteScopes is every scope that changes something. +// WriteScopes is every scope that changes something, now or once an +// owner confirms it. var WriteScopes = []string{ ScopeDispatchWrite, ScopeModeWrite, ScopeMembersWrite, + ScopeDispatchHold, + ScopeGoalsRequest, + ScopeUpdateRequest, } +// Roles, as the registry names them. apiauth holds what each carries. +const ( + // RoleAgent — Assistant. + RoleAgent = "agent" + // RoleOwner — Owner. + RoleOwner = "owner" + // RoleRelease — Release check. + RoleRelease = "release" + // RoleSupport — Sourceful support. + RoleSupport = "support" + // RoleViewer — Viewer. + RoleViewer = "viewer" + // RoleWatch — Watch. + RoleWatch = "watch" +) + // CloudScopes are the scopes an FTW Cloud grant may name. var CloudScopes = []string{ ScopeLiveRead, @@ -226,6 +260,74 @@ var RegistryOps = map[string]string{ "loadpoint.surplus_only.set": "ftw.dispatch.write", } +// RegistryAgentOps maps each op an agent may name to the scope that opens +// it. ftw.dispatch.hold runs as a cmd within the grant's caps; +// ftw.goals.request waits as an agent.request for an owner. An op not +// listed is never open to an agent. +var RegistryAgentOps = map[string]string{ + "site.mode.set": "ftw.goals.request", + "planner.prefs.set": "ftw.goals.request", + "loadpoint.hold": "ftw.dispatch.hold", + "loadpoint.soc.set": "ftw.goals.request", + "loadpoint.surplus_only.set": "ftw.goals.request", +} + +// RegistryAgentArgs is what an agent may put in each op it can name. The +// box refuses any other key, kind or range, in a cmd and in an +// agent.request, as it arrives. +var RegistryAgentArgs = map[string][]AgentArg{ + "loadpoint.hold": { + {Name: "id", Kind: "loadpoint", Unit: "", Required: true, Cap: "", Label: "Charger"}, + {Name: "power_w", Kind: "power", Unit: "W", Min: bound(0), Required: false, Cap: "maxW", Label: "Charging power"}, + {Name: "hold_s", Kind: "duration", Unit: "s", Min: bound(1), Required: false, Cap: "holdS", Label: "For how long"}, + {Name: "phase_mode", Kind: "enum", Unit: "", Values: []string{"auto", "1p", "3p"}, Required: false, Cap: "", Label: "Phases"}, + {Name: "clear", Kind: "bool", Unit: "", Required: false, Cap: "", Label: "Stop the agent's own hold"}, + }, + "loadpoint.soc.set": { + {Name: "id", Kind: "loadpoint", Unit: "", Required: true, Cap: "", Label: "Charger"}, + {Name: "soc", Kind: "ratio", Unit: "", Min: bound(0), Max: bound(1), Required: true, Cap: "", Label: "The car's charge level"}, + }, + "loadpoint.surplus_only.set": { + {Name: "id", Kind: "loadpoint", Unit: "", Required: true, Cap: "", Label: "Charger"}, + {Name: "surplus_only", Kind: "bool", Unit: "", Required: true, Cap: "", Label: "Charge from solar only"}, + }, + "planner.prefs.set": { + {Name: "safety_k", Kind: "number", Unit: "", Min: bound(0), Max: bound(2), Required: false, Cap: "", Label: "How cautious the plan is"}, + {Name: "battery_export", Kind: "enum", Unit: "", Values: []string{"unknown", "not_allowed", "allowed"}, Required: false, Cap: "", Label: "Whether the battery may sell to the grid"}, + }, + "site.mode.set": { + {Name: "mode", Kind: "mode", Unit: "", Required: true, Cap: "", Label: "Operating mode"}, + }, +} + +// RegistryReads maps each read an agent may send to the scope it needs. +var RegistryReads = map[string]string{ + // live.get — Readings and freshness now. + "live.get": "ftw.live.read", + // loadpoints.get — Each charger and the car on it. + "loadpoints.get": "ftw.live.read", + // cars.get — Each car's level and goal. + "cars.get": "ftw.live.read", + // plan.get — The plan and its reasons. + "plan.get": "ftw.plan.read", + // price.get — Electricity prices. + "price.get": "ftw.plan.read", + // hist.query — Stored history. + "hist.query": "ftw.history.read", + // health.get — The health summary. + "health.get": "ftw.health.read", + // drivers.get — Each driver's state. + "drivers.get": "ftw.health.read", + // events.get — Recent events. + "events.get": "ftw.events.read", + // logs.get — A log excerpt the box redacts. + "logs.get": "ftw.logs.read", + // config.get — A configuration section the box redacts. + "config.get": "ftw.config.read", + // diag.run — A check that changes nothing. + "diag.run": "ftw.diag.run", +} + // Error codes. The box sends the code and machine-readable args; the app // owns every word of prose, in every language it ships. const ( @@ -259,6 +361,24 @@ const ( ErrWholeDocument = "E_WHOLE_DOCUMENT" // ErrLocalOnly — Route is served only on the box's own page. ErrLocalOnly = "E_LOCAL_ONLY" + // ErrGrantExpired — Access ran out at its expiry. + ErrGrantExpired = "E_GRANT_EXPIRED" + // ErrOpNotGranted — The grant does not list this operation. + ErrOpNotGranted = "E_OP_NOT_GRANTED" + // ErrCapExceeded — An argument or a count is past its cap. + ErrCapExceeded = "E_CAP_EXCEEDED" + // ErrRateLimited — Too many commands for the grant's rate. + ErrRateLimited = "E_RATE_LIMITED" + // ErrNeedsOwner — A durable change waits for an owner as agent.request. + ErrNeedsOwner = "E_NEEDS_OWNER" + // ErrBadSignature — The approval signature is missing or does not verify. + ErrBadSignature = "E_BAD_SIGNATURE" + // ErrNoApprovalKey — This phone has no approval key on the box. + ErrNoApprovalKey = "E_NO_APPROVAL_KEY" + // ErrNotFound — Nothing by that id or name. + ErrNotFound = "E_NOT_FOUND" + // ErrClockSkew — The approval's time is too far from the box's. + ErrClockSkew = "E_CLOCK_SKEW" ) // ErrorRetryable reports whether the app should offer a retry for a code. @@ -278,6 +398,15 @@ var ErrorRetryable = map[string]bool{ ErrUnsupportedMedia: false, ErrWholeDocument: false, ErrLocalOnly: false, + ErrGrantExpired: false, + ErrOpNotGranted: false, + ErrCapExceeded: false, + ErrRateLimited: true, + ErrNeedsOwner: false, + ErrBadSignature: false, + ErrNoApprovalKey: false, + ErrNotFound: false, + ErrClockSkew: true, } // SourceState says whether a device is answering. It is orthogonal to the diff --git a/go/protocol/envelope.go b/go/protocol/envelope.go index 922e9957..08321e5a 100644 --- a/go/protocol/envelope.go +++ b/go/protocol/envelope.go @@ -65,6 +65,10 @@ type Envelope struct { // B is the body, left encoded so the handler can unmarshal it into the // type the message name implies. B cbor.RawMessage `cbor:"b,omitempty"` + // By names the person behind a call from a Sourceful support session: + // the staff member's id, at most ByMax bytes. FTW Cloud asserts it and + // the box writes it to its audit log. Absent on every other session. + By string `cbor:"by,omitempty"` } // Frame is one envelope on one lane. Package appwire owns the byte layout, the diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index bb6a93f2..80cc2418 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -18,6 +18,11 @@ const ( PushBoxUnreachable = "box.unreachable" PushCloudGranted = "cloud.granted" PushCloudRevoked = "cloud.revoked" + PushAgentAdded = "agent.added" + PushAgentRequest = "agent.request" + PushAgentExpiring = "agent.expiring" + PushApprovalKeySet = "approval.key.set" + PushOwnerAdded = "owner.added" ) // PushSentence is one lock-screen sentence, exactly as the app wrote it. @@ -39,4 +44,9 @@ var PushSentences = map[string]PushSentence{ PushBoxUnreachable: {Title: "Your box is out of reach", Body: "It stopped answering. Power cut, or the internet at home is down."}, PushCloudGranted: {Title: "FTW Cloud is on", Body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."}, PushCloudRevoked: {Title: "FTW Cloud is off", Body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."}, + PushAgentAdded: {Title: "New access to your box", Body: "You gave an assistant or Sourceful access. See who has access in the app."}, + PushAgentRequest: {Title: "Waiting for your answer", Body: "An assistant asks to change a setting. Open FTW to confirm or decline."}, + PushAgentExpiring: {Title: "Access ends tomorrow", Body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on."}, + PushApprovalKeySet: {Title: "A phone can now approve access", Body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app."}, + PushOwnerAdded: {Title: "A new owner", Body: "A phone was paired as an owner of your box. If that was not you, remove it in the app."}, } diff --git a/go/protocol/testdata/approval-vectors.json b/go/protocol/testdata/approval-vectors.json new file mode 100644 index 00000000..74caff7a --- /dev/null +++ b/go/protocol/testdata/approval-vectors.json @@ -0,0 +1,54 @@ +{ + "note": "docs/agents.md, The approval signature. signedHex is what the phone signs; sigHex is ECDSA P-256 over SHA-256 of it, r||s.", + "pubHex": "0494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c", + "boxKeyHex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "deviceId": "ownerPh1", + "cases": [ + { + "what": "approval.key.set", + "bodyHex": "a36370756258410494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c637369675840572e0e9343114001fe0e08e2b6803944572a07ce868ba32ab5e50ea534287d2688df2d87def4b16f612ded77f2ca199591efe6a05c9b9cf50cb9e08a7fe40a6a6461744d731b000001a13b860000", + "signedHex": "866f6674772e617070726f76616c2e763170617070726f76616c2e6b65792e73657458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b86000058410494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c", + "sha256Hex": "c1b1e8e41f10d4fb9bf01e62f679c7dab38f157beafc9206732cd50f9ae4e19c", + "sigHex": "572e0e9343114001fe0e08e2b6803944572a07ce868ba32ab5e50ea534287d2688df2d87def4b16f612ded77f2ca199591efe6a05c9b9cf50cb9e08a7fe40a6a" + }, + { + "what": "agent.add", + "bodyHex": "a9636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073816e6c6f6164706f696e742e686f6c646373696758403747ea567ad51a14821b73bb13cfd10ba9a2b6ba22b5018f36fe989ba8a7ec10a9bf0ed986f3d54d3890972d7224ffa6f8037558fd355b02419cc953a1f8fa636461744d731b000001a13b8600006463617073a3646d617857192af865686f6c6453190384667065724d696e0664726f6c65656167656e746673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e706c616e2e72656164697072696e636970616ca366636c69656e7466436c61756465667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d657461646174616b6578706972657341744d731b000001a1d604c800", + "signedHex": "8e6f6674772e617070726f76616c2e7631696167656e742e61646458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600005820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf656167656e7483716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e706c616e2e72656164816e6c6f6164706f696e742e686f6c6483190384192af8061b000001a1d604c80066436c61756465782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d6574616461746160", + "sha256Hex": "638de060149662d97f7ee71bd30c3d6a76e010171ed21fb874c9d3ea589bfc74", + "sigHex": "3747ea567ad51a14821b73bb13cfd10ba9a2b6ba22b5018f36fe989ba8a7ec10a9bf0ed986f3d54d3890972d7224ffa6f8037558fd355b02419cc953a1f8fa63" + }, + { + "what": "agent.add", + "bodyHex": "a9636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073f663736967584029023e8a6d2fc4fa27f32cffaf314f65666d5acf45aa8d20b89a32ea9de75e6cb6aaecf84ce2736bda5c7f19849406d8f02c1ed1ef9d79e9965f0cb3fc59b50e6461744d731b000001a13b8600006463617073a3646d6178570065686f6c645300667065724d696e0064726f6c6567737570706f72746673636f706573826f6674772e6865616c74682e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964606b6578706972657341744d731b000001a1839f0800", + "signedHex": "8e6f6674772e617070726f76616c2e7631696167656e742e61646458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600005820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf67737570706f7274826f6674772e6865616c74682e726561646d6674772e6c6f67732e7265616480830000001b000001a1839f080071536f7572636566756c20737570706f727460674654572d313233", + "sha256Hex": "cb6c7351948afb36746cc96b2cbd4111bc19bfbad7f361a743ca6a0615a812eb", + "sigHex": "29023e8a6d2fc4fa27f32cffaf314f65666d5acf45aa8d20b89a32ea9de75e6cb6aaecf84ce2736bda5c7f19849406d8f02c1ed1ef9d79e9965f0cb3fc59b50e" + }, + { + "what": "agent.widen", + "bodyHex": "a8626964686f4b47696f36536c636f7073f663736967584007a9d60a120b6fd2e4009024dc71a463404a5696db357e0eec9de0364bd97eb72339cb0784afca9572a662d2f807b504c5f801c56f9851e36fd937223d92260e6461744d731b000001a13b8600006463617073a3646d6178570065686f6c645300667065724d696e066565706f6368026673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e726561646b6578706972657341744d731b000001a1d604c800", + "contextHex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "signedHex": "8c6f6674772e617070726f76616c2e76316b6167656e742e776964656e58200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b860000686f4b47696f36536c5820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf02826d6674772e6c6976652e726561646d6674772e706c616e2e7265616480830000061b000001a1d604c800", + "sha256Hex": "f4c10ed374e6d4d814fa45bce11232c5644e2d398d9ccf80cc7125bb9f851ffe", + "sigHex": "07a9d60a120b6fd2e4009024dc71a463404a5696db357e0eec9de0364bd97eb72339cb0784afca9572a662d2f807b504c5f801c56f9851e36fd937223d92260e" + }, + { + "what": "agent.confirm", + "bodyHex": "a463736967584096b5a9c1c2280fd23e59d72a6a976831afff61db35367146436f5d1b92f94de3ad4aace2fdc82787a61e52b1fdf676895c2787d8448360febff79e3db7e27ef66461744d731b000001a13b8600006572657149647641414543417751464267634943516f4c4441304f447767617070726f7665f5", + "contextHex": "a8626f706d736974652e6d6f64652e736574637768796f4368656170657220746f6e696768746461726773a1646d6f64657073656c665f636f6e73756d7074696f6e6461744d731b000001a13b860000656167656e74686f4b47696f36536c6572657149647641414543417751464267634943516f4c4441304f44776573746174656770656e64696e676b6578706972657341744d731b000001a13e192e00", + "signedHex": "896f6674772e617070726f76616c2e76316d6167656e742e636f6e6669726d58200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600007641414543417751464267634943516f4c4441304f4477686f4b47696f36536c6d736974652e6d6f64652e7365745820a2149767a52b4bd6163c900c759658ec707ff59838a9898538d2c0e3fe79cefe", + "sha256Hex": "81a8677e665648b6f96454b19dee4c5b2a728d6c0a12b346ee18b9315c02133e", + "sigHex": "96b5a9c1c2280fd23e59d72a6a976831afff61db35367146436f5d1b92f94de3ad4aace2fdc82787a61e52b1fdf676895c2787d8448360febff79e3db7e27ef6" + } + ], + "offer": { + "note": "docs/agents.md, The grant offer. The link fragment is base64url of signedOfferHex.", + "kid": "yvJTP04C98wsViEa", + "pubHex": "047b0b548a5dba713577235bdfc95cb2de5231c2cf856adf6112f7a248617f2fb4236ea44a16d109c9f265569c1a735b766437fae63a6afcee3da61dafd3169042", + "nowMs": 1792000000000, + "signedOfferHex": "a3616f590110af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169636b69647079764a545030344339387773566945616373696758407ab7ea5341bd741de5d72129948cea0171de7bb2ce88232f15834401ee771e54673f6025cc5876a330b45e7d4bf3671bada1c0416c1ddc347e42087048669269", + "oHex": "af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169", + "signedBytesHex": "826c6674772e6f666665722e7631590110af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169" + } +}