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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
state
*.key
*.pem
go.work
go.work.sum
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 2 additions & 0 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions internal/keys/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
102 changes: 102 additions & 0 deletions internal/keys/invite_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
45 changes: 41 additions & 4 deletions internal/keys/server.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package keys

import (
"bytes"
"context"
"crypto/ecdh"
"crypto/rand"
Expand All @@ -15,6 +16,7 @@ import (
"sync"
"time"

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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -311,18 +315,27 @@ 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
}
var rec record
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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
17 changes: 9 additions & 8 deletions internal/keys/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
)

Expand Down
28 changes: 26 additions & 2 deletions internal/mcp/agent.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mcp

import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
Expand Down Expand Up @@ -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
}
Expand All @@ -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.")
Expand Down
Loading
Loading