From f6ba3f97d8f06bba950e185fec4d147332bc3b1f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:11:48 +0200 Subject: [PATCH 1/3] Connect shared MCP invitations through short single-use codes --- .dockerignore | 2 + README.md | 23 +++ cmd/ftwcloudd/main.go | 2 + deploy/README.md | 8 ++ go.mod | 2 +- go.sum | 4 +- internal/keys/client.go | 7 + internal/keys/invite_test.go | 102 +++++++++++++ internal/keys/server.go | 45 +++++- internal/keys/wire.go | 17 +-- internal/mcp/agent.go | 28 +++- internal/mcp/e2e_test.go | 52 ++++--- internal/mcp/invite.go | 35 +++++ internal/mcp/invite_test.go | 105 ++++++++++++++ internal/oauth/invite.go | 96 +++++++++++++ internal/oauth/invite_alias.go | 215 ++++++++++++++++++++++++++++ internal/oauth/invite_alias_test.go | 144 +++++++++++++++++++ internal/oauth/invite_test.go | 85 +++++++++++ internal/oauth/oauth.go | 15 +- internal/oauth/oauth_test.go | 2 +- internal/oauth/page.go | 23 ++- internal/oauth/store.go | 5 + internal/oauth/token.go | 20 ++- internal/store/invitation.go | 25 ++++ internal/store/invite_test.go | 53 +++++++ internal/store/session.go | 16 ++- internal/store/store.go | 62 +++++++- internal/uplink/uplink.go | 25 +++- 28 files changed, 1163 insertions(+), 55 deletions(-) create mode 100644 internal/keys/invite_test.go create mode 100644 internal/mcp/invite.go create mode 100644 internal/mcp/invite_test.go create mode 100644 internal/oauth/invite.go create mode 100644 internal/oauth/invite_alias.go create mode 100644 internal/oauth/invite_alias_test.go create mode 100644 internal/oauth/invite_test.go create mode 100644 internal/store/invitation.go create mode 100644 internal/store/invite_test.go diff --git a/.dockerignore b/.dockerignore index 09339a1..95e303c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ state *.key *.pem +go.work +go.work.sum diff --git a/README.md b/README.md index 2a1ac71..48b71c6 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,29 @@ to it yet. or the box purging its home; a key the keys part could not forget stays queued until it has. Only each home's newest roster is applied. +- **An owner can share access with six characters.** The owner creates a + strong, single-use invitation on Core, with chosen rights and access expiry. + Their page or app publishes that proof through TLS to `POST /oauth/invitations`. + The service checks its home, box and verifier against the current signed + roster, then returns a unique code such as `K7M-4PX`, valid for at most ten + minutes. No cookie grants the right to publish. The recipient pastes the + short code into their own OAuth connection page. Its original browser + cookie, Origin and separate rate limits apply before lookup. A pending key + can open only a typed invitation Noise handshake; Core consumes the + invitation after proof of that key and intersects the chosen rights with + the recipient's request. Only the new signed roster permits Keep, PKCE + completion and normal MCP access. Cancellation and revocation still belong + to Core. The short code does not give an owner role. + + The alias-to-proof mapping stays in memory and expires within ten minutes; + the normal minute sweep removes expired mappings. SQLite holds only keyed + digest reservations and expiry. A MAC key derived from `oauth-route.key`, + outside that database, protects those reservations. A restart cannot assign + an unexpired code to a different invitation. After a restart the owner can + republish the same proof for a working code without another Core grant. + No raw code, proof or secret enters a log, URL or database. This mapping + currently requires the single OAuth process used by the deployment. + - **MCP at `/mcp`, Streamable HTTP.** It speaks revision 2026-07-28, which is stateless: each request carries its version and capabilities in `_meta`, mirrored in `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`, diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index ce65868..4867ad8 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -182,6 +182,8 @@ func serve(args []string) error { // takes no agents yet, and the tools say so. agents := mcp.NewPool(st, up, agentKeys, mcp.RosterBoxKey(reg), log) auth.OnRevoke = agents.Drop + auth.ClaimInvite = agents.ClaimInvite + auth.Invitation = st.Invitation srv.MCP = mcp.New(auth, agents, log) // Alerts: the box's signed events to its phones through Apple, and diff --git a/deploy/README.md b/deploy/README.md index 756db7f..c1c603f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -41,6 +41,14 @@ Every AWS resource carries the tag `Project=ftw-cloud`. - The host runs one image, named by its digest, never by a tag. `/opt/ftw-cloud` is checked out at the commit that image was built from and supplies `compose.yml`. +- Six-character MCP invitation codes expire within ten minutes. Their + mapping stays in the OAuth process's memory until the minute sweep removes + it. Keep one OAuth instance until that mapping has shared + routing. A restart ends unused short codes; the owner can check status and + republish the same strong invitation for a working code. Existing grants + and tokens remain valid. Keep `oauth-route.key` with the service state: + its derived MAC also keeps unexpired code reservations distinct after a + restart, without storing a bearer mapping in SQLite. - The Docker volume `ftw-cloud_ftw-cloud-state` holds the SQLite registry (home ids, box keys, signed rosters, tombstones) and the ACME cache, on the encrypted root disk. With the reviewed statistics policy enabled, it also diff --git a/go.mod b/go.mod index 896cf73..f974bc9 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261018181143-a17455aa960e + github.com/srcfl/ftw/go/protocol v0.0.0-20261018181148-499d5de65104 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 7c9b984..528c4e3 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261018181143-a17455aa960e h1:yLxDPp/cIVdkkMfkpP9cYfeceVNFDHpTdDzFzRnKFPU= -github.com/srcfl/ftw/go/protocol v0.0.0-20261018181143-a17455aa960e/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261018181148-499d5de65104 h1:nwfkV8mMubBLB+cx7mxFYrVT6Asgl8ctXWfjXci8MZU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261018181148-499d5de65104/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/keys/client.go b/internal/keys/client.go index 77fc564..e4b08a6 100644 --- a/internal/keys/client.go +++ b/internal/keys/client.go @@ -172,3 +172,10 @@ func (c *Client) End(ctx context.Context, sess string) error { _, err := c.call(ctx, request{Op: opClose, Sess: sess}) return err } + +// OpenInvite opens a pending key only for a typed agent invitation claim. +// It grants no ordinary session until Core claims it and its roster arrives. +func (c *Client) OpenInvite(ctx context.Context, kid string, boxStatic, payload []byte) (string, []byte, error) { + resp, err := c.call(ctx, request{Op: opOpenInvite, Kid: kid, RS: boxStatic, D: payload}) + return resp.Sess, resp.D, err +} diff --git a/internal/keys/invite_test.go b/internal/keys/invite_test.go new file mode 100644 index 0000000..62a251a --- /dev/null +++ b/internal/keys/invite_test.go @@ -0,0 +1,102 @@ +package keys + +import ( + "bytes" + "context" + "errors" + "sync" + "testing" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/appwire" +) + +func invitePayload(t *testing.T, s *Server, kid string, pub []byte) []byte { + t.Helper() + o := protocol.GrantOffer{V: 1, Key: pub, Role: protocol.RoleAgent, Scopes: []string{protocol.ScopeLiveRead}, TTLS: 86400, ExpMs: s.now().UnixMilli() + 60_000} + raw, _ := protocol.Marshal(o) + sig, err := s.signOffer(kid, raw) + if err != nil { + t.Fatal(err) + } + p, err := (protocol.AgentInviteClaim{Secret: bytes.Repeat([]byte{0x81}, 32), Offer: protocol.SignedOffer{O: raw, Kid: OfferKid(s.offer.Public()), Sig: sig}}).Payload() + if err != nil { + t.Fatal(err) + } + return p +} + +func TestPendingKeyOpensOnlyTypedInvitationForItsOwnOffer(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + defer c.Close() + b := newBox(t) + kid, pub, err := c.New(ctx) + if err != nil { + t.Fatal(err) + } + payload := invitePayload(t, s, kid, pub) + if _, _, err := c.OpenInvite(ctx, kid, b.static.Public(), nil); !errors.Is(err, ErrRefused) { + t.Fatalf("empty invitation: %v", err) + } + other, _, _ := c.New(ctx) + if _, _, err := c.OpenInvite(ctx, other, b.static.Public(), payload); !errors.Is(err, ErrRefused) { + t.Fatalf("other key's offer: %v", err) + } + ksess, msg1, err := c.OpenInvite(ctx, kid, b.static.Public(), payload) + if err != nil { + t.Fatal(err) + } + defer c.End(ctx, ksess) + responder, _ := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: b.static, Prologue: SessionPrologue(b.static.Public())}) + got, err := responder.ReadMessage(msg1) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("typed Noise payload: %v", err) + } + if _, _, err := c.Open(ctx, kid, b.static.Public()); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("pending ordinary session: %v", err) + } + if err := c.Keep(ctx, kid); err != nil { + t.Fatal(err) + } + if _, _, err := c.OpenInvite(ctx, kid, b.static.Public(), payload); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("kept fallback: %v", err) + } +} + +type pausedUnwrap struct { + Sealer + entered, release chan struct{} + once sync.Once +} + +func (s *pausedUnwrap) Unwrap(ctx context.Context, kid string, raw []byte) ([]byte, error) { + s.once.Do(func() { close(s.entered) }) + <-s.release + return s.Sealer.Unwrap(ctx, kid, raw) +} + +func TestForgetWinsOverAnInvitationStillUnwrappingItsKey(t *testing.T) { + s, sealer, _ := newTestServer(t) + b := newBox(t) + kid, pub, err := s.newKey() + if err != nil { + t.Fatal(err) + } + payload := invitePayload(t, s, kid, pub) + paused := &pausedUnwrap{Sealer: sealer, entered: make(chan struct{}), release: make(chan struct{})} + s.sealer = paused + done := make(chan error, 1) + go func() { _, _, err := s.openPayload(kid, b.static.Public(), payload, true); done <- err }() + <-paused.entered + if err := s.forget(kid); err != nil { + t.Fatal(err) + } + close(paused.release) + if err := <-done; !errors.Is(err, ErrUnknownKey) { + t.Fatalf("forgotten key opened: %v", err) + } + if len(s.sessions) != 0 { + t.Fatal("forgotten key left a session") + } +} diff --git a/internal/keys/server.go b/internal/keys/server.go index 2d8d37b..135e04b 100644 --- a/internal/keys/server.go +++ b/internal/keys/server.go @@ -1,6 +1,7 @@ package keys import ( + "bytes" "context" "crypto/ecdh" "crypto/rand" @@ -15,6 +16,7 @@ import ( "sync" "time" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/appwire" ) @@ -166,6 +168,8 @@ func (s *Server) handle(req request) response { err = s.keep(req.Kid) case opForget: err = s.forget(req.Kid) + case opOpenInvite: + resp.Sess, resp.D, err = s.openPayload(req.Kid, req.RS, req.D, true) case opOpen: resp.Sess, resp.D, err = s.open(req.Kid, req.RS) case opFinish: @@ -311,11 +315,17 @@ func (s *Server) forget(kid string) error { // unseal reads a kept key. A key whose approval is still pending opens no // session. -func (s *Server) unseal(kid string) ([]byte, error) { +func (s *Server) unseal(kid string) ([]byte, error) { return s.unsealFrom(kid, false) } + +func (s *Server) unsealFrom(kid string, pending bool) ([]byte, error) { if !validKid(kid) { return nil, ErrUnknownKey } - raw, err := os.ReadFile(filepath.Join(s.dir, "kept", kid)) + kind := "kept" + if pending { + kind = "pending" + } + raw, err := os.ReadFile(filepath.Join(s.dir, kind, kid)) if err != nil { return nil, ErrUnknownKey } @@ -323,6 +333,9 @@ func (s *Server) unseal(kid string) ([]byte, error) { if err := decMode.Unmarshal(raw, &rec); err != nil || rec.Kid != kid || len(rec.Pub) != appwire.DHBytes { return nil, ErrUnknownKey } + if pending && !s.now().Before(time.UnixMilli(rec.At).Add(PendingFor)) { + return nil, ErrUnknownKey + } ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) defer cancel() dek, err := s.sealer.Unwrap(ctx, kid, rec.DEK) @@ -350,6 +363,10 @@ func (s *Server) unseal(kid string) ([]byte, error) { // The secret is wiped once message 1 is written; the handshake state keeps // what it needs to read message 2. func (s *Server) open(kid string, rs []byte) (string, []byte, error) { + return s.openPayload(kid, rs, nil, false) +} + +func (s *Server) openPayload(kid string, rs, payload []byte, pending bool) (string, []byte, error) { if len(rs) != appwire.DHBytes { return "", nil, ErrRefused } @@ -359,7 +376,7 @@ func (s *Server) open(kid string, rs []byte) (string, []byte, error) { if full { return "", nil, ErrBusy } - secret, err := s.unseal(kid) + secret, err := s.unsealFrom(kid, pending) if err != nil { return "", nil, err } @@ -368,13 +385,26 @@ func (s *Server) open(kid string, rs []byte) (string, []byte, error) { if err != nil { return "", nil, err } + if pending { + claim, err := protocol.ParseAgentInviteClaim(payload) + if err != nil || s.offer == nil { + return "", nil, ErrRefused + } + pub := s.offer.Public() + offer, err := protocol.OpenOffer(map[string][]byte{OfferKid(pub): pub}, claim.Offer, s.now().UnixMilli()) + if err != nil || offer.Role != protocol.RoleAgent || !bytes.Equal(offer.Key, kp.Public()) { + return "", nil, ErrRefused + } + } else if len(payload) > 0 { + return "", nil, ErrRefused + } init, err := appwire.NewInitiator(appwire.InitiatorOptions{StaticKey: kp, RemoteStatic: rs, Prologue: SessionPrologue(rs)}) if err != nil { return "", nil, err } // A paired device's message 1 carries no payload: the box knows it by // its static key. - msg1, err := init.WriteMessage(nil) + msg1, err := init.WriteMessage(payload) if err != nil { return "", nil, err } @@ -388,6 +418,13 @@ func (s *Server) open(kid string, rs []byte) (string, []byte, error) { if len(s.sessions) >= MaxSessions { return "", nil, ErrBusy } + kind := "kept" + if pending { + kind = "pending" + } + if _, err := os.Stat(filepath.Join(s.dir, kind, kid)); err != nil { + return "", nil, ErrUnknownKey + } s.sessions[sess] = &session{kid: kid, init: init, used: s.now()} return sess, msg1, nil } diff --git a/internal/keys/wire.go b/internal/keys/wire.go index 9f18101..393d018 100644 --- a/internal/keys/wire.go +++ b/internal/keys/wire.go @@ -31,14 +31,15 @@ import ( // Operations. const ( - opNew = "new" // make a key pair; answers kid and pub - opKeep = "keep" // the owner approved the agent: keep its key - opForget = "forget" // delete a key and close its sessions - opOpen = "open" // unseal kid, start a session to rs; answers sess and d (message 1) - opFinish = "finish" // read message 2 in d - opSeal = "seal" // encrypt frame d of sess; answers d - opUnseal = "unseal" // decrypt message d of sess; answers d - opClose = "close" // end sess + opNew = "new" // make a key pair; answers kid and pub + opKeep = "keep" // the owner approved the agent: keep its key + opForget = "forget" // delete a key and close its sessions + opOpenInvite = "open.invite" // pending agent key; only the typed invitation proof + opOpen = "open" // unseal kid, start a session to rs; answers sess and d (message 1) + opFinish = "finish" // read message 2 in d + opSeal = "seal" // encrypt frame d of sess; answers d + opUnseal = "unseal" // decrypt message d of sess; answers d + opClose = "close" // end sess // offer.pub and offer.sign are in offer.go. ) diff --git a/internal/mcp/agent.go b/internal/mcp/agent.go index 2635848..43137cd 100644 --- a/internal/mcp/agent.go +++ b/internal/mcp/agent.go @@ -1,6 +1,7 @@ package mcp import ( + "bytes" "context" "crypto/rand" "encoding/binary" @@ -350,11 +351,22 @@ func (c *agentConn) idleSince(now time.Time) time.Duration { // from the keys part, message 2 from the box. Then hello goes, without // waiting for its answer. func (c *agentConn) open(ctx context.Context, sub oauth.Subject) error { + return c.openFor(ctx, sub, nil) +} + +func (c *agentConn) openFor(ctx context.Context, sub oauth.Subject, invite *oauth.InviteClaim) error { boxStatic, ok := c.p.boxKey(sub.Home) if !ok { return errNoAgents } - h, err := c.p.store.For(store.Principal{Kind: store.Agent, Home: sub.Home, DeviceKey: sub.Key}) + principal := store.Principal{Kind: store.Agent, Home: sub.Home, DeviceKey: sub.Key} + if invite != nil { + if !bytes.Equal(boxStatic, invite.Box) { + return errDenied + } + principal.Kind, principal.Invite = store.AgentInvite, invite.Hash + } + h, err := c.p.store.For(principal) if err != nil { return errDenied } @@ -380,7 +392,19 @@ func (c *agentConn) open(ctx context.Context, sub oauth.Subject) error { c.msg2, c.opened = make(chan []byte, 1), make(chan struct{}) go c.read(sess, sid) - ksess, msg1, err := c.p.keys.Open(ctx, sub.Kid, boxStatic) + var ksess string + var msg1 []byte + if invite != nil { + k, ok := c.p.keys.(interface { + OpenInvite(context.Context, string, []byte, []byte) (string, []byte, error) + }) + if !ok { + return errNoAgents + } + ksess, msg1, err = k.OpenInvite(ctx, sub.Kid, boxStatic, invite.Payload) + } else { + ksess, msg1, err = c.p.keys.Open(ctx, sub.Kid, boxStatic) + } if err != nil { c.p.log.Warn("the keys part could not open an agent session", "err", err) return userError("FTW Cloud could not open a session for this assistant.") diff --git a/internal/mcp/e2e_test.go b/internal/mcp/e2e_test.go index af34ce4..f42113b 100644 --- a/internal/mcp/e2e_test.go +++ b/internal/mcp/e2e_test.go @@ -58,7 +58,8 @@ type world struct { mu sync.Mutex boxes map[string]*stubBox // heard is each /mcp request's version and method headers. - heard []string + heard []string + claimInvitation func(rid string, cookie *http.Cookie) } func newWorld(t *testing.T, opts ...func(*world)) *world { @@ -89,6 +90,8 @@ func newWorld(t *testing.T, opts ...func(*world)) *world { w.mcp = New(w.auth, w.pool, log) w.up.OnRoster = w.auth.Roster w.auth.OnRevoke = w.pool.Drop + w.auth.ClaimInvite = w.pool.ClaimInvite + w.auth.Invitation = w.st.Invitation for _, o := range opts { o(w) } @@ -130,15 +133,17 @@ type agentGrant struct { } type stubBox struct { - t *testing.T - w *world - key *ecdsa.PrivateKey - home string - static appwire.KeyPair - conn *websocket.Conn - wmu sync.Mutex - seq uint64 - rseq uint64 + inviteAdmission func(key, payload []byte) (func(), bool) + inviteHints []cloudwire.Pending + t *testing.T + w *world + key *ecdsa.PrivateKey + home string + static appwire.KeyPair + conn *websocket.Conn + wmu sync.Mutex + seq uint64 + rseq uint64 mu sync.Mutex agents map[string]*agentGrant // by device id @@ -171,9 +176,10 @@ type stubBox struct { } type boxSession struct { - sid uint32 - tr *appwire.Transport - dev string + claim func() + sid uint32 + tr *appwire.Transport + dev string } func (w *world) connectBox() *stubBox { @@ -315,6 +321,7 @@ func (b *stubBox) sendRoster() { Agent: &cloudwire.Agent{Scopes: a.scopes, Ops: a.ops, Caps: a.caps, ExpiresAtMs: a.until.UnixMilli(), Principal: protocol.Principal{Client: "Test assistant", ClientID: a.clientID}}}) } + r.Pending = append([]cloudwire.Pending(nil), b.inviteHints...) noNoise := b.noNoise b.mu.Unlock() // The box names its Noise key in the roster it signs. @@ -374,6 +381,10 @@ func (b *stubBox) inner(t cloudwire.Tun) { if err != nil { return } + if s.claim != nil { + s.claim() + s.claim = nil + } b.mu.Lock() b.frames = append(b.frames, frame) b.mu.Unlock() @@ -388,12 +399,17 @@ func (b *stubBox) inner(t cloudwire.Tun) { if err != nil { return } - if _, err := hs.ReadMessage(t.D); err != nil { + payload, err := hs.ReadMessage(t.D) + if err != nil { return } b.mu.Lock() + var claim func() a, ok := b.allowed(t.Dev) ok = ok && bytes.Equal(a.key, hs.RemoteStatic()) && cloudwire.DeviceID(hs.RemoteStatic()) == t.Dev + if !ok && b.inviteAdmission != nil && cloudwire.DeviceID(hs.RemoteStatic()) == t.Dev { + claim, ok = b.inviteAdmission(hs.RemoteStatic(), payload) + } if ok { b.handshakes++ } @@ -409,7 +425,7 @@ func (b *stubBox) inner(t cloudwire.Tun) { reply, _ := hs.WriteMessage(nil) split, _ := hs.Split() b.mu.Lock() - b.sessions[t.Dev] = &boxSession{sid: t.Sid, tr: appwire.NewTransport(split), dev: t.Dev} + b.sessions[t.Dev] = &boxSession{sid: t.Sid, tr: appwire.NewTransport(split), dev: t.Dev, claim: claim} b.mu.Unlock() b.tun(cloudwire.Tun{Dev: t.Dev, Sid: t.Sid, D: reply}) } @@ -778,7 +794,11 @@ func (w *world) connect(b *stubBox, scope string, granted ...string) *assistant if len(granted) == 0 { granted = oauth.ReadScopes } - b.approve(agentKey, reg.ClientID, granted...) + if w.claimInvitation != nil { + w.claimInvitation(rid, cookie) + } else { + b.approve(agentKey, reg.ClientID, granted...) + } var ev string select { case ev = <-events: diff --git a/internal/mcp/invite.go b/internal/mcp/invite.go new file mode 100644 index 0000000..dd46788 --- /dev/null +++ b/internal/mcp/invite.go @@ -0,0 +1,35 @@ +package mcp + +import ( + "context" + + "github.com/srcfl/ftw-cloud/internal/oauth" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// ClaimInvite runs one short bootstrap session. It never puts a pending key +// in the warm MCP pool and never grants a token; OAuth waits for the roster. +func (p *Pool) ClaimInvite(ctx context.Context, m oauth.InviteClaim) error { + if p.draining.Load() { + return errRestarting + } + c := &agentConn{p: p, home: m.Home, agent: cloudwire.DeviceID(m.Key), ready: make(chan struct{}), done: make(chan struct{}), turn: make(chan struct{}, 1), + waiting: map[uint32]chan appwire.Envelope{}, byCmd: map[string]chan appwire.Envelope{}, helloReady: make(chan struct{}), used: p.now()} + defer c.close(nil) + sub := oauth.Subject{Home: m.Home, Agent: c.agent, Kid: m.Kid, Key: m.Key} + if err := c.openFor(ctx, sub, &m); err != nil { + return err + } + select { + case <-c.helloReady: + c.mu.Lock() + err := c.helloErr + c.mu.Unlock() + return err + case <-c.done: + return errEnded + case <-ctx.Done(): + return errTimeout + } +} diff --git a/internal/mcp/invite_test.go b/internal/mcp/invite_test.go new file mode 100644 index 0000000..2ace25c --- /dev/null +++ b/internal/mcp/invite_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Core's own tunnel suite tests consumption at the first Noise proof. This +// TLS test covers the recipient page, pending key, tunnel, signed roster, +// Keep, PKCE and the resulting ordinary MCP read together. +func TestAnInvitationConnectsTheRecipientsOwnMCPKey(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + secret := bytes.Repeat([]byte{0x98}, 32) + keyClient := w.keys.Local() + defer keyClient.Close() + pub, err := keyClient.OfferKey(context.Background()) + if err != nil { + t.Fatal(err) + } + claimed := make(chan struct{}, 1) + b.mu.Lock() + b.inviteHints = []cloudwire.Pending{{Kind: protocol.RoleAgent, Hash: protocol.AgentInviteHash(b.home, secret), TTLS: 7200}} + b.inviteAdmission = func(key, payload []byte) (func(), bool) { + claim, err := protocol.ParseAgentInviteClaim(payload) + if err != nil || !bytes.Equal(claim.Secret, secret) { + return nil, false + } + offer, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, claim.Offer, time.Now().UnixMilli()) + if err != nil || !bytes.Equal(offer.Key, key) { + return nil, false + } + return func() { + b.mu.Lock() + b.inviteHints = nil + b.inviteAdmission = nil + b.mu.Unlock() + b.approve(key, offer.ClientID, protocol.ScopeLiveRead) + claimed <- struct{}{} + }, true + } + b.mu.Unlock() + b.sendRoster() + code, err := (protocol.AgentInviteCode{V: 1, Home: b.home, Box: b.static.Public(), Secret: secret}).Encode() + if err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _, ok := w.st.Invitation(b.home, protocol.AgentInviteHash(b.home, secret), b.static.Public()) + return ok + }) + publishBody, _ := json.Marshal(map[string]string{"invitation": code}) + response, err := w.client().Post(w.srv.URL+"/oauth/invitations", "application/json", bytes.NewReader(publishBody)) + if err != nil { + t.Fatal(err) + } + var alias struct{ Code string } + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&alias) != nil || len(alias.Code) != 7 { + t.Fatal("strong invitation did not get a short code") + } + response.Body.Close() + code = alias.Code + w.claimInvitation = func(rid string, cookie *http.Cookie) { + body, _ := json.Marshal(map[string]string{"code": code}) + r, _ := http.NewRequest(http.MethodPost, w.srv.URL+"/oauth/requests/"+rid+"/invite", bytes.NewReader(body)) + r.Header.Set("Origin", "https://cloud.ftw.energy") + r.Header.Set("Content-Type", "application/json") + r.AddCookie(cookie) + resp, err := w.client().Do(r) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + t.Fatalf("invitation: %d %s", resp.StatusCode, data) + } + } + a := w.connect(b, protocol.ScopeLiveRead+" "+protocol.ScopeSetupWrite) + select { + case <-claimed: + default: + t.Fatal("no proved claim") + } + if a.scope != protocol.ScopeLiveRead { + t.Fatalf("invitation widened rights: %s", a.scope) + } + _, failed, message := a.call("status", nil) + if failed { + t.Fatalf("claimed key did not read its own home: %s", message) + } + b.remove(a.agentKey) + waitFor(t, func() bool { + return a.rpc("tools/call", map[string]any{"name": "status"}, true).Status == http.StatusUnauthorized + }) +} diff --git a/internal/oauth/invite.go b/internal/oauth/invite.go new file mode 100644 index 0000000..fd6232b --- /dev/null +++ b/internal/oauth/invite.go @@ -0,0 +1,96 @@ +package oauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +// InviteClaim holds one browser's claim only in memory. Neither its bearer +// secret nor its encrypted handshake payload belongs in OAuth storage. +type InviteClaim struct { + Home, Kid string + Key, Box, Hash, Payload []byte +} + +func (s *Server) serveInvite(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + rid := r.PathValue("rid") + id, err := base64.RawURLEncoding.DecodeString(rid) + if err != nil || len(id) != 16 || r.Header.Get("Origin") != Issuer { + http.Error(w, "Open this connection page from your assistant.", http.StatusForbidden) + return + } + ck, err := r.Cookie(cookieName(rid)) + if err != nil || !s.st.browserMatches(rid, ck.Value) { + http.Error(w, "Return to the browser that started this connection.", http.StatusForbidden) + return + } + if s.limited(&s.inviteClaimLimit, s.inviteClaimByIP, r) { + http.Error(w, "Too many attempts. Try again shortly.", http.StatusTooManyRequests) + return + } + c, ok, err := s.st.consentByRID(rid) + if err != nil || !ok { + http.NotFound(w, r) + return + } + if c.Status == statusApproved { + writeJSON(w, http.StatusOK, map[string]string{"state": "approved"}, false) + return + } + if c.Status != statusPending || !s.now().Before(c.Expires) { + http.Error(w, "This connection request ended. Connect again from your assistant.", http.StatusGone) + return + } + var body struct { + Code string `json:"code"` + } + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) + d.DisallowUnknownFields() + if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF { + http.Error(w, "Paste the invitation code from the owner.", http.StatusBadRequest) + return + } + code, err := s.resolveInvitation(strings.TrimSpace(body.Code)) + if err != nil { + http.Error(w, "The code is not ready or has ended. Keep this page open while FTW checks approval. If it stays here, check the six characters with the owner or ask for a new code.", http.StatusConflict) + return + } + if s.ClaimInvite == nil { + http.Error(w, "Invitations are not available. Ask the owner to approve this connection in FTW.", http.StatusServiceUnavailable) + return + } + raw, _ := base64.RawURLEncoding.DecodeString(c.Offer) + var offer protocol.SignedOffer + if protocol.UnmarshalSigned(raw, &offer) != nil { + http.Error(w, "Connect again from your assistant.", http.StatusGone) + return + } + payload, err := (protocol.AgentInviteClaim{Secret: code.Secret, Offer: offer}).Payload() + if err != nil { + http.Error(w, "Connect again from your assistant.", http.StatusGone) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + err = s.ClaimInvite(ctx, InviteClaim{Home: code.Home, Kid: c.Kid, Key: c.AgentKey, Box: code.Box, Hash: protocol.AgentInviteHash(code.Home, code.Secret), Payload: payload}) + // Core's signed roster is the sole approval. It may have arrived even + // when a Noise reply was lost or Keep already moved the pending key. + latest, exists, _ := s.st.consentByRID(rid) + if exists && latest.Status == statusApproved { + writeJSON(w, http.StatusOK, map[string]string{"state": "approved"}, false) + return + } + if err != nil { + http.Error(w, "The connection is not confirmed. Keep this page open. Check that the box is online and the code has not expired or been used, then try the same code again.", http.StatusConflict) + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"state": "waiting"}, false) +} diff --git a/internal/oauth/invite_alias.go b/internal/oauth/invite_alias.go new file mode 100644 index 0000000..26eec64 --- /dev/null +++ b/internal/oauth/invite_alias.go @@ -0,0 +1,215 @@ +package oauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +const inviteAliasFor = 10 * time.Minute +const inviteAliasesMax = 4096 + +type inviteAlias struct { + proof protocol.AgentInviteCode + until time.Time + fingerprint [32]byte +} +type inviteAliases struct { + mu sync.Mutex + codes map[string]inviteAlias + fingerprints map[[32]byte]string + random io.Reader +} + +func (a *inviteAliases) pruneLocked(now time.Time) { + for code, row := range a.codes { + if !now.Before(row.until) { + delete(a.codes, code) + delete(a.fingerprints, row.fingerprint) + } + } +} + +func (s *Server) pruneInvitationAliases(now time.Time) { + s.aliases.mu.Lock() + s.aliases.pruneLocked(now) + s.aliases.mu.Unlock() +} + +func normalizeInviteAlias(raw string) string { + raw = strings.ToUpper(strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(raw)) + if len(raw) != 6 { + return "" + } + for _, ch := range raw { + if !strings.ContainsRune(inviteAlphabet, ch) { + return "" + } + } + return raw +} + +func (s *Server) currentInvitation(p protocol.AgentInviteCode) (time.Time, bool) { + if s.Invitation == nil { + return time.Time{}, false + } + return s.Invitation(p.Home, protocol.AgentInviteHash(p.Home, p.Secret), p.Box) +} + +// The short code names a strong invitation held only in this process for +// ten minutes. It never replaces the box's 256-bit verifier or Noise proof. +func (s *Server) publishInvitation(p protocol.AgentInviteCode) (string, time.Time, error) { + until, ok := s.currentInvitation(p) + if !ok { + return "", time.Time{}, errors.New("waiting for the box's invitation") + } + canonical, err := p.Encode() + if err != nil { + return "", time.Time{}, err + } + fingerprint := sha256.Sum256([]byte(canonical)) + s.aliases.mu.Lock() + defer s.aliases.mu.Unlock() + if s.aliases.codes == nil { + s.aliases.codes = map[string]inviteAlias{} + s.aliases.fingerprints = map[[32]byte]string{} + } + now := s.now() + s.aliases.pruneLocked(now) + if code := s.aliases.fingerprints[fingerprint]; code != "" { + row := s.aliases.codes[code] + return code[:3] + "-" + code[3:], row.until, nil + } + if len(s.aliases.codes) >= inviteAliasesMax { + return "", time.Time{}, errors.New("invitation service is busy") + } + end := now.Add(inviteAliasFor) + if until.Before(end) { + end = until + } + for n := 0; n < 32; n++ { + var random [6]byte + reader := s.aliases.random + if reader == nil { + reader = rand.Reader + } + if _, err := io.ReadFull(reader, random[:]); err != nil { + return "", time.Time{}, err + } + var text [6]byte + for n, b := range random { + text[n] = inviteAlphabet[int(b)&31] + } + code := string(text[:]) + if _, used := s.aliases.codes[code]; used { + continue + } + reserved, err := s.st.reserveInvitationCode(s.route.invitationCodeHash(code), end, now) + if err != nil { + return "", time.Time{}, err + } + if !reserved { + continue + } + s.aliases.codes[code] = inviteAlias{proof: p, until: end, fingerprint: fingerprint} + s.aliases.fingerprints[fingerprint] = code + return code[:3] + "-" + code[3:], end, nil + } + return "", time.Time{}, errors.New("could not make an invitation code") +} + +// Only spent-code reservations survive a process restart. No alias, home, +// strong secret or bearer mapping is stored. An old unused code cannot name +// a different invitation after a restart while its ten minutes remain. +func (s *store) reserveInvitationCode(sum []byte, until, now time.Time) (bool, error) { + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + if _, err = tx.Exec(`DELETE FROM invitation_code_reservations WHERE expires_ms<=?`, now.UnixMilli()); err != nil { + return false, err + } + var count int + if err = tx.QueryRow(`SELECT count(*) FROM invitation_code_reservations`).Scan(&count); err != nil { + return false, err + } + if count >= inviteAliasesMax { + return false, errors.New("invitation service is busy") + } + result, err := tx.Exec(`INSERT OR IGNORE INTO invitation_code_reservations(hash,expires_ms) VALUES(?,?)`, sum, until.UnixMilli()) + if err != nil { + return false, err + } + inserted, err := result.RowsAffected() + if err != nil { + return false, err + } + return inserted == 1, tx.Commit() +} + +func (s *Server) resolveInvitation(raw string) (protocol.AgentInviteCode, error) { + // The strong format remains an internal publishing proof and lets an + // older client finish its existing invitation during an update. + if len(raw) > 16 { + return protocol.ParseAgentInviteCode(raw) + } + code := normalizeInviteAlias(raw) + s.aliases.mu.Lock() + s.aliases.pruneLocked(s.now()) + row, ok := s.aliases.codes[code] + s.aliases.mu.Unlock() + if !ok || !s.now().Before(row.until) { + return protocol.AgentInviteCode{}, errors.New("invitation code ended") + } + if _, ok := s.currentInvitation(row.proof); !ok { + return protocol.AgentInviteCode{}, errors.New("waiting for the invitation or its approval") + } + return row.proof, nil +} + +func (s *Server) servePublishInvitation(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + // The body is a bearer proof, never a browser cookie. Public CORS lets + // a box on a local HTTP address publish it through TLS without a proxy. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if s.limited(&s.invitePublishLimit, s.invitePublishByIP, r) { + http.Error(w, "Please wait before trying again.", http.StatusTooManyRequests) + return + } + var body struct { + Invitation string `json:"invitation"` + } + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) + d.DisallowUnknownFields() + if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF { + http.Error(w, "Invalid invitation.", http.StatusBadRequest) + return + } + p, err := protocol.ParseAgentInviteCode(body.Invitation) + if err != nil { + http.Error(w, "Invalid invitation.", http.StatusBadRequest) + return + } + code, until, err := s.publishInvitation(p) + if err != nil { + http.Error(w, "Waiting for the box. Keep this page open and retry the same invitation.", http.StatusConflict) + return + } + writeJSON(w, http.StatusOK, map[string]any{"code": code, "expiresAtMs": until.UnixMilli()}, false) +} diff --git a/internal/oauth/invite_alias_test.go b/internal/oauth/invite_alias_test.go new file mode 100644 index 0000000..7b18a80 --- /dev/null +++ b/internal/oauth/invite_alias_test.go @@ -0,0 +1,144 @@ +package oauth + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +func aliasProof(n byte) protocol.AgentInviteCode { + return protocol.AgentInviteCode{V: 1, Home: "abcdefghijklmnopqrstuvwx23", Box: bytes.Repeat([]byte{0x31}, 32), Secret: bytes.Repeat([]byte{n}, 32)} +} + +func TestShortInvitationAliasesAreUniqueIdempotentAndRechecked(t *testing.T) { + g := newRig(t) + live := true + g.s.Invitation = func(home string, hash, box []byte) (time.Time, bool) { return g.now().Add(time.Hour), live } + // First code AAA-AAA, then the same random candidate (collision), then + // BBB-BBB. A collision never returns another invitation's code. + g.s.aliases.random = bytes.NewReader(append(bytes.Repeat([]byte{0}, 12), bytes.Repeat([]byte{1}, 6)...)) + first, end, err := g.s.publishInvitation(aliasProof(1)) + if err != nil { + t.Fatal(err) + } + if first != "AAA-AAA" || end.After(g.now().Add(inviteAliasFor)) { + t.Fatalf("code or expiry: %s %v", first, end) + } + again, sameEnd, err := g.s.publishInvitation(aliasProof(1)) + if err != nil || again != first || !sameEnd.Equal(end) { + t.Fatalf("lost publish reply created another code: %s %v", again, err) + } + second, _, err := g.s.publishInvitation(aliasProof(2)) + if err != nil || second != "BBB-BBB" { + t.Fatalf("collision selected wrong invitation: %s %v", second, err) + } + p, err := g.s.resolveInvitation(" aaa - aaa ") + if err != nil || !bytes.Equal(p.Secret, aliasProof(1).Secret) { + t.Fatalf("normalization changed proof: %v", err) + } + live = false + if _, err := g.s.resolveInvitation(first); err == nil { + t.Fatal("cancelled or consumed hint still resolved") + } +} + +func TestPublishingAShortCodeNeedsTheStrongProofAndReturnsOnlyTheAlias(t *testing.T) { + g := newRig(t) + wanted := aliasProof(3) + g.s.Invitation = func(home string, hash, box []byte) (time.Time, bool) { + return g.now().Add(2 * time.Minute), home == wanted.Home && bytes.Equal(box, wanted.Box) && bytes.Equal(hash, protocol.AgentInviteHash(wanted.Home, wanted.Secret)) + } + for _, p := range []protocol.AgentInviteCode{aliasProof(4), wanted} { + proof, _ := p.Encode() + body, _ := json.Marshal(map[string]string{"invitation": proof}) + r, _ := http.NewRequest(http.MethodPost, g.srv.URL+"/oauth/invitations", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "http://192.168.1.9:8080") + resp, err := noFollow.Do(r) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.Header.Get("Access-Control-Allow-Origin") != "*" || resp.Header.Get("Access-Control-Allow-Credentials") != "" { + t.Fatal("LAN publishing requires cookies or lacks CORS") + } + if !bytes.Equal(p.Secret, wanted.Secret) { + if resp.StatusCode != http.StatusConflict { + t.Fatal("wrong bearer proof published") + } + continue + } + var answer struct { + Code string + ExpiresAtMs int64 + } + if resp.StatusCode != http.StatusOK || json.Unmarshal(data, &answer) != nil || normalizeInviteAlias(answer.Code) == "" || answer.ExpiresAtMs > g.now().Add(2*time.Minute).UnixMilli() { + t.Fatalf("publication: %d %s", resp.StatusCode, data) + } + if strings.Contains(string(data), proof) || strings.Contains(string(data), wanted.Home) { + t.Fatal("alias response leaked strong proof or home") + } + } +} + +func TestExpiredOrRestartedAliasNeverResolves(t *testing.T) { + g := newRig(t) + g.s.Invitation = func(string, []byte, []byte) (time.Time, bool) { return g.now().Add(time.Hour), true } + code, _, err := g.s.publishInvitation(aliasProof(5)) + if err != nil { + t.Fatal(err) + } + g.s.aliases.mu.Lock() + row := g.s.aliases.codes[normalizeInviteAlias(code)] + row.until = g.now().Add(-time.Second) + g.s.aliases.codes[normalizeInviteAlias(code)] = row + g.s.aliases.mu.Unlock() + if _, err := g.s.resolveInvitation(code); err == nil { + t.Fatal("expired alias resolved") + } + g.s.aliases = inviteAliases{} + if _, err := g.s.resolveInvitation(code); err == nil { + t.Fatal("a process restart invented an alias") + } +} + +func TestRestartCannotGiveAnUnexpiredCodeToAnotherHome(t *testing.T) { + g := newRig(t) + g.s.Invitation = func(string, []byte, []byte) (time.Time, bool) { return g.now().Add(time.Hour), true } + g.s.aliases.random = bytes.NewReader(bytes.Repeat([]byte{0}, 6)) + old, _, err := g.s.publishInvitation(aliasProof(6)) + if err != nil { + t.Fatal(err) + } + g.s.aliases = inviteAliases{random: bytes.NewReader(append(bytes.Repeat([]byte{0}, 6), bytes.Repeat([]byte{1}, 6)...))} + other := aliasProof(7) + other.Home = "bbcdefghijklmnopqrstuvwx23" + fresh, _, err := g.s.publishInvitation(other) + if err != nil || fresh == old { + t.Fatalf("old code was reissued: %s %v", fresh, err) + } + if _, err := g.s.resolveInvitation(old); err == nil { + t.Fatal("old code reached a different home") + } +} + +func TestNormalOAuthUpkeepDropsExpiredInvitationSecretsWithoutNewPublications(t *testing.T) { + g := newRig(t) + g.s.Invitation = func(string, []byte, []byte) (time.Time, bool) { return g.now().Add(time.Hour), true } + if _, _, err := g.s.publishInvitation(aliasProof(8)); err != nil { + t.Fatal(err) + } + g.advance(inviteAliasFor + time.Second) + g.s.sweep(context.Background()) + if len(g.s.aliases.codes) != 0 || len(g.s.aliases.fingerprints) != 0 { + t.Fatal("upkeep retained an expired bearer mapping") + } +} diff --git a/internal/oauth/invite_test.go b/internal/oauth/invite_test.go new file mode 100644 index 0000000..0341238 --- /dev/null +++ b/internal/oauth/invite_test.go @@ -0,0 +1,85 @@ +package oauth + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +func TestInvitationRequiresTheOriginalBrowserAndDoesNotApproveByItself(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead) + code, err := (protocol.AgentInviteCode{V: 1, Home: "abcdefghijklmnopqrstuvwx23", Box: bytes.Repeat([]byte{0x31}, 32), Secret: bytes.Repeat([]byte{0x52}, 32)}).Encode() + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{"code": code}) + called := 0 + g.s.ClaimInvite = func(ctx context.Context, m InviteClaim) error { + called++ + if m.Kid != g.kidOf(f) || !bytes.Equal(m.Key, f.agentKey(t)) { + t.Fatal("claim did not use this browser's own pending key") + } + if _, err := protocol.ParseAgentInviteClaim(m.Payload); err != nil { + t.Fatal(err) + } + return nil + } + for _, cookie := range []bool{false, true} { + r, _ := http.NewRequest(http.MethodPost, g.srv.URL+"/oauth/requests/"+f.rid+"/invite", bytes.NewReader(body)) + r.Header.Set("Origin", Issuer) + r.Header.Set("Content-Type", "application/json") + if cookie { + r.AddCookie(f.cookie) + } + resp, err := noFollow.Do(r) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + want := http.StatusForbidden + if cookie { + want = http.StatusAccepted + } + if resp.StatusCode != want { + t.Fatalf("browser=%v status=%d", cookie, resp.StatusCode) + } + } + if called != 1 || g.details(f.rid).Status != statusPending { + t.Fatal("claim minted approval before a signed roster") + } +} + +func TestApprovalRetriesWhenKeepSucceededButDatabaseSaveFailed(t *testing.T) { + old := keepRetry + keepRetry = 20 * time.Millisecond + t.Cleanup(func() { keepRetry = old }) + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead) + _, err := g.s.st.db.Exec(`CREATE TRIGGER fail_approval BEFORE UPDATE OF status ON requests WHEN NEW.status='approved' BEGIN SELECT RAISE(FAIL,'temporary write failure'); END`) + if err != nil { + t.Fatal(err) + } + g.roster(home, agentFor(f.agentKey(t), cimdID, g.now().Add(24*time.Hour), ScopeLiveRead)) + if !g.opens(g.kidOf(f)) || g.details(f.rid).Status != statusPending { + t.Fatal("fixture did not leave Keep ahead of failed approval") + } + if _, err := g.s.st.db.Exec(`DROP TRIGGER fail_approval`); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if g.details(f.rid).Status == statusApproved { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("saved roster was not retried after database recovered") +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 51f9591..e2c692d 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -167,6 +167,12 @@ type Server struct { // OnRevoke hears that an agent is gone: its owner revoked it, or it // ran out. The MCP server drops its warm session. It must not block. OnRevoke func(home, agent string) + // ClaimInvite proves a pending key to the box through its invitation door. + ClaimInvite func(context.Context, InviteClaim) error + Invitation func(home string, hash, box []byte) (time.Time, bool) + aliases inviteAliases + invitePublishLimit, inviteClaimLimit limiter + invitePublishByIP, inviteClaimByIP *ipLimiter } // New opens the authorization server's state at path. @@ -192,6 +198,8 @@ func newServer(path string, keys AgentKeys, log *slog.Logger, roots *x509.CertPo authorizeByIP: newIPLimiter(10, 0.2), registerByIP: newIPLimiter(5, 1.0/60), lookupByIP: newIPLimiter(10, 1), streams: streams{per: map[string]int{}}, pending: map[string]*homeEvent{}, retries: map[string]*approvalRetry{}, wake: make(chan struct{}, 1)} + s.invitePublishLimit, s.inviteClaimLimit = newLimiter(20, 1), newLimiter(20, 0.2) + s.invitePublishByIP, s.inviteClaimByIP = newIPLimiter(5, 1.0/60), newIPLimiter(5, 1.0/60) return s, nil } @@ -203,7 +211,7 @@ func (s *Server) Close() error { return s.st.close() } var Routes = []string{ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/oauth/authorize", "/oauth/token", "/oauth/register", - "/oauth/revoke", "/oauth/requests", "/.well-known/ftw-offer-key", + "/oauth/revoke", "/oauth/requests", "/oauth/invitations", "/.well-known/ftw-offer-key", } // Mount adds the authorization server's routes to mux. @@ -220,6 +228,9 @@ func (s *Server) Mount(mux *http.ServeMux) { mux.HandleFunc("GET /oauth/requests/{rid}", s.serveRequest) mux.HandleFunc("GET /oauth/requests/{rid}/events", s.serveEvents) mux.HandleFunc("POST /oauth/requests/{rid}/deny", s.serveDeny) + mux.HandleFunc("POST /oauth/requests/{rid}/invite", s.serveInvite) + mux.HandleFunc("POST /oauth/invitations", s.servePublishInvitation) + mux.HandleFunc("OPTIONS /oauth/invitations", s.servePublishInvitation) } // -------------------------------------------------------------------------- @@ -450,6 +461,7 @@ func (s *Server) approve(ctx context.Context, home string, listed map[string]clo c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Agent.Scopes), " ") if err := s.st.approve(ridHash, c, grantOf(d, now), now); err != nil { s.log.Warn("could not record an approval", "err", err) + retry = true continue } s.hub.notify(string(ridHash)) @@ -549,6 +561,7 @@ func (s *Server) forgetPending(ctx context.Context) { func (s *Server) sweep(ctx context.Context) { now := s.now() + s.pruneInvitationAliases(now) if err := s.st.sweep(now); err != nil { s.log.Warn("oauth upkeep failed", "err", err) } diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index e17f444..7b0e5d8 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -1007,7 +1007,7 @@ func TestTheConsentPage(t *testing.T) { resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) body, _ := io.ReadAll(resp.Body) page := string(body) - for _, want := range []string{"Let Claude into your home?", "From example.com. FTW Cloud does not know this client", "127.0.0.1:8123", + for _, want := range []string{"Connect Claude to FTW", "I have an invitation code", "Invitation code from the owner", "From example.com. FTW Cloud does not know this client", "127.0.0.1:8123", "a program on this computer", " Connect an assistant · FTW Cloud
-

Let {{.Name}} into your home?

+

Connect {{.Name}} to FTW

{{if .Verified}}A client FTW Cloud knows, from {{.Host}}.{{else if .FromDocument}}From {{.Host}}. FTW Cloud does not know this client: check the name and the address.{{else}}This name is what the assistant registered with. FTW Cloud has not checked it.{{end}}

After you approve, this window goes back to {{.RedirectHost}}.{{if .Local}} That is a program on this computer. Approve only if you started it.{{end}}

@@ -131,6 +132,13 @@ var consentTmpl = template.Must(template.New("consent").Parse(`
    {{range .Scopes}}
  • {{.}}
  • {{end}}

{{if .Control}}Control lasts minutes at a time, within limits your box sets. {{end}}{{if .Setup}}Setup lets it save equipment and electrical settings after you review and confirm the exact draft in the assistant. These settings can affect operation. It can restart the box to apply them after you confirm the brief interruption; your box keeps its safety checks.{{else}}It can change nothing lasting by itself.{{end}} You can remove it at any time, in the FTW app or on your box's own page.

+
I have an invitation code +

Someone who owns the home can give you a code from FTW's Access settings. It connects your own assistant with the rights they chose. It does not pair your phone as an owner.

+
+ +

+

+

Or approve as the owner

{{.QR}}
@@ -157,6 +165,19 @@ var consentTmpl = template.Must(template.New("consent").Parse(` es.addEventListener('approved',function(e){go(e,'Approved. Going back…',true)}); es.addEventListener('denied',function(e){go(e,'Not approved.',true)}); es.addEventListener('expired',function(e){go(e,'This request ran out. Connect again from the assistant.',false)}); + document.getElementById('invite-form').addEventListener('submit',async function(e){ + e.preventDefault(); + var button=document.getElementById('invite-connect'), code=document.getElementById('invite-code'), note=document.getElementById('invite-status'); + button.disabled=true; code.readOnly=true; note.textContent='Connecting to the box… Keep this page open.'; + try { + var response=await fetch('/oauth/requests/'+encodeURIComponent(rid)+'/invite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code.value.trim()})}); + if(!response.ok) throw new Error(await response.text()); + code.value=''; note.textContent='The box accepted the connection. Waiting for its signed confirmation…'; + } catch(e) { + note.textContent=e.message||'The connection is not confirmed. Keep this page open and try the same code again.'; + button.disabled=false; code.readOnly=false; + } + }); document.getElementById('cancel').addEventListener('click',function(){ fetch('/oauth/requests/'+encodeURIComponent(rid)+'/deny',{method:'POST'}); }); diff --git a/internal/oauth/store.go b/internal/oauth/store.go index b8ee4db..8d54df2 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -108,6 +108,10 @@ CREATE TABLE IF NOT EXISTS spent_codes ( CREATE TABLE IF NOT EXISTS forgets ( kid TEXT PRIMARY KEY, at_ms INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS invitation_code_reservations ( + hash BLOB PRIMARY KEY, + expires_ms INTEGER NOT NULL );`) if err != nil { _ = db.Close() @@ -654,6 +658,7 @@ func (s *store) sweep(now time.Time) error { for _, q := range []string{ `DELETE FROM tokens WHERE expires_ms <= ?`, `DELETE FROM spent_codes WHERE expires_ms <= ?`, + `DELETE FROM invitation_code_reservations WHERE expires_ms <= ?`, } { if _, err := tx.Exec(q, ms); err != nil { return err diff --git a/internal/oauth/token.go b/internal/oauth/token.go index ee645b2..9cb5c3d 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -3,6 +3,7 @@ package oauth import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" @@ -35,7 +36,10 @@ const ( // routeKey seals a home into a token's route. Every instance that routes // tokens holds the same one. -type routeKey struct{ aead cipher.AEAD } +type routeKey struct { + aead cipher.AEAD + invitationMAC [32]byte +} // loadRouteKey reads the route key, making it on first start. func loadRouteKey(path string) (routeKey, error) { @@ -70,7 +74,19 @@ func loadRouteKey(path string) (routeKey, error) { if err != nil { return routeKey{}, err } - return routeKey{aead: aead}, nil + derive := hmac.New(sha256.New, key) + derive.Write([]byte("ftw.agent.invite.reservation.key.v1\x00")) + var invitationMAC [32]byte + copy(invitationMAC[:], derive.Sum(nil)) + return routeKey{aead: aead, invitationMAC: invitationMAC}, nil +} + +// The database alone cannot enumerate the short codes from reservations. +// The MAC key comes from the existing key file outside that database. +func (k routeKey) invitationCodeHash(code string) []byte { + h := hmac.New(sha256.New, k.invitationMAC[:]) + h.Write([]byte("ftw.agent.invite.alias.v1\x00" + code)) + return h.Sum(nil) } // newToken is prefix, the home sealed with a fresh nonce, a dot and 32 diff --git a/internal/store/invitation.go b/internal/store/invitation.go new file mode 100644 index 0000000..8bf2fe5 --- /dev/null +++ b/internal/store/invitation.go @@ -0,0 +1,25 @@ +package store + +import ( + "bytes" + "encoding/hex" + "time" +) + +// Invitation checks a strong bearer proof against the current signed hint. +// It grants no session and never consumes a hint. +func (s *Store) Invitation(home string, hash, box []byte) (time.Time, bool) { + s.mu.Lock() + h := s.homes[home] + s.mu.Unlock() + if h == nil { + return time.Time{}, false + } + h.mu.Lock() + defer h.mu.Unlock() + if !h.online || h.roster == nil || !bytes.Equal(h.roster.Noise, box) || !s.now().Before(h.rosterAt.Add(time.Duration(h.roster.TTLS)*time.Second)) { + return time.Time{}, false + } + until, ok := h.invitePending[hex.EncodeToString(hash)] + return until, ok && s.now().Before(until) +} diff --git a/internal/store/invite_test.go b/internal/store/invite_test.go new file mode 100644 index 0000000..cdc3bf9 --- /dev/null +++ b/internal/store/invite_test.go @@ -0,0 +1,53 @@ +package store + +import ( + "bytes" + "testing" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +func TestAgentInviteIsNotAPhoneCodeAndSurvivesLostHandshake(t *testing.T) { + hash := bytes.Repeat([]byte{0x73}, 32) + r := tunRoster(1, true) + r.Pending = []cloudwire.Pending{{Kind: protocol.RoleAgent, Hash: hash, TTLS: 7200}} + s, box, _ := tunHome(t, r) + box.Put(cloudwire.MsgLive, []byte("private reading"), 2, true) + if _, err := s.For(Principal{Kind: Device, Home: "h1", DeviceKey: newKey, Pair: hash}); err == nil { + t.Fatal("agent hint paired a phone") + } + if _, err := s.For(Principal{Kind: Agent, Home: "h1", DeviceKey: newKey}); err == nil { + t.Fatal("hint admitted ordinary agent") + } + for range 2 { + h, err := s.For(Principal{Kind: AgentInvite, Home: "h1", DeviceKey: newKey, Invite: hash}) + if err != nil { + t.Fatal(err) + } + snapshot, session, err := h.Open() + if err != nil { + t.Fatal(err) + } + if len(snapshot.Envelopes) != 0 || h.MayRead(cloudwire.MsgHB) { + t.Fatal("unapproved key read home's copy") + } + session.Close() // A lost handshake must not spend the invitation. + } + h, _ := s.For(Principal{Kind: AgentInvite, Home: "h1", DeviceKey: newKey, Invite: hash}) + _, session, err := h.Open() + if err != nil { + t.Fatal(err) + } + r.RosterSeq = 2 + r.Pending = nil + box.SetRoster(r) + select { + case <-session.Done(): + default: + t.Fatal("cancelled invitation left bootstrap session open") + } + if _, err := s.For(Principal{Kind: AgentInvite, Home: "h1", DeviceKey: newKey, Invite: hash}); err == nil { + t.Fatal("cancelled hint remained") + } +} diff --git a/internal/store/session.go b/internal/store/session.go index 606159f..dd087dd 100644 --- a/internal/store/session.go +++ b/internal/store/session.go @@ -94,7 +94,7 @@ type Session struct { // sends falls between them. A second session from the same device replaces // the first. func (x *Handle) Open() (Snapshot, *Session, error) { - if x.kind != Device && x.kind != Agent { + if x.kind != Device && !isAgentKind(x.kind) { return Snapshot{}, nil, ErrDenied } x.h.mu.Lock() @@ -102,7 +102,11 @@ func (x *Handle) Open() (Snapshot, *Session, error) { // The access checked in For may have changed since: check it again // under the lock that roster changes take. A pairing hash is spent here, // once the session is sure to open, so a busy home does not burn it. - if x.pairing { + if x.inviting { + if _, ok := x.s.invite(x.h, Principal{Invite: x.pair, DeviceKey: x.device.Key}); !ok { + return Snapshot{}, nil, ErrDenied + } + } else if x.pairing { if x.h.countLocked(false) >= MaxSessionsPerHome { return Snapshot{}, nil, ErrBusy } @@ -117,8 +121,8 @@ func (x *Handle) Open() (Snapshot, *Session, error) { sess.endLocked(cloudwire.CloseReplaced) } } - if x.kind == Agent && x.h.countLocked(true) >= MaxAgentSessionsPerHome || - x.kind != Agent && x.h.countLocked(false) >= MaxSessionsPerHome { + if isAgentKind(x.kind) && x.h.countLocked(true) >= MaxAgentSessionsPerHome || + !isAgentKind(x.kind) && x.h.countLocked(false) >= MaxSessionsPerHome { return Snapshot{}, nil, ErrBusy } sess := &Session{x: x, c: make(chan Out, SessionQueue), tun: make(chan TunOut, TunQueue), @@ -269,9 +273,11 @@ func (x *Handle) MayRead(class string) bool { func (h *home) countLocked(agents bool) int { n := 0 for sess := range h.sessions { - if (sess.x.kind == Agent) == agents { + if isAgentKind(sess.x.kind) == agents { n++ } } return n } + +func isAgentKind(k Kind) bool { return k == Agent || k == AgentInvite } diff --git a/internal/store/store.go b/internal/store/store.go index 1446d95..87eb21a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -42,6 +42,8 @@ const ( // carries `agent`, which keeps it off /v1/app and /v1/app/once, and it // is admitted only before the end the entry names, by this clock. Agent + // AgentInvite admits only a pending key carrying an invitation proof. + AgentInvite ) // Principal is who is asking for a home. @@ -52,7 +54,8 @@ type Principal struct { DeviceKey []byte // Pair is the pairing hash a new phone proved in its handshake, for a // Device the roster does not list yet. It admits one session, once. - Pair []byte + Invite []byte + Pair []byte // DeviceID and Epoch are the roster entry a token is bound to, for // Token. DeviceID string @@ -112,8 +115,9 @@ type home struct { // pending is each pairing hash the roster lists, by its hex, until it // runs out; spent marks the ones a phone has used, so a roster sent // again does not reopen them. - pending map[string]time.Time - spent map[string]time.Time + pending map[string]time.Time + spent map[string]time.Time + invitePending map[string]time.Time } // Held is one envelope as received. @@ -135,6 +139,7 @@ type Handle struct { scopes []string // pairing is a new phone admitted by a pairing hash until its pairing // runs out at pairedUntil, or the roster lists its key. + inviting bool pairing bool pairedUntil time.Time pair []byte @@ -158,6 +163,14 @@ func (s *Store) For(p Principal) (*Handle, error) { switch p.Kind { case Box: return &Handle{s: s, id: p.Home, h: h, kind: Box}, nil + case AgentInvite: + h.mu.Lock() + defer h.mu.Unlock() + until, ok := s.invite(h, p) + if !ok { + return nil, ErrDenied + } + return &Handle{s: s, id: p.Home, h: h, kind: AgentInvite, inviting: true, pair: bytes.Clone(p.Invite), pairedUntil: until, device: cloudwire.Device{ID: cloudwire.DeviceID(p.DeviceKey), Key: bytes.Clone(p.DeviceKey)}}, nil case Device, Agent: h.mu.Lock() defer h.mu.Unlock() @@ -192,7 +205,7 @@ func (s *Store) For(p Principal) (*Handle, error) { func newHome() *home { return &home{latest: map[string]Held{}, sessions: map[*Session]struct{}{}, - pending: map[string]time.Time{}, spent: map[string]time.Time{}} + pending: map[string]time.Time{}, spent: map[string]time.Time{}, invitePending: map[string]time.Time{}} } // pair checks a pending pairing hash for a key the roster does not list at @@ -220,6 +233,21 @@ func (s *Store) pair(h *home, p Principal) (time.Time, bool) { return until, true } +// invite admits only the typed agent bootstrap door, without spending the +// hint. Only Core's proved claim consumes an invitation. +func (s *Store) invite(h *home, p Principal) (time.Time, bool) { + if len(p.Invite) != 32 || len(p.DeviceKey) != 32 || h.roster == nil || !s.now().Before(h.rosterAt.Add(time.Duration(h.roster.TTLS)*time.Second)) { + return time.Time{}, false + } + for _, d := range h.roster.Devices { + if bytes.Equal(d.Key, p.DeviceKey) { + return time.Time{}, false + } + } + until, ok := h.invitePending[hex.EncodeToString(p.Invite)] + return until, ok && s.now().Before(until) +} + // spendLocked takes a pending pairing hash for good. False means it went // meanwhile. The caller holds h.mu. func (h *home) spendLocked(pair []byte, now time.Time) bool { @@ -418,7 +446,17 @@ func (x *Handle) SetRoster(r *cloudwire.Roster) bool { // A hash already spent stays spent. The caller holds h.mu. func (h *home) setPendingLocked(r *cloudwire.Roster, at time.Time) { listed := map[string]bool{} + h.invitePending = map[string]time.Time{} for _, p := range r.Pending { + if p.Kind == protocol.RoleAgent { + if len(p.Hash) == 32 && p.TTLS > 0 && p.TTLS <= protocol.AgentInviteClaimMaxMs/1000 { + h.invitePending[hex.EncodeToString(p.Hash)] = at.Add(time.Duration(p.TTLS) * time.Second) + } + continue + } + if p.Kind != "" { + continue + } key := hex.EncodeToString(p.Hash) listed[key] = true if _, spent := h.spent[key]; !spent && len(p.Hash) == cloudwire.PairingHashBytes && p.TTLS > 0 { @@ -448,8 +486,18 @@ func (x *Handle) Recheck() { func (x *Handle) recheckLocked() { for sess := range x.h.sessions { - dev, scopes, ok := x.s.admit(sess.x.kind, x.id, x.h, byKey(sess.x.device.Key)) + kind := sess.x.kind + if kind == AgentInvite { + kind = Agent + } + dev, scopes, ok := x.s.admit(kind, x.id, x.h, byKey(sess.x.device.Key)) switch { + case sess.x.inviting && ok: + sess.x.device, sess.x.scopes, sess.x.inviting, sess.x.kind = dev, scopes, false, Agent + case sess.x.inviting: + if until, ok := x.h.invitePending[hex.EncodeToString(sess.x.pair)]; !ok || !x.s.now().Before(until) { + sess.endLocked(cloudwire.CloseDenied) + } case sess.x.pairing && ok: // The box paired the phone: its session takes the roster's // entry and stays open. @@ -547,7 +595,7 @@ func (x *Handle) Now() (Snapshot, error) { } func (x *Handle) mayRead(class string) bool { - if x.kind == Agent { + if x.kind == Agent || x.kind == AgentInvite { // An agent reads through its box, which audits and enforces // revoke; never from the copy. return false @@ -584,7 +632,7 @@ func (s *Store) Sweep() { } // A pairing the box did not complete ends with its code. for sess := range h.sessions { - if sess.x.pairing && !now.Before(sess.x.pairedUntil) { + if (sess.x.pairing || sess.x.inviting) && !now.Before(sess.x.pairedUntil) { sess.endLocked(cloudwire.CloseDenied) } } diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index be0f812..6e63fc1 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -913,14 +913,29 @@ func (c *boxConn) roster(env cloudwire.Envelope) error { return fmt.Errorf("%w: a roster device the service cannot use", errProtocol) } } - if len(r.Pending) > maxPending { - return fmt.Errorf("%w: %d pairing codes in one roster", errProtocol, len(r.Pending)) - } + phones, invites := 0, 0 + seenPending := map[string]bool{} for _, p := range r.Pending { - if len(p.Hash) != cloudwire.PairingHashBytes || p.TTLS <= 0 || p.TTLS > maxPendingTTL { - return fmt.Errorf("%w: a pairing code the service cannot hold", errProtocol) + maxTTL := int64(maxPendingTTL) + switch p.Kind { + case "": + phones++ + case protocol.RoleAgent: + invites++ + maxTTL = protocol.AgentInviteClaimMaxMs / 1000 + default: + return fmt.Errorf("%w: unknown pending kind", errProtocol) + } + key := string(p.Hash) + if len(p.Hash) != cloudwire.PairingHashBytes || p.TTLS <= 0 || p.TTLS > maxTTL || seenPending[key] { + return fmt.Errorf("%w: invalid pending admission", errProtocol) } + seenPending[key] = true } + if phones > maxPending || invites > protocol.AgentInviteRowsMax { + return fmt.Errorf("%w: too many pending admissions", errProtocol) + } + if r.RosterSeq < c.rosterSeq() { return nil } From 2072c3969f3d7fb8f4e24a5c21a44a3019e21c90 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:16:01 +0200 Subject: [PATCH 2/3] Make the recipient short-code field clear and focus it on open --- internal/oauth/page.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 5eaf20b..25fc229 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -117,7 +117,8 @@ a.button{display:inline-block;background:var(--accent);color:#fff;text-decoratio @media (prefers-color-scheme:dark){a.button{color:#0b1a12}} button{font:inherit;background:none;border:1px solid var(--line);color:var(--fg);padding:8px 14px;border-radius:8px;cursor:pointer} #status{margin-top:24px;font-weight:600} -details{margin:24px 0}summary{cursor:pointer;font-weight:600}textarea{display:block;width:100%;margin-top:8px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:14px/1.4 ui-monospace,monospace} +details{margin:24px 0}summary{cursor:pointer;font-weight:600} +#invite-code{display:block;width:11ch;margin-top:8px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:1.5rem/1.4 ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase} ` var consentTmpl = template.Must(template.New("consent").Parse(` @@ -165,6 +166,9 @@ var consentTmpl = template.Must(template.New("consent").Parse(` es.addEventListener('approved',function(e){go(e,'Approved. Going back…',true)}); es.addEventListener('denied',function(e){go(e,'Not approved.',true)}); es.addEventListener('expired',function(e){go(e,'This request ran out. Connect again from the assistant.',false)}); + document.getElementById('invitation').addEventListener('toggle',function(){ + if(this.open) document.getElementById('invite-code').focus(); + }); document.getElementById('invite-form').addEventListener('submit',async function(e){ e.preventDefault(); var button=document.getElementById('invite-connect'), code=document.getElementById('invite-code'), note=document.getElementById('invite-status'); From 3d6a6e049351c64341c035055538b80326464ad1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:46:14 +0200 Subject: [PATCH 3/3] Keep consent recovery links hidden until a result is ready --- internal/oauth/page.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 25fc229..3a82d14 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -107,7 +107,7 @@ func qrSVG(text string) template.HTML { const pageStyle = ` :root{--bg:#f6f5f2;--fg:#17181a;--muted:#5d6066;--card:#fff;--line:#e2e0db;--accent:#1f6f4a;--warn:#8a4b00} @media (prefers-color-scheme:dark){:root{--bg:#111214;--fg:#eceae6;--muted:#a3a6ab;--card:#1b1c1f;--line:#2c2e32;--accent:#5cc28f;--warn:#f0a34a}} -*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif} +*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif} main{max-width:560px;margin:0 auto;padding:32px 16px}h1{font-size:1.5rem;line-height:1.25;margin:0 0 8px}h2{font-size:1rem;margin:24px 0 8px} .muted{color:var(--muted)}.warn{color:var(--warn)}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px} ul{margin:0;padding-left:20px}.approve{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:24px}