Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-shared-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Share MCP access with a single-use code. Choose rights and expiry, let the recipient connect with its own key, and cancel invitations or remove access from the box page or app.
2 changes: 2 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ capabilities:
# answers the agent.* messages and approval.key.set (docs/agents.md in
# srcfl/ftw). Absent means the app offers no Access list.
- access.agents
# Owner-created codes for one agent with its own rights and expiry.
- access.invites
# The box keeps cars apart from chargers: each car has its own level, goal
# and sources, and a charger names the car on it (docs/cars.md in
# srcfl/ftw). The box answers the car ops and session.car.set. Absent
Expand Down
69 changes: 69 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,75 @@ because removing access is safe. With `api.lan_auth` on, every route here,
the reads and the narrowing ones too, needs the sign-in, as every protected
route on the box does.

## Agent invitations

A box with `access.invites` supports `agent.invite.create`,
`agent.invite.list` and `agent.invite.cancel`. These messages require a current
owner phone. Creating also requires its P-256 approval signature. An agent
cannot create invitations, including with `ftw.setup.write`.

The owner chooses scopes, operations, caps, a label, a claim deadline within
24 hours and a separate access expiry within the agent role's longest grant.
The label helps the owner find the invitation; it does not verify a person's
identity. A code grants the first recipient who uses it a separate agent key,
never the owner's token, key or role. Technical diagnostics include complete
backups, which can contain saved credentials. Setup and control require an
explicit choice.

Create carries `{home, id, hash, label, scopes, ops, caps, claimUntilMs,
expiresAtMs, atMs, sig}`. The client draws a 16-byte random `id` and a 32-byte
random secret. It signs these fields in that order after the normal approval
prefix, with caps as `[holdS, maxW, perMin]` and sorted scopes and operations.
The hash and Noise claim format are in [the Cloud protocol](protocol-cloud.md#agent-invitations-through-the-service).
The strong publishing proof is base64url of deterministic CBOR `{v: 1, home, box, secret}`.
Core stores only the verifier, not the secret. The LAN page may send the
secret in its protected POST so Core can compute the verifier on plain LAN
HTTP, where browser hashing APIs may be unavailable. It never logs it.

An exact create retry returns the existing receipt, including claimed,
cancelled or expired states. A changed request with the same id conflicts.
Clients keep the exact signed request and secret in memory while the code is
on screen. A lost reply never starts a fresh invitation. A list contains
metadata and claim receipts, never a code. When the code is no longer held
locally, the owner can cancel the old invitation and create a new one.
The app list also returns `nowMs` and `clockReady`. Read the box's time before
signing a new invitation so phone clock differences cannot exceed the claim
or grant limits. A known signature refusal may retry the same fields with
the phone's replacement key; an unknown outcome must keep the exact request.

Cancelling needs no new signature, like revoking an agent, and returns the
invitation's current state. If claim already won, it returns `claimed` with
the agent id; the owner removes that ordinary connection instead. A clock
reset cancels pending invitations as well as ending existing agent grants.
Core admits at most 16 pending invitations. It retains live claim windows and
receipts for current agents, plus 32 old receipts, with a 128-row storage
bound. No expired or cancelled record can reopen a valid code after pruning.

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

The owner views publish that proof through TLS in JSON to
`POST https://cloud.ftw.energy/oauth/invitations`, without cookies. Cloud
checks its home, box and verifier against the current signed roster before
returning a unique six-character code such as `K7M-4PX`. The code works for
at most ten minutes, within Core's claim deadline. The granted access can
last longer. The normal owner views use a ten-minute Core claim window.

Cloud holds the code-to-proof mapping only in memory. Expiry stops use at
once; its normal minute sweep drops expired mappings. SQLite keeps only a
keyed digest reservation and expiry, with its MAC key outside that database,
so a restart cannot give an unexpired code to another home. After a Cloud
restart the owner can check status and republish the same strong proof to
get a working code, without another Core grant. A retry before restart gets
the same code. No secret enters a URL or log. This short-code service
currently runs on the single OAuth instance; multiple instances require
routing to the instance holding that mapping.

## The grant offer

The MCP consent page shows a short code, a QR code and a universal link. The
Expand Down
40 changes: 37 additions & 3 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ The service never forwards these to a phone.
| `ttlS` | uint | 86,400. Counted from when the service received the roster |
| `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 |
| `pending` | [Pending] | One phone QR code and up to 16 agent invitations |
| `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 |

| `webpush` | [WebPushRow], optional | Box-encrypted browser offline notices. Service only; no browser key leaves the box |
Expand Down Expand Up @@ -744,10 +744,12 @@ each time it receives such a roster. With no id, there is no copy.

| Field | Type | Meaning |
|---|---|---|
| `hash` | bstr, 32 B | SHA-256(`ftw.cloud.pair.v1\0` ‖ home id ‖ pairing code) |
| `hash` | bstr, 32 B | Phone pairing hash below, or the agent invitation verifier below |
| `kind` | tstr, optional | Absent for a phone; `agent` admits only the pending-agent invitation path |
| `ttlS` | uint | Seconds the code has left, counted from when the service received the roster |

Only a QR code is listed. A spoken code has 40 bits, and its hash would let
For a phone, the hash is SHA-256(`ftw.cloud.pair.v1\0` ‖ home id ‖ pairing code).
Only a phone QR code is listed. A spoken code has 40 bits, and its hash would let
the service find it offline; it pairs over the relay or the home network.

A new iPhone may exchange a short code directly with the box on the home
Expand Down Expand Up @@ -1123,6 +1125,37 @@ On the app session the phone and the service send `tun {sid, d}`, with no
through the tunnel costs the box up to four X25519 operations, so the box
answers at most 12 a minute per device and 240 in all.

### Agent invitations through the service

An owner creates a single-use agent invitation through the signed app session
or the password-protected box page. The roster carries its verifier with
`kind: "agent"`, for at most 24 hours. The verifier is
SHA-256(`ftw.agent.invite.v1\0` ‖ home id ‖ `\0` ‖ 32 random secret bytes).
No code, label or recipient name enters the roster.

This entry never admits a phone through `/v1/app`. OAuth gives each recipient
its own pending key in `ftwkeysd`. `store.For(AgentInvite)` opens only a tunnel,
within the agent-session quota, with no access to the readable copy. Opening
or losing this tunnel does not spend the verifier. The keys service's separate
`open.invite` reads only pending keys and accepts only a typed claim with its
own signed offer for that key. Ordinary `open` still needs a kept key.

Noise message 1 carries `ftw.agent.invite.v1\0` followed by strict CBOR
`{secret, offer: {o, kid, sig}}`. Core checks the code, current owner, expiry,
offer signature and the Noise static key, then checks them again after the
first transport frame proves the key. It atomically saves the consumed
invitation and the ordinary agent row. Rights and limits intersect the
owner's invitation and the recipient's request. A retry must match that
invitation, key and signed offer; it cannot revive a revoked agent.

Core reads its devices and pending entries in one snapshot. Its next signed
roster removes the invitation hint and lists the new agent. Only this roster
allows OAuth to keep the key and issue its normal PKCE authorization code.
The code's claim deadline and the agent's access expiry are separate. The
box must be online to claim a code. The owner-facing six-character code is a
short-lived Cloud alias for the strong proof; it never enters a roster or
replaces the Core verifier. See [Agent invitations](agents.md#agent-invitations).

### Pairing through the service

A new phone is in no roster, so the service would refuse it. While the box
Expand Down Expand Up @@ -1625,6 +1658,7 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand.
| `live` while watched | On each poll that changed it, at most once per period |
| Box handshakes through the tunnel | 12 a minute per device, 240 in all |
| Pending pairing entry | One QR code, for its own ten minutes; admits one handshake |
| Pending agent invitations | At most 16; claim window at most 24 hours; Core consumes each code after Noise proof |
| Box session up after TLS | One round trip: its message 1 rides in `now.tun` |
| Sessions | 16 per home; one per device |
| Envelopes queued per phone | 256 |
Expand Down
24 changes: 24 additions & 0 deletions go/cmd/ftw/agent_invites.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package main

import (
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/protocol"
)

func (b boxPageAgents) AgentInvites() []protocol.AgentInvite { return b.p.access.Enroll.AgentInvites() }

func (b boxPageAgents) CreateAgentInvite(by string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) {
v, err := b.p.access.Enroll.CreateAgentInvite(appenroll.AtTheBoxPage, m)
b.record(by, protocol.MsgAgentInviteCreate, m.ID, err)
return v, err
}

func (b boxPageAgents) CancelAgentInvite(by, id string) (protocol.AgentInvite, error) {
v, err := b.p.access.Enroll.CancelAgentInvite(id)
b.record(by, protocol.MsgAgentInviteCancel, id, err)
return v, err
}

func (b boxPageAgents) PrepareAgentInviteHome() (string, error) {
return b.p.access.Enroll.PrepareAgentInviteHome()
}
9 changes: 9 additions & 0 deletions go/cmd/ftw/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ func (b boxPageAgents) ResetClock(by string) error {
b.record(by, "agents.clock.reset", "", err)
return err
}
if err := b.p.access.Enroll.CancelAllAgentInvites(); err != nil {
return err
}
for _, row := range b.p.access.Agents() {
if err := b.RevokeAgent(by, row.ID); err != nil {
return err
Expand Down Expand Up @@ -612,5 +615,11 @@ func agentsForAPI(p *agentPorts) api.AgentAdmin {
if p == nil || p.access == nil {
return nil
}
p.access.Enroll.SetAgentOfferKeys(cloudlink.ParseOfferKeys(cloudlink.OfferKeys))
p.access.Enroll.OnAgentInviteClaimed(func(v protocol.AgentInvite, row protocol.AgentRow) {
b := boxPageAgents{p: p}
b.record("invitation", protocol.MsgAgentAdd, row.ID, nil)
p.push(protocol.PushAgentAdded, row.Principal.Client)
})
return boxPageAgents{p: p}
}
2 changes: 1 addition & 1 deletion go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ func startAppLink(
if st != nil {
agents.audit = newStateAudit(ctx, st)
}
caps = append(caps, appproto.CapAccessAgents)
caps = append(caps, appproto.CapAccessAgents, appproto.CapAccessInvites)
// One view for every agent session, so a charger's plug-in count is
// the same whichever session reads it.
views := agentViews{site: &cloudSite{site: site, api: gateway, cars: carsRT}, cars: carsRT}
Expand Down
1 change: 1 addition & 0 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ func startCloudLink(
// A code shown links the box at once, so a QR that names the home can.
enroll.OnPairingCode(link.RosterChanged)
enroll.SetHome(link.Home)
enroll.SetAgentInviteHome(link.PrepareAgentInviteHome)
go func() {
if err := link.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("FTW Cloud uplink stopped", "err", err)
Expand Down
1 change: 1 addition & 0 deletions go/internal/api/api_agent_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var agentLocalReads = map[string]bool{
"/api/ocpp/chargers": true,
"/api/backups/{id}": true,
"/api/agents": true,
"/api/agent-invites": true,
"/api/agents/audit": true,
}

Expand Down
86 changes: 86 additions & 0 deletions go/internal/api/api_agent_invites.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package api

import (
"encoding/json"
"github.com/srcfl/ftw/go/internal/apiauth"
"io"
"net/http"
"slices"

"github.com/srcfl/ftw/go/protocol"
)

type AgentInviteAdmin interface {
PrepareAgentInviteHome() (string, error)
AgentInvites() []protocol.AgentInvite
CreateAgentInvite(by string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error)
CancelAgentInvite(by, id string) (protocol.AgentInvite, error)
}

func (s *Server) agentInviteRoutes() {
s.handle("GET /api/agent-invites", Local, s.handleAgentInvites)
s.handle("POST /api/agent-invites", Local, s.handleAgentInvites)
s.handle("DELETE /api/agent-invites/{id}", Local, s.handleAgentInvites)
}

func (s *Server) handleAgentInvites(w http.ResponseWriter, r *http.Request) {
by, ok := s.agentsGate(w, r, r.Method == http.MethodPost)
if !ok {
return
}
admin, ok := s.deps.Agents.(AgentInviteAdmin)
if !ok {
writeAppLinkError(w, http.StatusServiceUnavailable, "update FTW to share access")
return
}
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, map[string]any{"invitations": admin.AgentInvites(), "home": s.deps.Agents.Home(), "scopes": apiauth.RoleScopes[apiauth.RoleAgent], "ops": slices.Sorted(slices.Values(inviteOps())), "caps": protocol.Caps{HoldS: protocol.HoldSMax, MaxW: protocol.MaxWMax, PerMin: protocol.PerMinDefault}})
return
}
var v protocol.AgentInvite
var err error
if r.Method == http.MethodPost {
var body struct {
protocol.AgentInviteCreate
Secret []byte `json:"secret,omitempty"`
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
d.DisallowUnknownFields()
if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF {
writeAppLinkError(w, http.StatusBadRequest, "invalid invitation fields")
return
}
m := body.AgentInviteCreate
if len(body.Secret) > 0 {
if len(body.Secret) != protocol.AgentInviteSecretBytes || len(m.Hash) > 0 {
writeAppLinkError(w, http.StatusBadRequest, "send one invitation verifier")
return
}
// Reserve even an existing home against concurrent idle teardown.
// The browser publishes the home returned in the saved receipt.
var err error
m.Home, err = admin.PrepareAgentInviteHome()
if err != nil {
agentError(w, err)
return
}
m.Hash = protocol.AgentInviteHash(m.Home, body.Secret)
}
v, err = admin.CreateAgentInvite(by, m)
} else {
v, err = admin.CancelAgentInvite(by, r.PathValue("id"))
}
if err != nil {
agentError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"invitation": v})
}

func inviteOps() []string {
var out []string
for op := range protocol.RegistryAgentOps {
out = append(out, op)
}
return out
}
Loading
Loading