From 499d5de6510439651fc295007dc85543217fe221 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:09:15 +0200 Subject: [PATCH 1/2] Share assistant access with single-use invitations and short codes --- .changeset/mcp-shared-access.md | 5 + contract/registry.yaml | 2 + docs/agents.md | 69 +++ docs/protocol-cloud.md | 40 +- go/cmd/ftw/agent_invites.go | 24 ++ go/cmd/ftw/agents.go | 9 + go/cmd/ftw/app_link.go | 2 +- go/cmd/ftw/cloud_link.go | 1 + go/internal/api/api_agent_catalog.go | 1 + go/internal/api/api_agent_invites.go | 86 ++++ go/internal/api/api_agent_invites_test.go | 64 +++ go/internal/api/api_agent_reads.go | 10 + go/internal/api/api_agents.go | 1 + go/internal/api/security.go | 1 + go/internal/appenroll/agent_invites.go | 405 ++++++++++++++++++ go/internal/appenroll/agent_invites_test.go | 284 ++++++++++++ go/internal/appenroll/cloud_snapshot.go | 39 ++ go/internal/appenroll/enroll.go | 46 +- go/internal/appproto/agent_invites.go | 43 ++ go/internal/appproto/handler.go | 2 + go/internal/appproto/protocol.go | 1 + go/internal/appuplink/agents.go | 8 + go/internal/appuplink/invite_test.go | 62 +++ go/internal/appuplink/roster.go | 22 +- go/internal/cloudlink/invite_test.go | 49 +++ go/internal/cloudlink/link.go | 65 ++- go/internal/cloudlink/sender.go | 26 +- go/protocol/agent_invites.go | 147 +++++++ go/protocol/agent_invites_test.go | 45 ++ go/protocol/cloudwire/allowlist.go | 2 +- go/protocol/cloudwire/messages.go | 9 +- go/protocol/contract_gen.go | 2 + go/protocol/testdata/invitation-vectors.json | 29 ++ web/index.html | 2 +- .../tabs/access-invitations-flow.test.mjs | 109 +++++ web/settings/tabs/access.js | 166 ++++++- web/style.css | 15 + 37 files changed, 1849 insertions(+), 44 deletions(-) create mode 100644 .changeset/mcp-shared-access.md create mode 100644 go/cmd/ftw/agent_invites.go create mode 100644 go/internal/api/api_agent_invites.go create mode 100644 go/internal/api/api_agent_invites_test.go create mode 100644 go/internal/appenroll/agent_invites.go create mode 100644 go/internal/appenroll/agent_invites_test.go create mode 100644 go/internal/appenroll/cloud_snapshot.go create mode 100644 go/internal/appproto/agent_invites.go create mode 100644 go/internal/appuplink/invite_test.go create mode 100644 go/internal/cloudlink/invite_test.go create mode 100644 go/protocol/agent_invites.go create mode 100644 go/protocol/agent_invites_test.go create mode 100644 go/protocol/testdata/invitation-vectors.json create mode 100644 web/settings/tabs/access-invitations-flow.test.mjs diff --git a/.changeset/mcp-shared-access.md b/.changeset/mcp-shared-access.md new file mode 100644 index 000000000..71a311003 --- /dev/null +++ b/.changeset/mcp-shared-access.md @@ -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. diff --git a/contract/registry.yaml b/contract/registry.yaml index b8c70b460..a5fff570e 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -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 diff --git a/docs/agents.md b/docs/agents.md index b6def1c45..37d982f9d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 14da29c6e..d5330cd9d 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -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 | @@ -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 @@ -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 @@ -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 | diff --git a/go/cmd/ftw/agent_invites.go b/go/cmd/ftw/agent_invites.go new file mode 100644 index 000000000..dede4125a --- /dev/null +++ b/go/cmd/ftw/agent_invites.go @@ -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() +} diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index 3b692ba35..494cdf86b 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -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 @@ -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} } diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 87d6bc3a4..7a346bf3e 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -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} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index b2836a102..d9a133573 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -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) diff --git a/go/internal/api/api_agent_catalog.go b/go/internal/api/api_agent_catalog.go index f654143ac..2c44c2685 100644 --- a/go/internal/api/api_agent_catalog.go +++ b/go/internal/api/api_agent_catalog.go @@ -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, } diff --git a/go/internal/api/api_agent_invites.go b/go/internal/api/api_agent_invites.go new file mode 100644 index 000000000..bc3c0ae82 --- /dev/null +++ b/go/internal/api/api_agent_invites.go @@ -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 + } + if m.Home == "" { + 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 +} diff --git a/go/internal/api/api_agent_invites_test.go b/go/internal/api/api_agent_invites_test.go new file mode 100644 index 000000000..5d94bccff --- /dev/null +++ b/go/internal/api/api_agent_invites_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +type inviteAdminStub struct { + *stubAgents + created []protocol.AgentInviteCreate +} + +func (a *inviteAdminStub) AgentInvites() []protocol.AgentInvite { return []protocol.AgentInvite{} } +func (a *inviteAdminStub) PrepareAgentInviteHome() (string, error) { return a.home, nil } +func (a *inviteAdminStub) CreateAgentInvite(_ string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) { + a.created = append(a.created, m) + return protocol.AgentInvite{ID: m.ID, Home: m.Home, State: "pending"}, nil +} +func (a *inviteAdminStub) CancelAgentInvite(_, id string) (protocol.AgentInvite, error) { + return protocol.AgentInvite{ID: id, State: "claimed", AgentID: "claimed1"}, nil +} + +func TestInvitationCreationUsesTheSameOwnerGateAndNeverStoresTheBrowserSecret(t *testing.T) { + secret := bytes.Repeat([]byte{0x72}, 32) + body, _ := json.Marshal(map[string]any{"id": "request1", "home": "", "secret": base64.StdEncoding.EncodeToString(secret)}) + for _, kind := range []string{"owner", "lan", "box", "session"} { + t.Run(kind, func(t *testing.T) { + a := &inviteAdminStub{stubAgents: newStubAgents("abcdefghijklmnopqrstuvwx23")} + s := New(&Deps{Agents: a}) + r := localRequest(http.MethodPost, "/api/agent-invites", string(body)) + switch kind { + case "owner": + r = r.WithContext(withLANSecret(r.Context(), true)) + case "box": + r = loopbackRequest(http.MethodPost, "/api/agent-invites", string(body)) + case "session": + r = sessionRequest(http.MethodPost, "/api/agent-invites", string(body)) + } + w := httptest.NewRecorder() + s.handleAgentInvites(w, r) + if kind != "owner" { + if w.Code != http.StatusForbidden || len(a.created) != 0 { + t.Fatalf("non-owner created invitation: %d", w.Code) + } + return + } + if w.Code != 200 || len(a.created) != 1 { + t.Fatalf("owner request: %d %s", w.Code, w.Body.String()) + } + if !bytes.Equal(a.created[0].Hash, protocol.AgentInviteHash(a.home, secret)) { + t.Fatal("wrong home verifier") + } + if bytes.Contains(w.Body.Bytes(), []byte(base64.StdEncoding.EncodeToString(secret))) { + t.Fatal("API echoed bearer secret") + } + }) + } +} diff --git a/go/internal/api/api_agent_reads.go b/go/internal/api/api_agent_reads.go index d96e59763..198863b39 100644 --- a/go/internal/api/api_agent_reads.go +++ b/go/internal/api/api_agent_reads.go @@ -103,6 +103,16 @@ func (s *Server) ServeAgentRead(w http.ResponseWriter, request *http.Request, re // remote caller is on the LAN or is an owner, and without admitting mutations. func (s *Server) serveAgentHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/api/agent-invites": + if s.deps == nil || s.deps.Agents == nil { + writeJSON(w, 503, map[string]string{"error": "agents unavailable"}) + return + } + if a, ok := s.deps.Agents.(AgentInviteAdmin); ok { + writeJSON(w, 200, map[string]any{"invitations": a.AgentInvites()}) + return + } + writeJSON(w, 503, map[string]string{"error": "invitations unavailable"}) case "/api/agents", "/api/agents/audit": if s.deps.Agents == nil { writeJSON(w, 503, map[string]string{"error": "agents are not available on this box"}) diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index bf2e9eff2..3505f4f4d 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -105,6 +105,7 @@ const ( ) func (s *Server) agentRoutes() { + s.agentInviteRoutes() s.handle("GET /api/agents", Local, s.handleAgentsList) s.handle("GET /api/agents/audit", Local, s.handleAgentsAudit) s.handle("POST /api/agents/offer", Local, s.handleAgentsOffer) diff --git a/go/internal/api/security.go b/go/internal/api/security.go index 842fd4a8e..f4f38fa98 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -292,6 +292,7 @@ func protectedReadPath(path string) bool { "/api/app-link/status", "/api/agents", "/api/agents/audit", + "/api/agent-invites", "/api/ocpp/chargers": return true } diff --git a/go/internal/appenroll/agent_invites.go b/go/internal/appenroll/agent_invites.go new file mode 100644 index 000000000..61afcf7d1 --- /dev/null +++ b/go/internal/appenroll/agent_invites.go @@ -0,0 +1,405 @@ +package appenroll + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "slices" + "sort" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +type storedInvite struct { + protocol.AgentInvite + Hash []byte `json:"hash"` + RequestHash []byte `json:"requestHash"` + ApprovedBy string `json:"approvedBy"` + AgentKey string `json:"agentKey,omitempty"` + OfferHash []byte `json:"offerHash,omitempty"` +} + +func (i *Identity) SetAgentOfferKeys(keys map[string][]byte) { + i.mu.Lock() + defer i.mu.Unlock() + i.offerKeys = make(map[string][]byte, len(keys)) + for k, v := range keys { + i.offerKeys[k] = bytes.Clone(v) + } +} + +func (i *Identity) inviteOwnerLocked(by string) bool { + if by == LANApprover { + return true + } + m := i.authorised[by] + return m != nil && m.role == apiauth.RoleOwner && m.agent == nil +} + +func (i *Identity) inviteViewLocked(v storedInvite, now int64, clock bool) protocol.AgentInvite { + r := v.AgentInvite + r.Box = bytes.Clone(r.Box) + r.Scopes, r.Ops = slices.Clone(r.Scopes), slices.Clone(r.Ops) + r.ClockUnavailable = !clock + if r.State == "pending" && (clock && now >= r.ClaimUntilMs || !i.inviteOwnerLocked(v.ApprovedBy)) { + r.State = "expired" + } + return r +} + +func (i *Identity) AgentInvites() []protocol.AgentInvite { + i.mu.Lock() + defer i.mu.Unlock() + now, ok := i.wallLocked() + rows := make([]protocol.AgentInvite, 0, len(i.invites)) + for _, v := range i.invites { + rows = append(rows, i.inviteViewLocked(v, now, ok)) + } + sort.Slice(rows, func(a, b int) bool { return rows[a].CreatedAtMs > rows[b].CreatedAtMs }) + return rows +} + +// CreateAgentInvite saves approval before returning a code can be shared. +// An exact retry returns the saved receipt, including after claim or cancel. +func (i *Identity) CreateAgentInvite(by Approver, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) { + home := i.homeID() + i.saveMu.Lock() + i.mu.Lock() + row, changed, err := i.createAgentInviteLocked(by, m, home) + i.mu.Unlock() + i.saveMu.Unlock() + if changed { + i.changed() + } + return row, err +} + +func (i *Identity) createAgentInviteLocked(by Approver, m protocol.AgentInviteCreate, home string) (protocol.AgentInvite, bool, error) { + now, ok := i.wallLocked() + if !ok { + return protocol.AgentInvite{}, false, noClock() + } + id, err := base64.RawURLEncoding.DecodeString(m.ID) + if err != nil || len(id) != 16 || base64.RawURLEncoding.EncodeToString(id) != m.ID || len(m.Hash) != sha256.Size || len(m.Label) > 128 { + return protocol.AgentInvite{}, false, badField("invitation") + } + approvedBy := LANApprover + if by.DeviceID != "" { + key, meta := i.findLocked(by.DeviceID) + if meta == nil || meta.role != apiauth.RoleOwner || meta.agent != nil { + return protocol.AgentInvite{}, false, refuse(protocol.ErrScopeDenied, nil) + } + approvedBy = key + } + raw, err := protocol.InviteCreateBytes(protocol.SignedFor{BoxKey: i.static.Public(), DeviceID: by.DeviceID}, m) + if err != nil { + return protocol.AgentInvite{}, false, badField("order") + } + sum := sha256.Sum256(raw) + if old, exists := i.invites[m.ID]; exists { + if old.ApprovedBy != approvedBy || !bytes.Equal(old.RequestHash, sum[:]) { + return protocol.AgentInvite{}, false, refuse(protocol.ErrConflict, nil) + } + return i.inviteViewLocked(old, now, true), false, nil + } + if home == "" || m.Home != home { + return protocol.AgentInvite{}, false, badField("home") + } + if m.ClaimUntilMs <= now || m.ClaimUntilMs > now+protocol.AgentInviteClaimMaxMs || m.ClaimUntilMs > m.ExpiresAtMs { + return protocol.AgentInvite{}, false, badField("claimUntilMs") + } + caps, err := checkGrant(apiauth.RoleAgent, m.Scopes, m.Ops, m.Caps, m.ExpiresAtMs, protocol.Principal{}, now) + if err != nil { + return protocol.AgentInvite{}, false, err + } + active := 0 + for _, v := range i.invites { + if i.inviteViewLocked(v, now, true).State == "pending" { + active++ + } + } + if active >= protocol.AgentInviteRowsMax { + return protocol.AgentInvite{}, false, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "invitations", "max": protocol.AgentInviteRowsMax}) + } + for _, v := range i.invites { + if bytes.Equal(v.Hash, m.Hash) { + return protocol.AgentInvite{}, false, badField("hash") + } + } + approvedBy, err = i.approveLocked(by, m.AtMs, func(f protocol.SignedFor) ([]byte, error) { return protocol.InviteCreateBytes(f, m) }, m.Sig, now) + if err != nil { + return protocol.AgentInvite{}, false, err + } + v := storedInvite{AgentInvite: protocol.AgentInvite{Home: home, Box: i.static.Public(), ID: m.ID, Label: m.Label, Scopes: slices.Clone(m.Scopes), Ops: slices.Clone(m.Ops), Caps: caps, + CreatedAtMs: now, ClaimUntilMs: m.ClaimUntilMs, ExpiresAtMs: m.ExpiresAtMs, State: "pending"}, Hash: bytes.Clone(m.Hash), RequestHash: sum[:], ApprovedBy: approvedBy} + s := i.snapshotLocked() + s.AgentInvites = append(s.AgentInvites, v) + // Keep live claim windows and active agents, then the 32 newest old + // receipts. A cancelled code cannot become new after a lost retry. + sort.Slice(s.AgentInvites, func(a, b int) bool { + left, right := s.AgentInvites[a], s.AgentInvites[b] + at, bt := max(left.CreatedAtMs, left.ClaimedAtMs, left.CancelledAtMs), max(right.CreatedAtMs, right.ClaimedAtMs, right.CancelledAtMs) + if at == bt { + return left.ID < right.ID + } + return at > bt + }) + finished := 0 + kept := s.AgentInvites[:0] + for _, row := range s.AgentInvites { + liveAgent := row.AgentKey != "" && i.authorised[row.AgentKey] != nil + if now >= row.ClaimUntilMs && !liveAgent { + finished++ + if finished > 32 { + continue + } + } + kept = append(kept, row) + } + s.AgentInvites = kept + if len(kept) > 128 { + if by.DeviceID != "" { + delete(i.seenApprovals, sum) + } + return protocol.AgentInvite{}, false, refuse(protocol.ErrCapExceeded, map[string]any{"cap": "invitationReceipts", "max": 128}) + } + if err := i.persistLocked(s); err != nil { + if by.DeviceID != "" { + delete(i.seenApprovals, sum) + } + return protocol.AgentInvite{}, false, err + } + i.invites = map[string]storedInvite{} + for _, row := range s.AgentInvites { + i.invites[row.ID] = row + } + return i.inviteViewLocked(v, now, true), true, nil +} + +func (i *Identity) CancelAgentInvite(id string) (protocol.AgentInvite, error) { + i.saveMu.Lock() + i.mu.Lock() + v, exists := i.invites[id] + if !exists { + i.mu.Unlock() + i.saveMu.Unlock() + return protocol.AgentInvite{}, refuse(protocol.ErrNotFound, nil) + } + // Cancelling an already claimed code does not revoke its agent. The + // owner sees that row and uses the ordinary Revoke operation for it. + if v.State != "pending" { + i.mu.Unlock() + i.saveMu.Unlock() + return v.AgentInvite, nil + } + v.State = "cancelled" + v.CancelledAtMs, _ = i.wallLocked() + s := i.snapshotLocked() + for n := range s.AgentInvites { + if s.AgentInvites[n].ID == id { + s.AgentInvites[n] = v + } + } + err := i.persistLocked(s) + if err == nil { + i.invites[id] = v + } + i.mu.Unlock() + i.saveMu.Unlock() + if err == nil { + i.changed() + } + return v.AgentInvite, err +} + +func (i *Identity) CancelAllAgentInvites() error { + i.saveMu.Lock() + i.mu.Lock() + now, _ := i.wallLocked() + s := i.snapshotLocked() + changed := false + for n := range s.AgentInvites { + if s.AgentInvites[n].State == "pending" { + s.AgentInvites[n].State = "cancelled" + s.AgentInvites[n].CancelledAtMs = now + changed = true + } + } + var err error + if changed { + err = i.persistLocked(s) + if err == nil { + for _, v := range s.AgentInvites { + i.invites[v.ID] = v + } + } + } + i.mu.Unlock() + i.saveMu.Unlock() + if changed && err == nil { + i.changed() + } + return err +} + +func (i *Identity) checkAgentInvite(appStatic, payload []byte) (Grant, func() (Grant, error), error) { + c, err := protocol.ParseAgentInviteClaim(payload) + if err != nil || len(appStatic) != 32 { + return Grant{}, nil, ErrBadPairing + } + home := i.homeID() + i.mu.Lock() + id, meta, err := i.inviteClaimLocked(appStatic, c, home) + if err != nil { + i.mu.Unlock() + return Grant{}, nil, err + } + key := base64.RawURLEncoding.EncodeToString(appStatic) + g := i.grantLocked(key, meta) + i.mu.Unlock() + return g, func() (Grant, error) { + home := i.homeID() + i.saveMu.Lock() + i.mu.Lock() + // Verify the code, signed request, owner, time and current row again + // after the recipient proves possession in the first transport frame. + gotID, current, err := i.inviteClaimLocked(appStatic, c, home) + if err != nil || gotID != id { + i.mu.Unlock() + i.saveMu.Unlock() + return Grant{}, ErrBadPairing + } + v := i.invites[id] + if v.State == "claimed" { + g := i.grantLocked(key, current) + i.mu.Unlock() + i.saveMu.Unlock() + return g, nil + } + offer, _ := protocol.Marshal(c.Offer) + sum := sha256.Sum256(offer) + now, _ := i.wallLocked() + v.State, v.AgentID, v.AgentKey, v.ClaimedAtMs, v.Client, v.OfferHash = "claimed", deviceID(key), key, now, current.agent.Principal.Client, sum[:] + s := i.snapshotLocked() + for n := range s.AgentInvites { + if s.AgentInvites[n].ID == id { + s.AgentInvites[n] = v + } + } + s.AuthorisedApps = append(s.AuthorisedApps, storedApp{Key: key, Role: current.role, Epoch: current.epoch, AddedAtMs: now, LastSeenMs: now, Agent: current.agent}) + if err := i.persistLocked(s); err != nil { + i.mu.Unlock() + i.saveMu.Unlock() + return Grant{}, err + } + i.invites[id], i.authorised[key] = v, current + g := i.grantLocked(key, current) + row := i.agentRowLocked(key, current) + notify := i.onInviteClaimed + i.mu.Unlock() + i.saveMu.Unlock() + if notify != nil { + notify(v.AgentInvite, row) + } + // No callback runs under the identity lock. + i.changed() + return g, nil + }, nil +} + +func (i *Identity) inviteClaimLocked(key []byte, c protocol.AgentInviteClaim, home string) (string, *appMeta, error) { + now, ok := i.wallLocked() + if !ok || home == "" { + return "", nil, ErrBadPairing + } + hash := protocol.AgentInviteHash(home, c.Secret) + var v storedInvite + found := false + for _, row := range i.invites { + if bytes.Equal(hash, row.Hash) { + v, found = row, true + break + } + } + if !found || v.Home != home || !i.inviteOwnerLocked(v.ApprovedBy) { + return "", nil, ErrBadPairing + } + offer, err := protocol.OpenOffer(i.offerKeys, c.Offer, now) + if err != nil || offer.Role != apiauth.RoleAgent || !bytes.Equal(key, offer.Key) || offer.Home != "" && offer.Home != home { + return "", nil, ErrBadPairing + } + keyString := base64.RawURLEncoding.EncodeToString(key) + if v.State == "claimed" { + raw, _ := protocol.Marshal(c.Offer) + sum := sha256.Sum256(raw) + meta := i.authorised[keyString] + if v.AgentKey != keyString || !bytes.Equal(v.OfferHash, sum[:]) || meta == nil || meta.agent == nil { + return "", nil, ErrBadPairing + } + g, _, valid := i.agentGrantLocked(meta) + if !valid || g.Expired || g.NoClock { + return "", nil, ErrBadPairing + } + return v.ID, meta, nil + } + if v.State != "pending" || now >= v.ClaimUntilMs || now >= v.ExpiresAtMs || i.authorised[keyString] != nil { + return "", nil, ErrBadPairing + } + if _, taken := i.findLocked(deviceID(keyString)); taken != nil || i.agentCountLocked() >= protocol.AgentRowsMax { + return "", nil, ErrBadPairing + } + scopes, ops := []string{}, []string{} + for _, s := range v.Scopes { + if slices.Contains(offer.Scopes, s) { + scopes = append(scopes, s) + } + } + for _, op := range v.Ops { + if slices.Contains(offer.Ops, op) && slices.Contains(scopes, protocol.RegistryAgentOps[op]) { + ops = append(ops, op) + } + } + caps := v.Caps + caps.HoldS = min(caps.HoldS, offer.Caps.HoldS) + caps.MaxW = min(caps.MaxW, offer.Caps.MaxW) + perMin := offer.Caps.PerMin + if perMin == 0 { + perMin = protocol.PerMinDefault + } + caps.PerMin = min(caps.PerMin, perMin) + if offer.TTLS == 0 || offer.TTLS > uint64(protocol.AgentMaxMs/1000) { + return "", nil, ErrBadPairing + } + expires := min(v.ExpiresAtMs, now+int64(offer.TTLS)*1000) + if !slices.Contains(scopes, protocol.ScopeDispatchHold) { + caps.HoldS, caps.MaxW = 0, 0 + } + principal := protocol.Principal{Client: offer.Client, ClientID: offer.ClientID, Ticket: offer.Ticket} + caps, err = checkGrant(apiauth.RoleAgent, scopes, ops, caps, expires, principal, now) + if err != nil { + return "", nil, ErrBadPairing + } + meta := &appMeta{role: apiauth.RoleAgent, epoch: firstEpoch, addedAtMs: now, lastSeenMs: now, agent: &agentMeta{Scopes: scopes, Ops: ops, Caps: caps, ExpiresAtMs: expires, Principal: principal, ApprovedBy: v.ApprovedBy}} + return v.ID, meta, nil +} + +func (i *Identity) SetAgentInviteHome(fn func() (string, error)) { + i.mu.Lock() + i.prepareInviteHome = fn + i.mu.Unlock() +} +func (i *Identity) PrepareAgentInviteHome() (string, error) { + i.mu.Lock() + fn := i.prepareInviteHome + i.mu.Unlock() + if fn == nil { + return "", refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "cloud"}) + } + return fn() +} +func (i *Identity) OnAgentInviteClaimed(fn func(protocol.AgentInvite, protocol.AgentRow)) { + i.mu.Lock() + i.onInviteClaimed = fn + i.mu.Unlock() +} diff --git a/go/internal/appenroll/agent_invites_test.go b/go/internal/appenroll/agent_invites_test.go new file mode 100644 index 000000000..ba1fcdffe --- /dev/null +++ b/go/internal/appenroll/agent_invites_test.go @@ -0,0 +1,284 @@ +package appenroll + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll/approvaltest" + "github.com/srcfl/ftw/go/protocol" +) + +const inviteHome = "abcdefghijklmnopqrstuvwx23" + +type inviteFixture struct { + b *agentBox + create protocol.AgentInviteCreate + secret []byte + offerKey *approvaltest.Signer +} + +func newInviteFixture(t *testing.T) *inviteFixture { + t.Helper() + b := newAgentBox(t) + b.id.SetHome(func() string { return inviteHome }) + f := &inviteFixture{b: b, secret: bytes.Repeat([]byte{0x91}, 32), offerKey: approvaltest.New()} + b.id.SetAgentOfferKeys(map[string][]byte{"test": f.offerKey.Pub()}) + f.create = protocol.AgentInviteCreate{Home: inviteHome, ID: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x55}, 16)), Hash: protocol.AgentInviteHash(inviteHome, f.secret), Label: "Hannes", Scopes: []string{protocol.ScopeDiagRun, protocol.ScopeLiveRead}, ClaimUntilMs: b.ms() + 60_000, ExpiresAtMs: b.ms() + 86_400_000, AtMs: b.ms()} + raw, _ := protocol.InviteCreateBytes(b.signedFor(b.owner.DeviceID), f.create) + f.create.Sig = b.signer.Sign(raw) + return f +} + +func (f *inviteFixture) make(t *testing.T) protocol.AgentInvite { + t.Helper() + v, err := f.b.id.CreateAgentInvite(Approver{DeviceID: f.b.owner.DeviceID}, f.create) + if err != nil { + t.Fatal(err) + } + return v +} + +func (f *inviteFixture) claim(t *testing.T, key []byte) []byte { + t.Helper() + o := protocol.GrantOffer{V: 1, Key: key, Role: apiauth.RoleAgent, Client: "ChatGPT", ClientID: "https://client.example/metadata", Scopes: []string{protocol.ScopeDiagRun, protocol.ScopeLiveRead, protocol.ScopeSetupWrite}, TTLS: uint64(protocol.AgentMaxMs / 1000), ExpMs: f.b.ms() + 60_000} + raw, _ := protocol.Marshal(o) + signed, _ := protocol.OfferBytes(raw) + p, err := (protocol.AgentInviteClaim{Secret: f.secret, Offer: protocol.SignedOffer{O: raw, Kid: "test", Sig: f.offerKey.Sign(signed)}}).Payload() + if err != nil { + t.Fatal(err) + } + return p +} + +func TestAgentInvitationNeedsOwnerAndIsSavedBeforePublication(t *testing.T) { + f := newInviteFixture(t) + b := f.b + unsigned := f.create + unsigned.Sig = nil + if _, err := b.id.CreateAgentInvite(Approver{DeviceID: b.owner.DeviceID}, unsigned); refusalCode(err) != protocol.ErrBadSignature { + t.Fatalf("unsigned: %v", err) + } + original := b.id.path + b.id.path = filepath.Join(t.TempDir(), "missing", "identity.json") + if _, err := b.id.CreateAgentInvite(Approver{DeviceID: b.owner.DeviceID}, f.create); err == nil { + t.Fatal("create succeeded without a saved receipt") + } + if len(b.id.AgentInvites()) != 0 { + t.Fatal("failed create published in memory") + } + b.id.path = original + v := f.make(t) // Same signature, including after failed persistence. + if v.Home != inviteHome || !bytes.Equal(v.Box, b.id.StaticKey().Public()) { + t.Fatalf("wrong code binding: %+v", v) + } + raw, err := os.ReadFile(original) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(base64.RawURLEncoding.EncodeToString(f.secret))) { + t.Fatal("secret saved on disk") + } + if _, err := b.id.CreateAgentInvite(Approver{DeviceID: b.owner.DeviceID}, f.create); err != nil { + t.Fatalf("exact retry: %v", err) + } + changed := f.create + changed.Label = "Somebody else" + if _, err := b.id.CreateAgentInvite(Approver{DeviceID: b.owner.DeviceID}, changed); refusalCode(err) != protocol.ErrConflict { + t.Fatalf("id overwritten: %v", err) + } +} + +func TestAgentInvitationConsumesOnlyAtProofAndCannotReviveARevokedKey(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + key := appKey(t) + p := f.claim(t, key) + _, commit, err := f.b.id.CheckTunnel(key, p) + if err != nil { + t.Fatal(err) + } + if len(f.b.id.Agents()) != 0 || f.b.id.AgentInvites()[0].State != "pending" { + t.Fatal("handshake alone spent invitation") + } + g, err := commit() + if err != nil { + t.Fatal(err) + } + if g.Role != apiauth.RoleAgent || g.Agent == nil || slicesContain(g.Agent.Scopes, protocol.ScopeSetupWrite) { + t.Fatalf("claim widened grant: %+v", g) + } + row := f.b.id.AgentInvites()[0] + if row.State != "claimed" || row.AgentID != g.DeviceID { + t.Fatalf("missing receipt: %+v", row) + } + _, retry, err := f.b.id.CheckTunnel(key, p) + if err != nil { + t.Fatalf("lost reply retry: %v", err) + } + if _, err := retry(); err != nil { + t.Fatal(err) + } + other := appKey(t) + if _, _, err := f.b.id.CheckTunnel(other, f.claim(t, other)); err == nil { + t.Fatal("second key reused code") + } + if _, err := f.b.id.Revoke(g.DeviceID, OverASession); err != nil { + t.Fatal(err) + } + if _, _, err := f.b.id.CheckTunnel(key, p); err == nil { + t.Fatal("claimed code revived revoked agent") + } + if _, _, err := f.b.id.Check(key, p); err == nil { + t.Fatal("invitation accepted outside Cloud tunnel") + } +} + +func slicesContain(s []string, want string) bool { + for _, v := range s { + if v == want { + return true + } + } + return false +} + +func TestAgentInvitationFinalCommitRechecksCancelOwnerClockAndOffer(t *testing.T) { + for _, change := range []string{"cancel", "owner", "clock", "offer", "home"} { + t.Run(change, func(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + key := appKey(t) + p := f.claim(t, key) + _, commit, err := f.b.id.CheckTunnel(key, p) + if err != nil { + t.Fatal(err) + } + switch change { + case "cancel": + _, err = f.b.id.CancelAgentInvite(f.create.ID) + case "owner": + _ = pair(t, f.b.id, apiauth.RoleOwner) + _, err = f.b.id.Revoke(f.b.owner.DeviceID, OverASession) + case "clock": + f.b.synced = false + case "offer": + f.b.now = f.b.now.Add(2 * time.Minute) + case "home": + f.b.id.SetHome(func() string { return "zyxwvutsrqponmlkjihgfedc23" }) + } + if err != nil { + t.Fatal(err) + } + if _, err := commit(); err == nil { + t.Fatal("stale claim committed") + } + if len(f.b.id.Agents()) != 0 { + t.Fatal("refused claim left an agent") + } + }) + } +} + +func TestAgentInvitationAtomicClaimAndRestart(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + var commits []func() (Grant, error) + for range 2 { + key := appKey(t) + _, commit, err := f.b.id.CheckTunnel(key, f.claim(t, key)) + if err != nil { + t.Fatal(err) + } + commits = append(commits, commit) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for _, commit := range commits { + wg.Add(1) + go func() { defer wg.Done(); _, err := commit(); results <- err }() + } + wg.Wait() + close(results) + good := 0 + for err := range results { + if err == nil { + good++ + } + } + if good != 1 || len(f.b.id.Agents()) != 1 { + t.Fatalf("claim winners=%d", good) + } + _, err := f.b.id.CancelAgentInvite(f.create.ID) + if err != nil { + t.Fatal(err) + } + if f.b.id.AgentInvites()[0].State != "claimed" { + t.Fatal("cancel hid already granted access") + } + keyPath := filepath.Join(filepath.Dir(f.b.id.path), "nova.key") + again, err := LoadOrCreate(keyPath) + if err != nil { + t.Fatal(err) + } + again.SetClock(func() (time.Time, bool) { return f.b.now, true }) + if len(again.Agents()) != 1 || again.AgentInvites()[0].State != "claimed" { + t.Fatal("restart lost atomic claim") + } +} + +func TestAgentInvitationRetriesAfterExpiryAndDoesNotCallHomeUnderLock(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + f.b.id.SetHome(func() string { _ = f.b.id.StaticKey(); return inviteHome }) + key := appKey(t) + if _, _, err := f.b.id.CheckTunnel(key, f.claim(t, key)); err != nil { + t.Fatal(err) + } + f.b.synced = false + v := f.b.id.AgentInvites()[0] + if v.State != "pending" || !v.ClockUnavailable { + t.Fatalf("clock gap pretended expiry: %+v", v) + } + f.b.synced = true + f.b.now = f.b.now.Add(time.Hour) + v, err := f.b.id.CreateAgentInvite(Approver{DeviceID: f.b.owner.DeviceID}, f.create) + if err != nil || v.State != "expired" { + t.Fatalf("expired exact retry: %+v %v", v, err) + } +} + +func TestCancelledInvitationCannotReturnAfterReceiptPruning(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + if _, err := f.b.id.CancelAgentInvite(f.create.ID); err != nil { + t.Fatal(err) + } + // More than the finished-receipt budget, all within the original code's + // claim window. An exact retry must still return its cancellation. + for n := 0; n < 40; n++ { + m := f.create + m.ID = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{byte(n)}, 16)) + m.Hash = protocol.AgentInviteHash(inviteHome, bytes.Repeat([]byte{byte(n)}, 32)) + m.Sig = nil + if _, err := f.b.id.CreateAgentInvite(AtTheBoxPage, m); err != nil { + t.Fatal(err) + } + if _, err := f.b.id.CancelAgentInvite(m.ID); err != nil { + t.Fatal(err) + } + } + row, err := f.b.id.CreateAgentInvite(Approver{DeviceID: f.b.owner.DeviceID}, f.create) + if err != nil || row.State != "cancelled" { + t.Fatalf("cancelled code returned: %+v %v", row, err) + } + key := appKey(t) + if _, _, err := f.b.id.CheckTunnel(key, f.claim(t, key)); err == nil { + t.Fatal("cancelled code admitted a recipient") + } +} diff --git a/go/internal/appenroll/cloud_snapshot.go b/go/internal/appenroll/cloud_snapshot.go new file mode 100644 index 000000000..aaf58c955 --- /dev/null +++ b/go/internal/appenroll/cloud_snapshot.go @@ -0,0 +1,39 @@ +package appenroll + +import ( + "bytes" + "time" +) + +type InviteHint struct { + Hash []byte + Expires time.Time +} + +type CloudSnapshot struct { + Devices []RosterEntry + Noise []byte + PairingCode []byte + PairingExpires time.Time + Invites []InviteHint +} + +// CloudSnapshot reads devices and bootstrap hints under the same lock. A +// claim must appear as either its hint or its new agent, never neither. +func (i *Identity) CloudSnapshot() CloudSnapshot { + i.mu.Lock() + defer i.mu.Unlock() + s := CloudSnapshot{Devices: i.rosterLocked(), Noise: i.static.Public()} + if p := i.pairing; p != nil && p.kind == PairingKindQR && i.now().Before(p.expiresAt) { + s.PairingCode, s.PairingExpires = bytes.Clone(p.code), p.expiresAt + } + now, ok := i.wallLocked() + if ok { + for _, v := range i.invites { + if i.inviteViewLocked(v, now, true).State == "pending" { + s.Invites = append(s.Invites, InviteHint{Hash: bytes.Clone(v.Hash), Expires: i.now().Add(time.Duration(v.ClaimUntilMs-now) * time.Millisecond)}) + } + } + } + return s +} diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 90aa3fb92..d0f2aec00 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -31,6 +31,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/appwire" ) @@ -118,7 +119,8 @@ type stored struct { // // Earlier versions stored a bare list of key strings; load() reads both // so an update never silently unpairs a house. - AuthorisedApps []storedApp `json:"authorisedApps"` + AuthorisedApps []storedApp `json:"authorisedApps"` + AgentInvites []storedInvite `json:"agentInvites,omitempty"` } type storedApp struct { @@ -199,15 +201,20 @@ func deviceID(key string) string { type Identity struct { path string + saveMu sync.Mutex mu sync.Mutex static appwire.KeyPair // staticSecret is kept beside the key pair because appwire deliberately // exposes only the public half; persisting the private one is this // package's job and nobody else's. - staticSecret []byte - rendezvous []byte - authorised map[string]*appMeta - pairing *pairingCode + staticSecret []byte + rendezvous []byte + authorised map[string]*appMeta + invites map[string]storedInvite + offerKeys map[string][]byte + prepareInviteHome func() (string, error) + onInviteClaimed func(protocol.AgentInvite, protocol.AgentRow) + pairing *pairingCode // watchPairing is the watch code, in a slot of its own so that adding a // watch never cancels a phone's pending code. See MintWatchCode. watchPairing *pairingCode @@ -316,6 +323,7 @@ func LoadOrCreate(keyPath string) (*Identity, error) { id := &Identity{ path: path, authorised: map[string]*appMeta{}, + invites: map[string]storedInvite{}, now: time.Now, randRead: rand.Read, } @@ -363,6 +371,9 @@ func (i *Identity) load(raw []byte) error { i.static = static i.staticSecret = secret i.rendezvous = rendezvous + for _, inv := range s.AgentInvites { + i.invites[inv.ID] = inv + } for _, app := range s.AuthorisedApps { role := app.Role if role == "" { @@ -438,7 +449,15 @@ func (i *Identity) mint() error { // write leaves the old material rather than half of the new. A box that loses // its static key is a box every phone in the house has to be re-paired with. func (i *Identity) save() error { + i.saveMu.Lock() + defer i.saveMu.Unlock() i.mu.Lock() + defer i.mu.Unlock() + return i.persistLocked(i.snapshotLocked()) +} + +// The caller holds saveMu then mu through persistence and publication. +func (i *Identity) snapshotLocked() stored { s := stored{ NoiseSecret: base64.RawURLEncoding.EncodeToString(i.staticSecret), RendezvousSecret: base64.RawURLEncoding.EncodeToString(i.rendezvous), @@ -460,8 +479,14 @@ func (i *Identity) save() error { sort.Slice(s.AuthorisedApps, func(a, b int) bool { return s.AuthorisedApps[a].Key < s.AuthorisedApps[b].Key }) - i.mu.Unlock() + for _, inv := range i.invites { + s.AgentInvites = append(s.AgentInvites, inv) + } + sort.Slice(s.AgentInvites, func(a, b int) bool { return s.AgentInvites[a].ID < s.AgentInvites[b].ID }) + return s +} +func (i *Identity) persistLocked(s stored) error { raw, err := json.Marshal(s) if err != nil { return fmt.Errorf("appenroll: encoding %s: %w", i.path, err) @@ -680,6 +705,9 @@ func (i *Identity) Check(appStatic, payload []byte) (Grant, func() (Grant, error // agent row that has a clock to run by and has not expired: an agent reaches // the box through the tunnel and nowhere else. func (i *Identity) CheckTunnel(appStatic, payload []byte) (Grant, func() (Grant, error), error) { + if protocol.IsAgentInvitePayload(payload) { + return i.checkAgentInvite(appStatic, payload) + } return i.check(appStatic, payload, true) } @@ -1154,6 +1182,11 @@ type RosterEntry struct { // Roster lists every paired device, watches included, sorted by id. func (i *Identity) Roster() []RosterEntry { i.mu.Lock() + defer i.mu.Unlock() + return i.rosterLocked() +} + +func (i *Identity) rosterLocked() []RosterEntry { out := make([]RosterEntry, 0, len(i.authorised)) for key, meta := range i.authorised { raw, err := base64.RawURLEncoding.DecodeString(key) @@ -1172,7 +1205,6 @@ func (i *Identity) Roster() []RosterEntry { out = append(out, RosterEntry{ID: grant.DeviceID, Key: raw, Role: meta.role, Epoch: meta.epoch, ParentRole: grant.ParentRole, Agent: grant.Agent}) } - i.mu.Unlock() sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) return out } diff --git a/go/internal/appproto/agent_invites.go b/go/internal/appproto/agent_invites.go new file mode 100644 index 000000000..faa46f6db --- /dev/null +++ b/go/internal/appproto/agent_invites.go @@ -0,0 +1,43 @@ +package appproto + +import "github.com/srcfl/ftw/go/protocol" + +type InviteAccess interface { + AgentInvites() []protocol.AgentInvite + CreateAgentInvite(deviceID string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) + CancelAgentInvite(id string) (protocol.AgentInvite, error) +} + +func (h *Handler) onAgentInvite(env Envelope) error { + if _, ok, err := h.ownerPhone(env.ID); !ok { + return err + } + a, ok := h.cfg.Access.(InviteAccess) + if !ok { + return h.refuse(env.ID, ErrUnavailable, nil) + } + if env.T == protocol.MsgAgentInviteList { + now, ready := h.wall() + return h.agentOK(env.ID, map[string]any{"invitations": a.AgentInvites(), "nowMs": now, "clockReady": ready}) + } + var v protocol.AgentInvite + var err error + if env.T == protocol.MsgAgentInviteCreate { + var m protocol.AgentInviteCreate + if protocol.UnmarshalSigned(env.B, &m) != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"field": "body"}) + } + v, err = a.CreateAgentInvite(h.selfID(), m) + } else { + var m protocol.AgentInviteCancel + if protocol.UnmarshalSigned(env.B, &m) != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"field": "body"}) + } + v, err = a.CancelAgentInvite(m.ID) + } + h.audit("", env.T, v.ID, resultOf(err)) + if err != nil { + return h.refuseErr(env.ID, err) + } + return h.agentOK(env.ID, map[string]any{"invitation": v}) +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index fa049f666..8512a06cc 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -452,6 +452,8 @@ func (h *Handler) dispatch(ctx context.Context, env Envelope) error { return h.onCmd(ctx, env, "") case protocol.MsgApprovalKeySet: return h.onApprovalKeySet(env) + case protocol.MsgAgentInviteCreate, protocol.MsgAgentInviteList, protocol.MsgAgentInviteCancel: + return h.onAgentInvite(env) case protocol.MsgAgentAdd: return h.onAgentAdd(env) case protocol.MsgAgentWiden: diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 9b99e0512..0a4010bee 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -235,6 +235,7 @@ const ( CapCloudTun = protocol.CapCloudTun CapCmdExchange = protocol.CapCmdExchange CapAccessAgents = protocol.CapAccessAgents + CapAccessInvites = protocol.CapAccessInvites ScopeLiveRead = protocol.ScopeLiveRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopePlanRead = protocol.ScopePlanRead diff --git a/go/internal/appuplink/agents.go b/go/internal/appuplink/agents.go index 597d10aed..394361b89 100644 --- a/go/internal/appuplink/agents.go +++ b/go/internal/appuplink/agents.go @@ -87,6 +87,14 @@ func (a *AgentAccess) ExpireAgents() []appenroll.Removed { func (a *AgentAccess) Agents() []protocol.AgentRow { return a.Enroll.Agents() } +func (a *AgentAccess) AgentInvites() []protocol.AgentInvite { return a.Enroll.AgentInvites() } +func (a *AgentAccess) CreateAgentInvite(deviceID string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) { + return a.Enroll.CreateAgentInvite(approver(deviceID), m) +} +func (a *AgentAccess) CancelAgentInvite(id string) (protocol.AgentInvite, error) { + return a.Enroll.CancelAgentInvite(id) +} + func (a *AgentAccess) ApprovalKeys() []protocol.ApprovalKeyInfo { return a.Enroll.ApprovalKeys() } func (a *AgentAccess) SetApprovalKey(deviceID string, m protocol.ApprovalKeySet) (int64, error) { diff --git a/go/internal/appuplink/invite_test.go b/go/internal/appuplink/invite_test.go new file mode 100644 index 000000000..c705b5334 --- /dev/null +++ b/go/internal/appuplink/invite_test.go @@ -0,0 +1,62 @@ +package appuplink + +import ( + "bytes" + "encoding/base64" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appenroll/approvaltest" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +func TestInvitationWaitsForNoiseTransportProofOnTheRealTunnel(t *testing.T) { + r := newTunRig(t) + now := time.Now() + home := "abcdefghijklmnopqrstuvwx23" + r.enroll.SetClock(func() (time.Time, bool) { return now, true }) + r.enroll.SetHome(func() string { return home }) + signer := approvaltest.New() + r.enroll.SetAgentOfferKeys(map[string][]byte{"test": signer.Pub()}) + secret := bytes.Repeat([]byte{0x92}, 32) + _, err := r.enroll.CreateAgentInvite(appenroll.AtTheBoxPage, protocol.AgentInviteCreate{Home: home, ID: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x63}, 16)), Hash: protocol.AgentInviteHash(home, secret), Scopes: []string{protocol.ScopeLiveRead}, ClaimUntilMs: now.Add(time.Hour).UnixMilli(), ExpiresAtMs: now.Add(24 * time.Hour).UnixMilli()}) + if err != nil { + t.Fatal(err) + } + app, err := newAppClient() + if err != nil { + t.Fatal(err) + } + offer := protocol.GrantOffer{V: 1, Key: app.staticPublic(), Role: protocol.RoleAgent, Client: "Test assistant", Scopes: []string{protocol.ScopeLiveRead}, TTLS: 86400, ExpMs: now.Add(time.Minute).UnixMilli()} + raw, _ := protocol.Marshal(offer) + signed, _ := protocol.OfferBytes(raw) + payload, err := (protocol.AgentInviteClaim{Secret: secret, Offer: protocol.SignedOffer{O: raw, Kid: "test", Sig: signer.Sign(signed)}}).Payload() + if err != nil { + t.Fatal(err) + } + dev := deviceIDOf(app.staticPublic()) + r.open(t, app, dev, 91, payload) + if len(r.enroll.Agents()) != 0 || r.enroll.AgentInvites()[0].State != "pending" { + t.Fatal("message 1 consumed invitation") + } + // An authenticated transport message proves the recipient's private key. + // No hardware command or valid application request is needed for proof. + proof, err := app.encrypt([]byte{0}) + if err != nil { + t.Fatal(err) + } + r.tunnel.Deliver(cloudwire.Tun{Dev: dev, Sid: 91, D: proof}) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if rows := r.enroll.Agents(); len(rows) == 1 { + if rows[0].Role != protocol.RoleAgent || rows[0].ID != dev { + t.Fatalf("wrong agent: %+v", rows[0]) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("proved invitation did not commit") +} diff --git a/go/internal/appuplink/roster.go b/go/internal/appuplink/roster.go index f445ec766..bd6db82b9 100644 --- a/go/internal/appuplink/roster.go +++ b/go/internal/appuplink/roster.go @@ -24,8 +24,12 @@ type CloudRoster struct { // Devices lists the paired devices. func (r CloudRoster) Devices() []cloudlink.RosterDevice { + return r.devices(r.Enroll.Roster()) +} + +func (r CloudRoster) devices(entries []appenroll.RosterEntry) []cloudlink.RosterDevice { var out []cloudlink.RosterDevice - for _, e := range r.Enroll.Roster() { + for _, e := range entries { d := cloudlink.RosterDevice{ID: e.ID, Key: e.Key, Role: e.Role, Epoch: e.Epoch, Scopes: appproto.GrantScopes(e.Role, e.ParentRole), Tun: r.Tunnel && e.Role != apiauth.RoleWatch} if a := e.Agent; a != nil { @@ -62,3 +66,19 @@ func (r CloudRoster) Pending() ([]byte, time.Time, bool) { } return r.Enroll.PendingCode() } + +// Snapshot keeps invitation consumption and the newly listed key together. +func (r CloudRoster) Snapshot(home string) cloudlink.RosterSnapshot { + s := r.Enroll.CloudSnapshot() + out := cloudlink.RosterSnapshot{Devices: r.devices(s.Devices), Noise: s.Noise} + if !r.Tunnel { + return out + } + if len(s.PairingCode) > 0 { + out.Pending = append(out.Pending, cloudlink.RosterPending{Hash: cloudwire.PairingHash(home, s.PairingCode), Expires: s.PairingExpires}) + } + for _, v := range s.Invites { + out.Pending = append(out.Pending, cloudlink.RosterPending{Hash: v.Hash, Kind: protocol.RoleAgent, Expires: v.Expires}) + } + return out +} diff --git a/go/internal/cloudlink/invite_test.go b/go/internal/cloudlink/invite_test.go new file mode 100644 index 000000000..0dfaf9479 --- /dev/null +++ b/go/internal/cloudlink/invite_test.go @@ -0,0 +1,49 @@ +package cloudlink + +import ( + "crypto/ecdsa" + "path/filepath" + "sync" + "testing" +) + +type blockedInviteRoster struct { + fakeRoster + once sync.Once + entered, release chan struct{} +} + +func (r *blockedInviteRoster) Snapshot(string) RosterSnapshot { + r.once.Do(func() { close(r.entered); <-r.release }) + return RosterSnapshot{} +} + +func TestInvitePreparationCannotBeEndedByAnOlderIdleCheck(t *testing.T) { + svc := newFakeService(t) + r := &blockedInviteRoster{entered: make(chan struct{}), release: make(chan struct{})} + link, err := New(Options{Site: &fakeSite{}, Roster: r, KV: &memKV{}, KeyPath: filepath.Join(t.TempDir(), "cloud.key"), Tunnel: &fakeTunnel{}, ServiceKeys: []*ecdsa.PublicKey{&svc.key.PublicKey}, Endpoint: svc.endpoint(), RootCAs: svc.roots()}) + if err != nil { + t.Fatal(err) + } + // Make an idle link without starting an invitation; the controlled + // snapshot stops its final cleanup between the two link-lock phases. + link.mu.Lock() + err = link.linkLocked() + link.mu.Unlock() + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { link.endIfIdle(); close(done) }() + <-r.entered + home, err := link.PrepareAgentInviteHome() + if err != nil || home == "" { + close(r.release) + t.Fatalf("prepare: %s %v", home, err) + } + close(r.release) + <-done + if link.Home() != home { + t.Fatal("old idle check ended the newly prepared home") + } +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 35623bf7b..c0b19a624 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -176,6 +176,29 @@ type Roster interface { Noise() []byte } +type RosterPending struct { + Hash []byte + Kind string + Expires time.Time +} + +type RosterSnapshot struct { + Devices []RosterDevice + Noise []byte + Pending []RosterPending +} + +func (l *Link) rosterSnapshot(home string) RosterSnapshot { + if r, ok := l.opts.Roster.(interface{ Snapshot(string) RosterSnapshot }); ok { + return r.Snapshot(home) + } + s := RosterSnapshot{Devices: l.opts.Roster.Devices(), Noise: l.opts.Roster.Noise()} + if code, expires, ok := l.opts.Roster.Pending(); ok { + s.Pending = append(s.Pending, RosterPending{Hash: cloudwire.PairingHash(home, code), Expires: expires}) + } + return s +} + // Tunnel serves the phones' sessions that FTW Cloud carries. The box's app // session table implements it; the link reaches it only through this port, // and hands it each tun from the service unread. @@ -233,9 +256,11 @@ type Options struct { // Link is the box's one uplink to FTW Cloud. type Link struct { - opts Options - log *slog.Logger - boot []byte + invitePreparingUntil time.Time + needsRevision uint64 + opts Options + log *slog.Logger + boot []byte // wake is signalled when the grant or the roster changes, so the // sender acts within one tick. @@ -473,6 +498,9 @@ func (l *Link) signal() { // its screen, links at once, so the code's home id exists before the code // is drawn. func (l *Link) RosterChanged() { + l.mu.Lock() + l.needsRevision++ + l.mu.Unlock() if l.wantsLink() { l.mu.Lock() if err := l.linkLocked(); err != nil && !errors.Is(err, ErrNoService) { @@ -495,14 +523,19 @@ func (l *Link) RosterChanged() { func (l *Link) wantsLink() bool { l.mu.Lock() blocked := l.disabled || (l.grant != nil && l.grant.statisticsOnly()) + preparing := time.Now().Before(l.invitePreparingUntil) l.mu.Unlock() if blocked { return false } - if _, _, ok := l.opts.Roster.Pending(); ok { + if preparing { return true } - for _, d := range l.opts.Roster.Devices() { + snapshot := l.rosterSnapshot("") + if len(snapshot.Pending) > 0 { + return true + } + for _, d := range snapshot.Devices { if d.Tun || (d.Agent != nil && l.opts.Tunnel != nil) { return true } @@ -556,13 +589,14 @@ func (l *Link) linkLocked() error { func (l *Link) endIfIdle() { l.mu.Lock() idle := l.link != nil && !l.link.Ending && l.grant == nil + revision := l.needsRevision l.mu.Unlock() if !idle || l.wantsLink() { return } l.mu.Lock() defer l.mu.Unlock() - if l.link == nil || l.link.Ending || l.grant != nil { + if l.link == nil || l.link.Ending || l.grant != nil || l.needsRevision != revision || time.Now().Before(l.invitePreparingUntil) { return } rec := *l.link @@ -1189,3 +1223,22 @@ func (l *Link) quicTLSConfig() *tls.Config { // now is the wall clock, or a test's. func (l *Link) now() time.Time { return l.opts.Now() } + +// PrepareAgentInviteHome allocates the home for an owner who creates an +// invitation before pairing a phone. No readable-copy consent changes. +func (l *Link) PrepareAgentInviteHome() (string, error) { + l.mu.Lock() + defer l.mu.Unlock() + if !l.Available() || l.opts.Tunnel == nil || l.disabled || l.grant.statisticsOnly() { + return "", protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "cloud"}) + } + if err := l.linkLocked(); err != nil { + return "", err + } + if l.link.Ending { + return "", protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "cloud"}) + } + l.invitePreparingUntil = time.Now().Add(30 * time.Second) + l.needsRevision++ + return l.home, nil +} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 93bc57789..b377eaf7a 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -263,8 +263,11 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { return err } st.rosterAt = now - if _, expires, ok := l.opts.Roster.Pending(); ok { - st.pendingUntil = expires + for _, p := range roster.Pending { + expires := now.Add(time.Duration(p.TTLS) * time.Second) + if st.pendingUntil.IsZero() || expires.Before(st.pendingUntil) { + st.pendingUntil = expires + } } } @@ -698,7 +701,8 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { r.Grant = cloudwire.Grant{ID: g.ID, Scopes: g.Scopes, Purposes: g.Purposes, Controller: g.Controller, TermsPolicy: g.Policy, StatsID: g.StatsID, StatsPolicy: g.StatsPolicy, StatsSinceMs: g.StatsAtMs} } if !snap.g.statisticsOnly() { - for _, d := range l.opts.Roster.Devices() { + roster := l.rosterSnapshot(snap.home) + for _, d := range roster.Devices { var scopes []string for _, s := range protocol.CloudScopes { if slices.Contains(d.Scopes, s) { @@ -709,7 +713,7 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { Epoch: d.Epoch, Tun: d.Tun, Agent: d.Agent}) } sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) - r.Noise = l.opts.Roster.Noise() + r.Noise = roster.Noise if l.opts.WebPush != nil { var err error r.WebPush, err = l.opts.WebPush.WebPushRows() @@ -718,20 +722,18 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { } sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) } - if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { - if left := time.Until(expires); left > 0 { - r.Pending = append(r.Pending, cloudwire.Pending{ - Hash: cloudwire.PairingHash(snap.home, code), - TTLS: int64((left + time.Second - 1) / time.Second), - }) + for _, p := range roster.Pending { + if left := time.Until(p.Expires); left > 0 && snap.home != "" { + r.Pending = append(r.Pending, cloudwire.Pending{Hash: p.Hash, Kind: p.Kind, TTLS: int64((left + time.Second - 1) / time.Second)}) } } + sort.Slice(r.Pending, func(a, b int) bool { return string(r.Pending[a].Hash) < string(r.Pending[b].Hash) }) } // The number follows what the roster says, not how long a code has // left, so it does not rise every second a code is shown. - var pending [][]byte + var pending []any for _, p := range r.Pending { - pending = append(pending, p.Hash) + pending = append(pending, []any{p.Kind, p.Hash}) } sum := digest([]any{r.Devices, r.Grant, pending, r.Noise, r.WebPush}) diff --git a/go/protocol/agent_invites.go b/go/protocol/agent_invites.go new file mode 100644 index 000000000..248f59ae4 --- /dev/null +++ b/go/protocol/agent_invites.go @@ -0,0 +1,147 @@ +package protocol + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "errors" +) + +const ( + MsgAgentInviteCreate = "agent.invite.create" + MsgAgentInviteList = "agent.invite.list" + MsgAgentInviteCancel = "agent.invite.cancel" + AgentInviteSecretBytes = 32 + AgentInviteRowsMax = 16 + AgentInviteClaimMaxMs int64 = 24 * 60 * 60 * 1000 + AgentInviteHandshakeMax = 8192 +) + +// AgentInviteCreate approves one future agent. The owner draws Secret locally +// and sends only its verifier. ID makes a lost create reply safe to retry. +type AgentInviteCreate struct { + Home string `cbor:"home" json:"home"` + ID string `cbor:"id" json:"id"` + Hash []byte `cbor:"hash" json:"hash"` + Label string `cbor:"label" json:"label"` + Scopes []string `cbor:"scopes" json:"scopes"` + Ops []string `cbor:"ops" json:"ops"` + Caps Caps `cbor:"caps" json:"caps"` + ClaimUntilMs int64 `cbor:"claimUntilMs" json:"claimUntilMs"` + ExpiresAtMs int64 `cbor:"expiresAtMs" json:"expiresAtMs"` + AtMs int64 `cbor:"atMs" json:"atMs"` + Sig []byte `cbor:"sig" json:"sig"` +} + +type AgentInviteCancel struct { + ID string `cbor:"id" json:"id"` +} + +// AgentInvite contains no secret or verifier. A claimed invitation points to +// the ordinary agent row; revoking that row never revives its invitation. +type AgentInvite struct { + CancelledAtMs int64 `cbor:"cancelledAtMs,omitempty" json:"cancelledAtMs,omitempty"` + ClockUnavailable bool `cbor:"clockUnavailable,omitempty" json:"clockUnavailable,omitempty"` + Home string `cbor:"home" json:"home"` + Box []byte `cbor:"box" json:"box"` + ID string `cbor:"id" json:"id"` + Label string `cbor:"label" json:"label"` + Scopes []string `cbor:"scopes" json:"scopes"` + Ops []string `cbor:"ops" json:"ops"` + Caps Caps `cbor:"caps" json:"caps"` + CreatedAtMs int64 `cbor:"createdAtMs" json:"createdAtMs"` + ClaimUntilMs int64 `cbor:"claimUntilMs" json:"claimUntilMs"` + ExpiresAtMs int64 `cbor:"expiresAtMs" json:"expiresAtMs"` + State string `cbor:"state" json:"state"` + AgentID string `cbor:"agentId,omitempty" json:"agentId,omitempty"` + ClaimedAtMs int64 `cbor:"claimedAtMs,omitempty" json:"claimedAtMs,omitempty"` + Client string `cbor:"client,omitempty" json:"client,omitempty"` +} + +func InviteCreateBytes(f SignedFor, m AgentInviteCreate) ([]byte, error) { + return ApprovalBytes(MsgAgentInviteCreate, f, m.AtMs, m.Home, m.ID, m.Hash, m.Label, + emptyIfNil(m.Scopes), emptyIfNil(m.Ops), capsArray(m.Caps), m.ClaimUntilMs, m.ExpiresAtMs) +} + +// AgentInviteCode is copied to the person receiving access. It contains a +// 256-bit bearer secret; never put it in logs, a query string or a roster. +type AgentInviteCode struct { + V uint64 `cbor:"v"` + Home string `cbor:"home"` + Box []byte `cbor:"box"` + Secret []byte `cbor:"secret"` +} + +var ErrAgentInvite = errors.New("protocol: invalid agent invitation") + +func (c AgentInviteCode) valid() bool { + if c.V != 1 || len(c.Box) != 32 || len(c.Secret) != AgentInviteSecretBytes || len(c.Home) != 26 { + return false + } + for _, b := range c.Home { + if !(b >= 'a' && b <= 'z' || b >= '2' && b <= '7') { + return false + } + } + return true +} + +func (c AgentInviteCode) Encode() (string, error) { + if !c.valid() { + return "", ErrAgentInvite + } + b, err := encMode.Marshal(c) + return base64.RawURLEncoding.EncodeToString(b), err +} + +func ParseAgentInviteCode(raw string) (AgentInviteCode, error) { + var c AgentInviteCode + if len(raw) > 512 { + return c, ErrAgentInvite + } + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || strictDec.Unmarshal(b, &c) != nil || !c.valid() { + return AgentInviteCode{}, ErrAgentInvite + } + return c, nil +} + +func AgentInviteHash(home string, secret []byte) []byte { + h := sha256.New() + h.Write([]byte("ftw.agent.invite.v1\x00")) + h.Write([]byte(home)) + h.Write([]byte{0}) + h.Write(secret) + return h.Sum(nil) +} + +var agentInvitePrefix = []byte("ftw.agent.invite.v1\x00") + +// AgentInviteClaim is encrypted inside Noise message 1. The signed offer +// binds the recipient's own pending key to the OAuth request. +type AgentInviteClaim struct { + Secret []byte `cbor:"secret"` + Offer SignedOffer `cbor:"offer"` +} + +func (c AgentInviteClaim) Payload() ([]byte, error) { + if len(c.Secret) != AgentInviteSecretBytes { + return nil, ErrAgentInvite + } + b, err := encMode.Marshal(c) + if err != nil || len(b)+len(agentInvitePrefix) > AgentInviteHandshakeMax { + return nil, ErrAgentInvite + } + return append(bytes.Clone(agentInvitePrefix), b...), nil +} + +func IsAgentInvitePayload(b []byte) bool { return bytes.HasPrefix(b, agentInvitePrefix) } + +func ParseAgentInviteClaim(b []byte) (AgentInviteClaim, error) { + var c AgentInviteClaim + if !IsAgentInvitePayload(b) || len(b) > AgentInviteHandshakeMax || + strictDec.Unmarshal(b[len(agentInvitePrefix):], &c) != nil || len(c.Secret) != AgentInviteSecretBytes { + return c, ErrAgentInvite + } + return c, nil +} diff --git a/go/protocol/agent_invites_test.go b/go/protocol/agent_invites_test.go new file mode 100644 index 000000000..1c002dd28 --- /dev/null +++ b/go/protocol/agent_invites_test.go @@ -0,0 +1,45 @@ +package protocol + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "testing" +) + +func TestAgentInvitationVector(t *testing.T) { + var v struct { + Home, BoxHex, SecretHex, DeviceID, HashHex, SignedHex, Code string + Request AgentInviteCreate + } + raw, err := os.ReadFile("testdata/invitation-vectors.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + box, _ := hex.DecodeString(v.BoxHex) + secret, _ := hex.DecodeString(v.SecretHex) + if got := hex.EncodeToString(AgentInviteHash(v.Home, secret)); got != v.HashHex { + t.Fatalf("hash %s", got) + } + signed, err := InviteCreateBytes(SignedFor{BoxKey: box, DeviceID: v.DeviceID}, v.Request) + if err != nil || hex.EncodeToString(signed) != v.SignedHex { + t.Fatalf("signed bytes %x: %v", signed, err) + } + code, err := (AgentInviteCode{V: 1, Home: v.Home, Box: box, Secret: secret}).Encode() + if err != nil || code != v.Code { + t.Fatalf("code %s: %v", code, err) + } + decoded, err := ParseAgentInviteCode(code) + if err != nil || !bytes.Equal(decoded.Secret, secret) { + t.Fatal("code did not round trip") + } + for _, bad := range []string{"ABCD-EFGH", code + "=", code + "extra"} { + if _, err := ParseAgentInviteCode(bad); err == nil { + t.Fatalf("accepted malformed code %q", bad) + } + } +} diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 66befad80..f4b21e66f 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -49,7 +49,7 @@ var Fields = map[string][]string{ "Caps": {"holdS", "maxW", "perMin"}, "Principal": {"client", "clientId", "ticket"}, "Grant": {"id", "scopes", "purposes", "controller", "termsPolicy", "statsId", "statsPolicy", "statsSinceMs"}, - "Pending": {"hash", "ttlS"}, + "Pending": {"hash", "kind", "ttlS"}, "WebPushRow": {"id", "dev", "epoch", "endpoint", "ct", "auth"}, MsgBye: {"why", "purge"}, diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 80f403ba0..8ace59fe0 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -328,9 +328,8 @@ type Roster struct { TTLS int64 `cbor:"ttlS"` Devices []Device `cbor:"devices"` Grant Grant `cbor:"grant"` - // Pending lists the pairing codes the box shows now. Each admits one - // first session through the service, for a phone the roster does not - // list yet. + // Pending lists phone pairing and agent invitation hints. Each kind + // has its own admission path; agent hints never pair a phone. 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 @@ -382,8 +381,10 @@ type Agent struct { Principal protocol.Principal `cbor:"principal"` } -// Pending is one pairing code on the box's screen, by its PairingHash. +// Pending is a bootstrap hint. Kind keeps phone pairing and agent claims apart. type Pending struct { + // Kind is empty for phone pairing; agent invitations never pair a phone. + Kind string `cbor:"kind,omitempty"` Hash []byte `cbor:"hash"` // TTLS is how long the code has left, counted from when the service // receives the roster. diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 0dee9b66f..701af119c 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -128,6 +128,7 @@ const ( CapCloudTun = "cloud.tun" CapCmdExchange = "cmd.exchange" CapAccessAgents = "access.agents" + CapAccessInvites = "access.invites" CapDerCar = "der.car" ) @@ -153,6 +154,7 @@ var AllCapabilities = []string{ CapCloudTun, CapCmdExchange, CapAccessAgents, + CapAccessInvites, CapDerCar, } diff --git a/go/protocol/testdata/invitation-vectors.json b/go/protocol/testdata/invitation-vectors.json new file mode 100644 index 000000000..eb0305537 --- /dev/null +++ b/go/protocol/testdata/invitation-vectors.json @@ -0,0 +1,29 @@ +{ + "boxHex": "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + "code": "pGF2AWNib3hYIFpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaZGhvbWV4GmFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eDIzZnNlY3JldFggkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZE", + "deviceId": "demoPh01", + "hashHex": "084bd13dd37899d0a7ca8f7b74050689d66a7969a97222d86e8c20f96534380e", + "home": "abcdefghijklmnopqrstuvwx23", + "request": { + "home": "abcdefghijklmnopqrstuvwx23", + "id": "VVVVVVVVVVVVVVVVVVVVVQ", + "hash": "CEvRPdN4mdCnyo97dAUGidZqeWmpciLYbowg+WU0OA4=", + "label": "Hannes", + "scopes": [ + "ftw.diag.run", + "ftw.live.read" + ], + "ops": [], + "caps": { + "holdS": 0, + "maxW": 0, + "perMin": 0 + }, + "claimUntilMs": 1792000060000, + "expiresAtMs": 1792086400000, + "atMs": 1792000000000, + "sig": null + }, + "secretHex": "9191919191919191919191919191919191919191919191919191919191919191", + "signedHex": "8e6f6674772e617070726f76616c2e7631736167656e742e696e766974652e63726561746558205a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a6864656d6f506830311b000001a13b860000781a6162636465666768696a6b6c6d6e6f707172737475767778323376565656565656565656565656565656565656565656515820084bd13dd37899d0a7ca8f7b74050689d66a7969a97222d86e8c20f96534380e6648616e6e6573826c6674772e646961672e72756e6d6674772e6c6976652e7265616480830000001b000001a13b86ea601b000001a140ac5c00" +} diff --git a/web/index.html b/web/index.html index e2ab3ccf8..172118e91 100644 --- a/web/index.html +++ b/web/index.html @@ -1009,7 +1009,7 @@

Price bars (top of the chart)

- + diff --git a/web/settings/tabs/access-invitations-flow.test.mjs b/web/settings/tabs/access-invitations-flow.test.mjs new file mode 100644 index 000000000..67d3c1c9f --- /dev/null +++ b/web/settings/tabs/access-invitations-flow.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { webcrypto } from 'node:crypto'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('./access.js', import.meta.url), 'utf8'); +const vector = JSON.parse(readFileSync(new URL('../../../go/protocol/testdata/invitation-vectors.json', import.meta.url), 'utf8')); +const settle = async () => { for (let n = 0; n < 30; n++) await Promise.resolve(); }; + +function rig({ loseReply = false } = {}) { + function element(tag) { + const e = { tag, children: [], listeners: {}, checked: false, value: '', + setAttribute(k, v) { this[k] = v; }, addEventListener(k, v) { this.listeners[k] = v; }, + appendChild(c) { this.children.push(c); if (tag === 'select' && (!this.value || c.selected)) this.value = c.value; return c; }, + all() { return this.children.flatMap(c => [c, ...c.all()]); }, + querySelectorAll(q) { return this.all().filter(n => { + if (q === 'input, select') return ['input', 'select'].includes(n.tag); + if (q.startsWith('[id^=')) return n.id?.startsWith(q.slice(6, -2)); + const m = /^(input|select)\[name="(\w+)"\]$/.exec(q); + return m && n.tag === m[1] && n.name === m[2]; + }); }, + querySelector(q) { return this.querySelectorAll(q)[0] || null; }, + submit() { this.listeners.submit({ preventDefault() {} }); }, + click() { this.listeners.click?.(); this.onclick?.(); }, select() { this.selectedText = true; }, + }; + let text = ''; + Object.defineProperty(e, 'textContent', { get() { return text; }, set(v) { text = v; this.children = []; } }); + return e; + } + const roots = Object.fromEntries(['access-ready', 'access-code', 'access-code-form', 'access-lookup', 'access-note', 'access-offer', 'access-rows', 'access-rows-section', 'access-requests', 'access-requests-section', 'access-invites'].map(id => [id, Object.assign(element('div'), { id })])); + const all = () => Object.values(roots).flatMap(e => [e, ...e.all()]); + const posts = [], rows = [], alias = { fails:false, code:'K7M-4PX' }; + const answer = body => Promise.resolve({ ok: true, status: 200, json: async () => body }); + const context = vm.createContext({ window: {}, document: { createElement: element, getElementById: id => all().find(e => e.id === id) || null }, + Uint8Array, crypto: webcrypto, atob, btoa, navigator: {}, setTimeout() {}, clearTimeout() {}, console, + fetch: (path, opts) => { + if (path === '/api/auth/status') return answer({ lan_auth: true, configured: true }); + if (path === '/api/agents') return answer({ agents: [], requests: [], registry: { agentOps: {}, writeScopes: ['ftw.setup.write'], callScopes: ['ftw.setup.write'] }, box: { widen: true, offers: true, clock: 'ok', nowMs: Date.now() } }); + if (path === 'https://cloud.ftw.energy/oauth/invitations') { + if(alias.fails) return Promise.reject(new Error('closed test publication failure')); + assert.equal(opts.credentials, 'omit'); + assert.ok(JSON.parse(opts.body).invitation.length > 100, 'only the TLS publishing proof uses the strong format'); + return answer({code:alias.code,expiresAtMs:Date.now()+600000}); + } + if (path === '/api/agent-invites' && !opts) return answer({ invitations: rows, home: vector.home, scopes: ['ftw.live.read', 'ftw.diag.run', 'ftw.setup.write'], ops: [], caps: { perMin: 6 } }); + if (path === '/api/agent-invites' && opts?.method === 'POST') { + const body = JSON.parse(opts.body); posts.push(body); + let row = rows.find(r => r.id === body.id); + if (!row) { row = { ...body, box: Buffer.from(vector.boxHex, 'hex').toString('base64'), state: 'pending' }; delete row.secret; rows.push(row); } + if (loseReply) { loseReply = false; return Promise.reject(new Error('closed test connection')); } + return answer({ invitation: row }); + } + throw new Error(`Unexpected request ${path}`); + }, + }); + vm.runInContext(source, context); + const tab = context.window.FTWSettings.tabs.access; + const refresh = () => tab.after({ navigateTab() {} }); refresh(); + const button = text => all().find(e => e.tag === 'button' && e.textContent === text); + return { roots, posts, rows, alias, all, button, refresh, pure: tab._pure }; +} + +test('the LAN page makes the same invitation code as Go and Swift', () => { + const r = rig(); + const code = r.pure.inviteCode({ home: vector.home, box: Buffer.from(vector.boxHex, 'hex').toString('base64') }, Buffer.from(vector.secretHex, 'hex')); + assert.equal(code, vector.code); +}); + +test('lost creation reply keeps the exact body and recovers its copyable code', async () => { + const r = rig({ loseReply: true }); await settle(); + r.button('Create an invitation').click(); + const form = r.roots['access-invites'].all().find(e => e.tag === 'form'); + assert.equal(form.querySelector('input[name="call"]').checked, false, 'setup starts off'); + r.all().find(e => e.id === 'invite-label').value = 'Hannes'; + form.submit(); await settle(); + const first = structuredClone(r.posts[0]); + assert.ok(first); + assert.equal(first.scopes.includes('ftw.setup.write'), false); + form.submit(); await settle(); + assert.deepEqual(r.posts[1], first, 'a retry never makes a new invitation'); + const code = r.all().find(e => e['aria-label'] === 'Invitation code'); + assert.equal(code.value, 'K7M-4PX'); + r.button('Copy invitation code').click(); + assert.equal(code.selectedText, true, 'copy works on plain HTTP without clipboard APIs'); + r.rows[0].state = 'claimed'; r.rows[0].agentId = 'recipient-key'; r.rows[0].client = 'ChatGPT'; + r.button('Check status and code').click(); await settle(); + assert.equal(r.all().some(e => e['aria-label'] === 'Invitation code'), false, 'a used code disappears'); +}); + +test('a lost reply followed by a list read recovers the code without resending', async () => { + const r = rig({ loseReply: true }); await settle(); + r.button('Create an invitation').click(); + r.roots['access-invites'].all().find(e => e.tag === 'form').submit(); await settle(); + r.refresh(); await settle(); + assert.equal(r.posts.length, 1); + r.button('Get the six-character code').click(); await settle(); + assert.equal(r.all().find(e => e['aria-label'] === 'Invitation code').value, 'K7M-4PX'); +}); + +test('publication failure survives refresh and a Cloud restart can replace the code without a new grant', async () => { + const r=rig();await settle();r.button('Create an invitation').click(); + r.roots['access-invites'].all().find(e=>e.tag==='form').submit();await settle(); + r.alias.fails=true;r.button('Check status and code').click();await settle(); + assert.ok(r.all().some(e=>e.textContent==='closed test publication failure')); + r.alias.fails=false;r.alias.code='P3K-9MX';r.button('Check status and code').click();await settle(); + assert.equal(r.all().find(e=>e['aria-label']==='Invitation code').value,'P3K-9MX'); + assert.equal(r.posts.length,1,'republishing does not create a second Core invitation'); +}); diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index db8a4b38a..180ccf58a 100644 --- a/web/settings/tabs/access.js +++ b/web/settings/tabs/access.js @@ -54,6 +54,7 @@ // The box's answers, kept while Settings is open: the grant on screen // holds a code that worked once, and switching tabs must not lose it. var state = { + invites: null, inviteDraft: null, list: null, // GET /api/agents unavailable: "", // why the box has no agents, when it has none auth: null, // GET /api/auth/status @@ -414,10 +415,17 @@ }); }), fetch("/api/auth/status").then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), + fetch("/api/agent-invites").then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }), ]).then(function (got) { state.unavailable = got[0].unavailable ? "off" : ""; state.list = got[0].unavailable ? null : got[0]; - state.auth = got[1]; + state.auth = got[1]; state.invites = got[2]; + if(state.inviteDraft && state.inviteDraft.request && state.invites) { + var latest=(state.invites.invitations || []).find(function(v){return v.id===state.inviteDraft.request.id;}); + if(latest) { + state.inviteDraft.receipt=latest; + } + } }); } @@ -452,6 +460,7 @@ drawHeld(); drawRequests(); drawRows(); + drawInvites(); } function drawReady(ctx) { @@ -596,8 +605,9 @@ // it on, caps no higher than given, and a length. limits are the most it // may be given; current, when set, is a row being changed. function grantForm(form, given, registry, current) { + var suffix = given.idSuffix || (current ? current.id : "new"); var split = splitGrant(given.scopes, given.ops, registry); - form.appendChild(el("p", { className: "access-heading", text: current ? "It may" : "It asks to" })); + form.appendChild(el("p", { className: "access-heading", text: given.heading || (current ? "It may" : "It asks to") })); split.reads.forEach(function (scope) { form.appendChild(checkRow("read", scope, scopeWords(scope), true)); }); @@ -608,7 +618,7 @@ if (split.groups.length) { var controlOn = !!current; - var control = el("input", { type: "checkbox", id: "access-control-" + (current ? current.id : "new") }); + var control = el("input", { type: "checkbox", id: "access-control-" + suffix }); control.checked = controlOn; var label = current ? "It may control" : "Also let it control"; form.appendChild(el("label", { className: "checkbox-row access-control-toggle" }, [control, el("span", { text: label })])); @@ -622,9 +632,9 @@ g.ops.forEach(function (op) { detail.appendChild(checkRow("op", op, opWords(op), true)); }); if (g.ops.some(function (op) { return capped(op, registry); })) { var holdMin = Math.floor((given.caps.holdS || 0) / 60); - detail.appendChild(numberField("access-hold-" + (current ? current.id : "new"), "Longest hold", + detail.appendChild(numberField("access-hold-" + suffix, "Longest hold", holdMin, holdMin, 1, 1, "minutes")); - detail.appendChild(numberField("access-power-" + (current ? current.id : "new"), "Highest power", + detail.appendChild(numberField("access-power-" + suffix, "Highest power", kwExact(given.caps.maxW), kwExact(given.caps.maxW), 0.001, "any", "kW")); } }); @@ -639,7 +649,7 @@ return { perMin: given.caps.perMin || 0 }; } - function readChoice(form, current) { + function readChoice(form, current, overrideSuffix) { var reads = [], ops = [], calls = []; form.querySelectorAll('input[name="read"]').forEach(function (b) { if (b.checked) reads.push(b.value); }); form.querySelectorAll('input[name="call"]').forEach(function (b) { if (b.checked) calls.push(b.value); }); @@ -647,7 +657,7 @@ if (!control || control.checked) { form.querySelectorAll('input[name="op"]').forEach(function (b) { if (b.checked) ops.push(b.value); }); } - var suffix = current ? current.id : "new"; + var suffix = overrideSuffix || (current ? current.id : "new"); var hold = document.getElementById("access-hold-" + suffix); var power = document.getElementById("access-power-" + suffix); var length = form.querySelector('select[name="length"]'); @@ -947,6 +957,144 @@ }); } + function base64Bytes(bytes, url) { + var text = btoa(Array.from(bytes, function (b) { return String.fromCharCode(b); }).join("")); + return url ? text.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") : text; + } + + // Deterministic CBOR {v, box, home, secret}, as protocol.AgentInviteCode. + // This needs no secure-context-only Web Crypto API on the box's LAN page. + function inviteCode(receipt, secret) { + var box = Uint8Array.from(atob(receipt.box), function (s) { return s.charCodeAt(0); }); + var home = String(receipt.home || ""); + if (!/^[a-z2-7]{26}$/.test(home) || box.length !== 32 || secret.length !== 32) throw new Error("The box returned an invalid invitation receipt."); + function ascii(s) { return Array.from(s, function (c) { return c.charCodeAt(0); }); } + return base64Bytes([0xa4,0x61,0x76,1,0x63].concat(ascii("box"),[0x58,32],Array.from(box),[0x64],ascii("home"),[0x78,26],ascii(home),[0x66],ascii("secret"),[0x58,32],Array.from(secret)), true); + } + + function inviteWhen(ms) { return new Date(ms).toLocaleString(undefined,{day:"numeric",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit",timeZoneName:"short"}); } + + var inviteTimer=null; + function drawInvites() { + var slot = document.getElementById("access-invites"); + if (!slot) return; + if(inviteTimer) clearTimeout(inviteTimer);inviteTimer=null; + slot.textContent = ""; + var info = state.invites; + if (!info || !info.invitations) { slot.appendChild(el("p", { text: "Sharing is not available on this box yet." })); return; } + slot.appendChild(el("p", { text: "Let someone use their own assistant to help with this home. Choose what they may do. The first person who uses the code gets a separate connection you can remove below." })); + var draft = state.inviteDraft; + if (draft && draft.receipt) { + var v = draft.receipt; + if(v.state==="pending" && !v.clockUnavailable && boxNow()>=v.claimUntilMs) v={...v,state:"expired"}; + if (v.state === "pending" && draft.code && boxNow()boxNow())) throw new Error("The code is not ready yet."); + draft.code=body.code;draft.codeExpiresAtMs=body.expiresAtMs;draft.publishError=""; + }).catch(function(e){ + draft.publishError=e.message || "Wait for the box, then try again.";throw e; + }); + } + var lastCtx = null; function refresh(ctx) { @@ -989,7 +1137,8 @@ '
' + "Assistants with access" + '

Checking the box…

' + - "
" + "" + + '
Share access with a code
' ); }, @@ -1008,6 +1157,7 @@ }; S.tabs.access._pure = { + inviteCode: inviteCode, SCOPE_WORDS: SCOPE_WORDS, OP_WORDS: OP_WORDS, normalizeCode: normalizeCode, diff --git a/web/style.css b/web/style.css index bee0503b0..4c76dec73 100644 --- a/web/style.css +++ b/web/style.css @@ -1878,6 +1878,21 @@ footer { } .access-code-row .btn-send { margin-top: 0; } .access-note { margin: 6px 0 0; font-size: 0.85rem; min-height: 1.2em; } +#access-invites .access-invite-code { + display: block; + box-sizing: border-box; + width: 10ch; + padding: 12px; + margin: 12px 0 4px; + color: var(--text); + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + font: 1.6rem/1.5 var(--mono, monospace); + letter-spacing: 0.08em; +} +#access-invites button { margin-right: 8px; } +#access-invites .access-card { overflow-wrap: anywhere; } .access-error { margin: 6px 0 0; color: var(--red-e); font-size: 0.85rem; } .access-error:empty { display: none; } .access-warn { margin: 4px 0; color: var(--amber); font-size: 0.85rem; line-height: 1.4; } From 229e0c171dbe34b8b4d862f39e01b89d7ed0686d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 20:22:31 +0200 Subject: [PATCH 2/2] Keep withdrawn invitations cancelled and reserve the Cloud home --- go/internal/api/api_agent_invites.go | 14 ++--- go/internal/api/api_agent_invites_test.go | 44 +++++++++++++- go/internal/appenroll/agent_invites.go | 13 +++++ go/internal/appenroll/agent_invites_test.go | 65 +++++++++++++++++++++ go/internal/appenroll/enroll.go | 4 ++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/go/internal/api/api_agent_invites.go b/go/internal/api/api_agent_invites.go index bc3c0ae82..33bb05611 100644 --- a/go/internal/api/api_agent_invites.go +++ b/go/internal/api/api_agent_invites.go @@ -56,13 +56,13 @@ func (s *Server) handleAgentInvites(w http.ResponseWriter, r *http.Request) { writeAppLinkError(w, http.StatusBadRequest, "send one invitation verifier") return } - if m.Home == "" { - var err error - m.Home, err = admin.PrepareAgentInviteHome() - if err != nil { - agentError(w, err) - 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) } diff --git a/go/internal/api/api_agent_invites_test.go b/go/internal/api/api_agent_invites_test.go index 5d94bccff..068f3e0a9 100644 --- a/go/internal/api/api_agent_invites_test.go +++ b/go/internal/api/api_agent_invites_test.go @@ -13,11 +13,19 @@ import ( type inviteAdminStub struct { *stubAgents - created []protocol.AgentInviteCreate + created []protocol.AgentInviteCreate + prepared int + preparedHome string } -func (a *inviteAdminStub) AgentInvites() []protocol.AgentInvite { return []protocol.AgentInvite{} } -func (a *inviteAdminStub) PrepareAgentInviteHome() (string, error) { return a.home, nil } +func (a *inviteAdminStub) AgentInvites() []protocol.AgentInvite { return []protocol.AgentInvite{} } +func (a *inviteAdminStub) PrepareAgentInviteHome() (string, error) { + a.prepared++ + if a.preparedHome != "" { + return a.preparedHome, nil + } + return a.home, nil +} func (a *inviteAdminStub) CreateAgentInvite(_ string, m protocol.AgentInviteCreate) (protocol.AgentInvite, error) { a.created = append(a.created, m) return protocol.AgentInvite{ID: m.ID, Home: m.Home, State: "pending"}, nil @@ -62,3 +70,33 @@ func TestInvitationCreationUsesTheSameOwnerGateAndNeverStoresTheBrowserSecret(t }) } } + +func TestLocalInvitationReservesTheCurrentHomeEvenWhenBrowserSuppliesOne(t *testing.T) { + for _, supplied := range []string{"", "abcdefghijklmnopqrstuvwx23", "oldhomeabcdefghijklmnopqr23"} { + t.Run(supplied, func(t *testing.T) { + secret := bytes.Repeat([]byte{0x72}, 32) + a := &inviteAdminStub{stubAgents: newStubAgents("abcdefghijklmnopqrstuvwx23"), preparedHome: "newhomeabcdefghijklmnopqr23"} + body, _ := json.Marshal(map[string]any{"id": "request1", "home": supplied, "secret": base64.StdEncoding.EncodeToString(secret)}) + s := New(&Deps{Agents: a}) + r := localRequest(http.MethodPost, "/api/agent-invites", string(body)) + r = r.WithContext(withLANSecret(r.Context(), true)) + w := httptest.NewRecorder() + s.handleAgentInvites(w, r) + if w.Code != http.StatusOK || a.prepared != 1 || len(a.created) != 1 { + t.Fatalf("unreserved create: %d %s, prepared %d", w.Code, w.Body.String(), a.prepared) + } + if a.created[0].Home != a.preparedHome || !bytes.Equal(a.created[0].Hash, protocol.AgentInviteHash(a.preparedHome, secret)) { + t.Fatalf("wrong home binding: %+v", a.created[0]) + } + var answer struct { + Invitation protocol.AgentInvite `json:"invitation"` + } + if err := json.Unmarshal(w.Body.Bytes(), &answer); err != nil { + t.Fatal(err) + } + if answer.Invitation.Home != a.preparedHome { + t.Fatal("receipt did not name reserved home") + } + }) + } +} diff --git a/go/internal/appenroll/agent_invites.go b/go/internal/appenroll/agent_invites.go index 61afcf7d1..6ec865313 100644 --- a/go/internal/appenroll/agent_invites.go +++ b/go/internal/appenroll/agent_invites.go @@ -37,6 +37,19 @@ func (i *Identity) inviteOwnerLocked(by string) bool { return m != nil && m.role == apiauth.RoleOwner && m.agent == nil } +// Cancel pending approvals in the same saved snapshot as the owner's removal +// or demotion. Restoring that phone must never restore an old invitation. +func (i *Identity) cancelOwnerInvitesLocked(owner string) { + now, _ := i.wallLocked() + for id, v := range i.invites { + if v.State == "pending" && v.ApprovedBy == owner { + v.State = "cancelled" + v.CancelledAtMs = now + i.invites[id] = v + } + } +} + func (i *Identity) inviteViewLocked(v storedInvite, now int64, clock bool) protocol.AgentInvite { r := v.AgentInvite r.Box = bytes.Clone(r.Box) diff --git a/go/internal/appenroll/agent_invites_test.go b/go/internal/appenroll/agent_invites_test.go index ba1fcdffe..58d9cdf61 100644 --- a/go/internal/appenroll/agent_invites_test.go +++ b/go/internal/appenroll/agent_invites_test.go @@ -282,3 +282,68 @@ func TestCancelledInvitationCannotReturnAfterReceiptPruning(t *testing.T) { t.Fatal("cancelled code admitted a recipient") } } + +func TestOwnerRestorationNeverRevivesAnInvitation(t *testing.T) { + for _, change := range []string{"demote", "revoke"} { + t.Run(change, func(t *testing.T) { + f := newInviteFixture(t) + f.make(t) + id := f.b.id + pair(t, id, apiauth.RoleOwner) + id.mu.Lock() + fullKey, _ := id.findLocked(f.b.owner.DeviceID) + id.mu.Unlock() + ownerKey, err := base64.RawURLEncoding.DecodeString(fullKey) + if err != nil { + t.Fatal(err) + } + recipient := appKey(t) + payload := f.claim(t, recipient) + _, pendingCommit, err := id.CheckTunnel(recipient, payload) + if err != nil { + t.Fatal(err) + } + if change == "demote" { + if err := id.SetRole(f.b.owner.DeviceID, apiauth.RoleViewer); err != nil { + t.Fatal(err) + } + if err := id.SetRole(f.b.owner.DeviceID, apiauth.RoleOwner); err != nil { + t.Fatal(err) + } + } else { + if _, err := id.Revoke(f.b.owner.DeviceID, OverASession); err != nil { + t.Fatal(err) + } + code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL) + if err != nil { + t.Fatal(err) + } + if _, err := id.Authorise(ownerKey, code); err != nil { + t.Fatal(err) + } + } + if _, err := pendingCommit(); err == nil { + t.Fatal("old proof committed after owner restoration") + } + again, err := LoadOrCreate(filepath.Join(filepath.Dir(id.path), "nova.key")) + if err != nil { + t.Fatal(err) + } + again.SetClock(func() (time.Time, bool) { return f.b.now, true }) + again.SetHome(func() string { return inviteHome }) + again.SetAgentOfferKeys(map[string][]byte{"test": f.offerKey.Pub()}) + for _, identity := range []*Identity{id, again} { + row := identity.AgentInvites()[0] + if row.State != "cancelled" || row.CancelledAtMs != f.b.ms() { + t.Fatalf("restored invitation: %+v", row) + } + if _, _, err := identity.CheckTunnel(recipient, payload); err == nil { + t.Fatal("owner restoration revived invitation") + } + if len(identity.Agents()) != 0 { + t.Fatal("cancelled invitation granted access") + } + } + }) + } +} diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index d0f2aec00..109027710 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -997,6 +997,9 @@ func (i *Identity) SetRole(id, role string) error { return ErrLastOwnerProtected } promoted := role == apiauth.RoleOwner + if meta.role == apiauth.RoleOwner { + i.cancelOwnerInvitesLocked(key) + } meta.role = role meta.epoch++ // The phone's watches hold what it holds, so their grants changed too. @@ -1126,6 +1129,7 @@ func (i *Identity) Revoke(id string, from Presence) ([]Removed, error) { i.mu.Unlock() return nil, ErrLastOwnerProtected } + i.cancelOwnerInvitesLocked(fullKey) gone := []string{fullKey} for key, other := range i.authorised { // A phone's watches go with it, and so do the agents it approved: