Skip to content

feat(keys): ftwkeysd, the one part that can open an agent's session - #18

Closed
frahlg wants to merge 5 commits into
mainfrom
feat/agent-keys
Closed

frahlg wants to merge 5 commits into
mainfrom
feat/agent-keys

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

First of four stacked PRs that bring MCP into FTW Cloud, as decided on 15 October 2026: keys (this), OAuth and consent, MCP read, MCP control. Each is based on the one before. It pins the agent contract as merged: srcfl/ftw#1642 at bf5bf50.

What this adds

ftwkeysd, the keys part: the one process that can open an agent's Noise session to a box.

  • A process of its own. It runs as its own user on the same host and answers only on a unix socket. It checks the peer's uid on every connection (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS).
  • Keys made at the start. It makes an assistant's X25519 key pair when an authorization starts. Only the public half leaves.
  • Sealed at rest. Each agent key gets its own AES-256 data key from AWS KMS (GenerateDataKey), and the secret is sealed with it by AES-GCM. The encryption context names the purpose (ftw-agent-key) and the key id, so a wrapped data key opens only for its own key. A local file key replaces KMS only in a development build (-tags ftwclouddev); a production build has no -file-key and no filekey.
  • Pending until approved. A key opens no session until keep, which comes when the owner approves. An unapproved key goes after an hour.
  • Unsealed only to open a session. A key is unsealed to open one session to one box. ftwkeysd sets the box session's prologue itself, so it opens ftw.session.v1 sessions and nothing else. The secret is wiped once message 1 is written. The transport keys stay in ftwkeysd, which seals and opens each frame. ftwcloudd carries the frames as tun and never holds an agent's static or session keys.
  • Forget. forget deletes a key and ends its sessions at once.
  • Nothing to leak. Errors on the socket are a few fixed words, and nothing logs a key.
  • The offer key. The agent contract (feat(contract): agents, their approval signature and roster entry ftw#1642) has FTW Cloud sign each grant offer, so the app can tell an offer from FTW Cloud from a copy.
    • In KMS. In production it is an asymmetric KMS key of its own (ECC_NIST_P256, SIGN_VERIFY). ftwkeysd signs with kms:Sign on the digest and checks the answer against the public half; the private half never leaves KMS. A production build will not start without -offer-kms-key. A local P-256 key in the state directory exists only in a development build.
    • Only offers it can vouch for. offer.sign names a kid and decodes the offer strictly, as the box does (no unknown or repeated keys). It signs only version 1, the agent role, exp no more than ten minutes ahead, and a key that is the public half of a key ftwkeysd made and that waits for approval. So ftwcloudd cannot get an owner to approve a key that ftwcloudd holds, or offer any other role.
    • VerifyOffer is the check the app makes with the key it pins, and what it signs opens with protocol.OpenOffer.

Deploy files (not run; the owner deploys)

  • deploy/keys.md covers the shape, the security argument and the steps.
  • Compose. A ftwkeysd service under the agents profile, as uid 65533 in ftwcloudd's group, on a bridge network of its own, with its own volume. ftwcloudd mounts only the socket's directory.
  • ftwkeysd reaches KMS and nothing else. With the instance's IMDS hop limit at 1, no container gets the instance role, which reads the service key and the parameters. ftwkeysd uses a role of its own, ftw-cloud-keys, whose policy allows the two KMS keys and nothing more. The host assumes it every 15 minutes and leaves hour-long credentials in tmpfs, readable only by ftwkeysd's user. ftwcloudd needs no AWS credentials, and links no AWS SDK (check-imports.sh checks that).
  • Key policies (agent key, offer key). Only ftw-cloud-keys may use each key: the agent key only with the purpose, the offer key only to sign digests with ECDSA_SHA_256 and read its public half. The account root may administer them (kms:CreateAlias, not kms:Create*) but not use them. An explicit deny on kms:CreateGrant stops a grant from opening either key beside its policy. keys.md says plainly that whoever may put a key's policy can write themselves in, and that the change shows in CloudTrail.
  • Also here:
  • The offer key is pinned from the host. Step 7 takes the kid and public half from ftwkeysd offerkey through SSM, not from the public /.well-known/ftw-offer-key, with aws kms get-public-key as a cross-check.
  • ftwcloudd runs as 65530, its own uid, not distroless's shared nonroot (65532). ftw-cloud-own, run before each start, gives the state volume and the secrets in tmpfs to the user the configured image runs as, so deploying this image, or going back to an older one, finds its files its own. ftw-cloud-deploy now keeps the lines keys.sh adds to compose.env; before, it overwrote them. Step 0 of keys.md: re-run bootstrap.sh before deploying.
  • The image carries both binaries. CI also cross-builds ftwkeysd for the host.

The guide's first invariant changes to "forwards ciphertext it cannot read; acts on a home only as an agent its owner approved, within limits the box enforces". A second invariant says agent keys live only in ftwkeysd.

What the tests prove (internal/keys)

  • Approval comes first. A key opens a session only after approval, and the box sees exactly the public half that was handed out.
  • A session is bound to its box. Another box cannot read message 1, and a malformed box key is refused.
  • Bad input ends cleanly. A bad message 2 ends the attempt, and a frame sealed before the handshake ends is refused.
  • Forget is thorough. It ends that key's sessions and no other, and deletes the sealed file.
  • Key ids are never paths.
  • The secret is never in the clear. It is not on disk in the clear, and a sealed record moved to another kid does not open. After every operation, neither the secret nor the data key appears in the log, in raw, hex or base64 form.
  • KMS gets the context. With a fake that enforces the encryption context like KMS: each data key carries purpose and kid, one KMS call opens a session, and a wrapped key does not open for another kid.
  • The sweep works. It drops unapproved keys older than an hour and idle sessions.
  • The socket holds. It refuses another uid and any connection that is not a unix socket's. The client dials again after ftwkeysd restarts, kept keys survive, and sessions do not.
  • Concurrency is safe. Sixteen concurrent sessions work under -race.
  • The offer key signs only what it can vouch for. What it signs opens with protocol.OpenOffer. It refuses, each by name: an empty or non-CBOR offer, an unknown field, another pending key's kid, another key in the offer, a made-up key, no kid, the support or owner role, another version, an expired offer, an offer good for too long, and a key the owner already approved.
  • The offer key in KMS. A fake asymmetric key shows: the public half is KMS's, no local key is made beside it, a refused offer never reaches KMS, and a signature that does not verify is not passed on.
  • The local offer key is stable and contained. It is the same after a restart, and not on disk or in the log in the clear. Its data key does not open under an agent kid.
  • A production build needs both keys in KMS (cmd/ftwkeysd). It has no -file-key and no filekey, and refuses to start without -kms-key or -offer-kms-key. The keys role's credentials come from the host's file and expire when it says.

Not in this PR

  • Nothing here touches AWS. Turning it on is the steps in deploy/keys.md.

🤖 Generated with Claude Code

A second process on the same host, as another user, behind a unix socket
whose peer uid it checks. It makes an assistant's X25519 agent key when an
authorization starts and seals it at rest: one AES-256 data key per agent
key from AWS KMS (a file key in development), with an encryption context
that names the purpose and the key id. A key stays pending until the owner
approves, and opens no session until then; an unapproved key goes after an
hour.

It unseals a key only to open one Noise IK session to one box, with the box
session's prologue set by ftwkeysd itself, and wipes the secret once message
1 is written. It keeps the transport keys and seals and opens each frame for
ftwcloudd, which carries them as tun. Forget deletes the key and ends its
sessions. Errors on the socket are a few fixed words, and nothing logs a
key.

Deploy files, not run: a compose service under the `agents` profile on the
host's network (the one container that reaches the instance role with an
IMDS hop limit of 1), the KMS key policy (root may administer, only the host
role may use, only with the purpose), the role's inline policy, and a host
script for the socket directory. The guide's first invariant changes as
decided on 15 October 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:27:45.401500Z c2e7c49 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2e7c496a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread deploy/keys.sh Outdated
Comment on lines +25 to +26
sed -i '/^FTW_CLOUD_KMS_KEY=/d; /^COMPOSE_PROFILES=/d' "$env"
printf 'FTW_CLOUD_KMS_KEY=%s\nCOMPOSE_PROFILES=agents\n' "$key_arn" >> "$env"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the key-service settings during deployment

In the documented activation flow, keys.sh writes these settings and then step 5 runs ftw-cloud-deploy, but deploy/bootstrap.sh:145 replaces the entire environment file with only FTW_CLOUD_IMAGE. Consequently COMPOSE_PROFILES=agents and FTW_CLOUD_KMS_KEY disappear immediately before the service restart, so ftwkeysd is not started. The deploy helper must preserve these entries rather than overwrite them.

Useful? React with 👍 / 👎.

Comment thread internal/keys/server.go
Comment on lines +355 to +360
s.mu.Lock()
defer s.mu.Unlock()
if len(s.sessions) >= MaxSessions {
return "", nil, ErrBusy
}
s.sessions[sess] = &session{kid: kid, init: init, used: s.now()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize session creation with key revocation

When an Open is blocked in unseal—notably during the KMS call—a concurrent Forget can delete the key, inspect the session map, and return before this code inserts the new session. The revoked key then leaves a usable session behind after Forget has promised to end all of its sessions. Session insertion must be coordinated with revocation or revalidate that the key still exists while holding the relevant lock.

Useful? React with 👍 / 👎.

Comment thread internal/keys/server.go
Comment on lines +201 to +203
if n, _ := countDir(filepath.Join(s.dir, "pending")); n >= MaxPending {
return "", nil, ErrBusy
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve pending-key capacity atomically

Under concurrent new requests, every goroutine can count the same below-limit directory before any of them writes its record, so all proceed to call KMS and create files. A burst of simultaneous authorization requests can therefore exceed MaxPending by an arbitrary amount, defeating the stated resource bound and allowing unbounded KMS work; the check and reservation need synchronization.

Useful? React with 👍 / 👎.

frahlg and others added 4 commits October 15, 2026 19:41
The agent contract (srcfl/ftw#1642) has FTW Cloud sign each grant offer the
consent page hands the owner's app, with a key the app pins. It lives in
the keys part, apart from the agent keys: one P-256 key, made at the first
start, sealed like an agent key under its own context, kept in memory.
It signs only the deterministic CBOR ["ftw.offer.v1", offer]. VerifyOffer
is the app's check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract's signed offer is {o, kid, sig}, and Core and the apps pin
offer keys by kid. The kid is the first 16 characters of the base64url
SHA-256 of the key's SEC1 point. ftwkeysd logs kid and key when it makes
the offer key, and `ftwkeysd offerkey` prints them from the same state
directory; deploy/keys.md adds the pinning step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r key in KMS, a role of its own

- signOffer decodes the GrantOffer strictly and signs only version 1, the
  agent role, at most OfferTTLMs ahead, and a key that is a pending kid's.
  The pin of the agent contract (e2559632) moves here from #19 for it.
- The offer key is an asymmetric KMS key (ECC_NIST_P256, SIGN_VERIFY) that
  only the keys role may sign with. A local offer key and the file sealer
  exist only in a development build (-tags ftwclouddev).
- ftwkeysd leaves the host network for a bridge of its own and uses the
  ftw-cloud-keys role, which may use the two KMS keys and nothing else. The
  host renews its credentials into tmpfs every 15 minutes.
- Key policies: kms:CreateAlias instead of kms:Create*, an explicit deny
  on kms:CreateGrant, and keys.md says plainly that PutKeyPolicy lets an
  admin take use.
- ftwcloudd runs as 65530, not distroless's shared nonroot. ftw-cloud-own
  gives its files to the user its image runs as at every start, and
  ftw-cloud-deploy keeps the lines keys.sh adds to compose.env.
- Pin the offer key from ftwkeysd on the host through SSM, not from the
  public endpoint.
- The socket refuses a connection that is not a unix socket's, and a
  message 2 that does not split ends its session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
#19, #20, #21)

The MCP stack as one change: ftwkeysd, the one part that can open an agent's session (#18); OAuth for assistants with the owner's approval at home (#19); reads at /mcp (#20); control within the owner's grant, and requests that wait for the owner (#21). Reviewed in two passes; merged with main after #17 (scale), go/protocol pinned to srcfl/ftw master f024251.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Included in #21 (squash 2e7afbd), which carries the whole MCP stack.

@frahlg frahlg closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant