From c2e7c496a8619317630749a1a591856f27150ade Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:01:49 +0200 Subject: [PATCH 01/28] feat(keys): ftwkeysd, the one part that can open an agent's session 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 --- .github/workflows/ci.yml | 4 +- CLAUDE.md | 18 +- Dockerfile | 11 +- README.md | 10 + cmd/ftwkeysd/harden_linux.go | 16 ++ cmd/ftwkeysd/harden_other.go | 6 + cmd/ftwkeysd/main.go | 150 ++++++++++ deploy/compose.yml | 43 +++ deploy/keys-host-policy.json | 14 + deploy/keys-kms-policy.json | 39 +++ deploy/keys.md | 129 +++++++++ deploy/keys.sh | 27 ++ go.mod | 15 + go.sum | 30 ++ internal/keys/client.go | 174 ++++++++++++ internal/keys/keys_test.go | 511 ++++++++++++++++++++++++++++++++++ internal/keys/peer_darwin.go | 35 +++ internal/keys/peer_linux.go | 35 +++ internal/keys/peer_other.go | 13 + internal/keys/seal.go | 178 ++++++++++++ internal/keys/server.go | 513 +++++++++++++++++++++++++++++++++++ internal/keys/wire.go | 157 +++++++++++ scripts/check-imports.sh | 10 + 23 files changed, 2129 insertions(+), 9 deletions(-) create mode 100644 cmd/ftwkeysd/harden_linux.go create mode 100644 cmd/ftwkeysd/harden_other.go create mode 100644 cmd/ftwkeysd/main.go create mode 100644 deploy/keys-host-policy.json create mode 100644 deploy/keys-kms-policy.json create mode 100644 deploy/keys.md create mode 100755 deploy/keys.sh create mode 100644 internal/keys/client.go create mode 100644 internal/keys/keys_test.go create mode 100644 internal/keys/peer_darwin.go create mode 100644 internal/keys/peer_linux.go create mode 100644 internal/keys/peer_other.go create mode 100644 internal/keys/seal.go create mode 100644 internal/keys/server.go create mode 100644 internal/keys/wire.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c838f0..9f1e2f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,4 +28,6 @@ jobs: - name: What a watch can post never panics the service run: go test -run XXX -fuzz FuzzOnceMessage -fuzztime 30s ./internal/appsession - name: Builds for the host - run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd + run: | + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwkeysd diff --git a/CLAUDE.md b/CLAUDE.md index 4ad9eeb..257d30f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,12 +11,18 @@ maintains it. ## Invariants -- **FTW Cloud forwards ciphertext it cannot read, and never chooses an - action.** It sends a box only `challenge`, `hello_ok`, `watch`, `resync`, - `purged` and `tun` (`internal/uplink/downlink.go`). Commands go from the app - to the box inside the phone's own Noise session, which rides here as `tun` - under the device id the service's own handshake proved, never one a phone - names. +- **FTW Cloud forwards ciphertext it cannot read. It acts on a home only as + an agent its owner approved, within limits the box enforces.** It sends a + box only `challenge`, `hello_ok`, `watch`, `resync`, `purged` and `tun` + (`internal/uplink/downlink.go`). Commands go from the app to the box inside + the phone's own Noise session, which rides here as `tun` under the device + id the service's own handshake proved, never one a phone names. An agent's + requests ride the agent's own Noise session the same way, and the box + admits it only while its signed roster lists the agent's key. +- **Agent keys live only in `ftwkeysd`** (`internal/keys`, `cmd/ftwkeysd`). It + answers on a local unix socket, seals keys at rest under KMS, unseals a key + only to open one session, and logs no key. `ftwcloudd` never holds an + agent's static key or session keys. Keep it small: nothing else goes in. - **One way to a home:** `store.For(principal)`. No listing, no second lookup, no principal for staff. A new route needs a row in `server.Routes` and must pass `TestNoRouteReadsAHome`. The one other way in is `store.Seed`, called diff --git a/Dockerfile b/Dockerfile index 7d05a26..88dafd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,15 +18,22 @@ COPY . . # No cgo: the SQLite driver is pure Go, so the binary runs on a static base. RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ -ldflags "-s -w -X github.com/srcfl/ftw-cloud/internal/server.Revision=${REVISION}" \ - -o /out/ftwcloudd ./cmd/ftwcloudd + -o /out/ftwcloudd ./cmd/ftwcloudd \ + && CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags "-s -w" \ + -o /out/ftwkeysd ./cmd/ftwkeysd RUN mkdir -p /out/share && tar -czf /out/share/source.tar.gz \ --exclude=./.git --exclude=./state . \ - && mkdir -p /out/state + && mkdir -p /out/state /out/keys FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/ftwcloudd /usr/local/bin/ftwcloudd +# The keys part, run as its own container and user (deploy/keys.md). +COPY --from=build /out/ftwkeysd /usr/local/bin/ftwkeysd COPY --from=build /out/share/source.tar.gz /usr/share/ftw-cloud/source.tar.gz COPY --from=build --chown=nonroot:nonroot /out/state /state +# Owned by ftwkeysd's user, so a new volume mounted here starts out its own +# and ftwcloudd cannot read it. +COPY --from=build --chown=65533:65532 /out/keys /keys USER nonroot EXPOSE 443 ENTRYPOINT ["/usr/local/bin/ftwcloudd"] diff --git a/README.md b/README.md index 6dc9eb4..f096d50 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ to it yet. the roster drops the device, moves its epoch or runs out, an owner suspends the device, or the home is purged. +- **The keys part (`ftwkeysd`).** A second process on the same host, as + another user, answering only on a unix socket. It makes each assistant's + agent key (X25519) when an authorization starts, seals it at rest with a + data key from AWS KMS, and keeps it pending until the owner approves. It + unseals a key only to open that agent's Noise session to its box, keeps + the session's transport keys, and seals and opens each frame for + `ftwcloudd`, which carries them as `tun`. Forgetting a key ends its + sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn + it on. + Not built yet: | Step | What | Where | diff --git a/cmd/ftwkeysd/harden_linux.go b/cmd/ftwkeysd/harden_linux.go new file mode 100644 index 0000000..ae5ce09 --- /dev/null +++ b/cmd/ftwkeysd/harden_linux.go @@ -0,0 +1,16 @@ +package main + +import ( + "log/slog" + + "golang.org/x/sys/unix" +) + +// harden makes the process not dumpable: no core file, and no ptrace attach +// from another process of the same user. Agent keys and session keys live in +// this process's memory while a session is open. +func harden() { + if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { + slog.Warn("could not turn off core dumps", "err", err) + } +} diff --git a/cmd/ftwkeysd/harden_other.go b/cmd/ftwkeysd/harden_other.go new file mode 100644 index 0000000..bb16421 --- /dev/null +++ b/cmd/ftwkeysd/harden_other.go @@ -0,0 +1,6 @@ +//go:build !linux + +package main + +// harden has nothing to set outside Linux, where the service runs. +func harden() {} diff --git a/cmd/ftwkeysd/main.go b/cmd/ftwkeysd/main.go new file mode 100644 index 0000000..a266083 --- /dev/null +++ b/cmd/ftwkeysd/main.go @@ -0,0 +1,150 @@ +// Command ftwkeysd is FTW Cloud's keys part: the one process that can open an +// agent's Noise session to a box. It runs beside ftwcloudd on the same host, +// as another user, and answers only on a local unix socket. See +// internal/keys and deploy/keys.md. +// +// ftwkeysd filekey -out dev.key make a development file key +// ftwkeysd [flags] serve +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/srcfl/ftw-cloud/internal/keys" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "filekey" { + fs := flag.NewFlagSet("filekey", flag.ExitOnError) + out := fs.String("out", "", "file to write the key to") + _ = fs.Parse(os.Args[2:]) + if *out == "" { + fmt.Fprintln(os.Stderr, "filekey: -out is required") + os.Exit(1) + } + if err := keys.MakeFileKey(*out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err := serve(os.Args[1:]); err != nil { + slog.Error("ftwkeysd stopped", "err", err) + os.Exit(1) + } +} + +func serve(args []string) error { + fs := flag.NewFlagSet("ftwkeysd", flag.ExitOnError) + socket := fs.String("socket", "/run/ftw-cloud-keys/keys.sock", "unix socket to answer on") + state := fs.String("state", "/state/keys", "directory for the sealed keys") + kmsKey := fs.String("kms-key", "", "AWS KMS key id, ARN or alias that wraps the data keys") + region := fs.String("region", "eu-north-1", "AWS region of the KMS key") + fileKey := fs.String("file-key", "", "a local file key instead of KMS (development only)") + peerUID := fs.Int("peer-uid", -1, "the one user allowed to connect; -1 is this process's own") + socketMode := fs.Uint("socket-mode", 0o660, "permissions of the socket file") + _ = fs.Parse(args) + + harden() + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + var sealer keys.Sealer + switch { + case *kmsKey != "" && *fileKey != "": + return errors.New("choose -kms-key or -file-key, not both") + case *kmsKey != "": + cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(*region)) + if err != nil { + return fmt.Errorf("aws config: %w", err) + } + sealer = keys.KMSSealer{KMS: kmsClient{kms.NewFromConfig(cfg)}, KeyID: *kmsKey} + case *fileKey != "": + s, err := keys.NewFileSealer(*fileKey) + if err != nil { + return err + } + log.Warn("agent keys sealed with a local file key; for development only") + sealer = s + default: + return errors.New("no sealer: set -kms-key, or -file-key in development") + } + + srv, err := keys.NewServer(*state, sealer, log) + if err != nil { + return err + } + srv.PeerUID = *peerUID + + // A socket left by an earlier run is removed; nothing else may sit at + // the path. + if fi, err := os.Lstat(*socket); err == nil { + if fi.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("%s exists and is not a socket", *socket) + } + _ = os.Remove(*socket) + } + if err := os.MkdirAll(filepath.Dir(*socket), 0o750); err != nil { + return err + } + ln, err := net.Listen("unix", *socket) + if err != nil { + return err + } + if err := os.Chmod(*socket, os.FileMode(*socketMode)); err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + _ = ln.Close() + return + case <-t.C: + srv.Sweep() + } + } + }() + log.Info("ftwkeysd listening", "socket", *socket) + return srv.Serve(ln) +} + +// kmsClient adapts the AWS SDK to keys.KMS. +type kmsClient struct{ c *kms.Client } + +func (k kmsClient) GenerateDataKey(ctx context.Context, keyID string, ec map[string]string) ([]byte, []byte, error) { + out, err := k.c.GenerateDataKey(ctx, &kms.GenerateDataKeyInput{ + KeyId: aws.String(keyID), KeySpec: types.DataKeySpecAes256, EncryptionContext: ec, + }) + if err != nil { + return nil, nil, err + } + return out.Plaintext, out.CiphertextBlob, nil +} + +func (k kmsClient) Decrypt(ctx context.Context, keyID string, blob []byte, ec map[string]string) ([]byte, error) { + out, err := k.c.Decrypt(ctx, &kms.DecryptInput{ + KeyId: aws.String(keyID), CiphertextBlob: blob, EncryptionContext: ec, + }) + if err != nil { + return nil, err + } + return out.Plaintext, nil +} diff --git a/deploy/compose.yml b/deploy/compose.yml index 5504f2a..2d06f07 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -34,6 +34,9 @@ services: # boot by deploy/noise.sh. Empty means /v1/app is off, and the # service runs. - /run/ftw-cloud/noise:/run/secrets/noise:ro + # The keys part's socket (deploy/keys.md). Without ftwkeysd it holds + # nothing, and assistants cannot connect. + - /run/ftw-cloud-keys:/run/ftw-cloud-keys secrets: - ftw-cloud-service.key read_only: true @@ -51,6 +54,45 @@ services: max-size: '10m' max-file: '3' + # The keys part: the only process that can act on a home, as an agent its + # owner approved (deploy/keys.md). It starts with COMPOSE_PROFILES=agents + # in compose.env, once the KMS key exists. + ftwkeysd: + image: ${FTW_CLOUD_IMAGE:?set FTW_CLOUD_IMAGE to an image digest} + profiles: [agents] + restart: unless-stopped + entrypoint: ['/usr/local/bin/ftwkeysd'] + command: + - -socket=/run/ftw-cloud-keys/keys.sock + - -state=/keys + - -kms-key=${FTW_CLOUD_KMS_KEY:-} + - -region=eu-north-1 + # ftwcloudd's user, the only one allowed on the socket. + - -peer-uid=65532 + # Its own user, in ftwcloudd's group so that ftwcloudd may connect to + # the socket and nothing more. + user: '65533:65532' + # The host's network, because the instance's credentials answer one hop + # away and the bridged containers sit two hops off (deploy/keys.md): + # this is the one container that can reach KMS. It listens on nothing + # but the unix socket. + network_mode: host + volumes: + # Agent keys, sealed under KMS. ftwcloudd never mounts this. + - ftw-cloud-keys:/keys + - /run/ftw-cloud-keys:/run/ftw-cloud-keys + read_only: true + ulimits: + core: 0 + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' + secrets: # ftw-cloud-key.service fetches it from Secrets Manager into tmpfs at # boot; it is never in the image, the volume or on disk. @@ -59,3 +101,4 @@ secrets: volumes: ftw-cloud-state: + ftw-cloud-keys: diff --git a/deploy/keys-host-policy.json b/deploy/keys-host-policy.json new file mode 100644 index 0000000..cd7d468 --- /dev/null +++ b/deploy/keys-host-policy.json @@ -0,0 +1,14 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SealAndOpenAgentKeys", + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt"], + "Resource": "arn:aws:kms:eu-north-1:976075258475:key/KEY-ID", + "Condition": { + "StringEquals": { "kms:EncryptionContext:purpose": "ftw-agent-key" } + } + } + ] +} diff --git a/deploy/keys-kms-policy.json b/deploy/keys-kms-policy.json new file mode 100644 index 0000000..bcc2acf --- /dev/null +++ b/deploy/keys-kms-policy.json @@ -0,0 +1,39 @@ +{ + "Version": "2012-10-17", + "Id": "ftw-cloud-agent-keys", + "Statement": [ + { + "Sid": "AdministerButNeverUse", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:root" }, + "Action": [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Resource": "*" + }, + { + "Sid": "TheHostSealsAndOpensAgentKeys", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-host" }, + "Action": ["kms:GenerateDataKey", "kms:Decrypt"], + "Resource": "*", + "Condition": { + "StringEquals": { "kms:EncryptionContext:purpose": "ftw-agent-key" }, + "ForAllValues:StringEquals": { "kms:EncryptionContextKeys": ["purpose", "kid"] } + } + } + ] +} diff --git a/deploy/keys.md b/deploy/keys.md new file mode 100644 index 0000000..2e55618 --- /dev/null +++ b/deploy/keys.md @@ -0,0 +1,129 @@ +# The keys part + +`ftwkeysd` is the one process in FTW Cloud that can act on a home. It holds +each assistant's agent key, an X25519 Noise static key that a home's owner +approved, and it is the only process that can open that agent's Noise session +to the box. `ftwcloudd` carries the session's messages as `tun` through the +box's uplink, as it does a phone's, and cannot read or forge them. + +Nothing here has run on the host. The owner creates the AWS resources below +and turns `ftwkeysd` on. + +## Shape + +| | `ftwcloudd` | `ftwkeysd` | +|---|---|---| +| User | 65532 (`nonroot`) | 65533, group 65532 | +| Network | bridge, 443 published | the host's, listens on nothing | +| Reaches | boxes, phones, assistants | KMS, through the instance role | +| State | `ftw-cloud-state` (registry, OAuth token hashes) | `ftw-cloud-keys` (sealed agent keys) | +| Socket | connects to `/run/ftw-cloud-keys/keys.sock` | owns it, mode 0660, checks the peer's uid | + +- **One KMS key wraps the data keys.** Each agent key has its own AES-256 data + key from `kms:GenerateDataKey`, and the agent key 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 agent key. +- **A key is unsealed only to open a session.** `kms:Decrypt` runs once per + session. The secret is wiped after handshake message 1. The session's + transport keys stay in `ftwkeysd` until the session ends: `ftwcloudd` closes + a warm session after a short idle time, and `ftwkeysd` closes any session + idle for 10 minutes. +- **A key waits for approval.** Each authorize request makes a key under + `pending/`. It opens no session until the owner approves the agent and the + box's signed roster lists it; then it moves to `kept/`. An unapproved key is + deleted after an hour. +- **Revoking deletes the key.** When the box's roster drops an agent, or it + expires, `ftwcloudd` asks `ftwkeysd` to forget the key, which ends its + sessions too. +- **Nothing logs a key.** Errors on the socket are a few fixed words. +- **The volume is not backed up.** Losing it means every assistant must be + approved again; no home is exposed by it. + +### Only `ftwkeysd` reaches KMS + +The instance role can call KMS, so the separation rests on who can reach the +role's credentials. The instance metadata service answers IMDSv2 token +requests one network hop away when the hop limit is 1. A container on Docker's +bridge network is two hops away and gets no credentials; `ftwkeysd` uses the +host's network and gets them. `ftwcloudd` needs no AWS credentials: the host +fetches its secrets before Docker starts. + +The KMS key's policy lets only the host role use it, and only with the +`ftw-agent-key` purpose. The account root can administer the key but not use +it, so another role in the shared account cannot be granted decrypt by IAM +alone. + +If the cloud is fully broken into, the attacker can act as the existing +agents, within the limits their boxes enforce, until the owners revoke them +or they expire. The attacker cannot act on a home without an agent, and +cannot read the apps' sessions with their boxes. + +## Turning it on + +1. **The KMS key** (once, from a workstation with admin rights): + + ```bash + key=$(aws kms create-key --region eu-north-1 \ + --description 'FTW Cloud agent keys (ftwkeysd)' \ + --key-spec SYMMETRIC_DEFAULT --key-usage ENCRYPT_DECRYPT \ + --policy file://deploy/keys-kms-policy.json \ + --tags TagKey=Project,TagValue=ftw-cloud \ + --query KeyMetadata.Arn --output text) + aws kms create-alias --region eu-north-1 --alias-name alias/ftw-cloud-agent-keys \ + --target-key-id "$key" + aws kms enable-key-rotation --region eu-north-1 --key-id "$key" + echo "$key" + ``` + +2. **The role's policy.** Put the key's id into + [`keys-host-policy.json`](keys-host-policy.json) in place of `KEY-ID`, then: + + ```bash + aws iam put-role-policy --role-name ftw-cloud-host --policy-name ftw-cloud-agent-keys \ + --policy-document file://deploy/keys-host-policy.json + ``` + +3. **One hop to the metadata service**, so no bridged container gets the + role's credentials: + + ```bash + aws ec2 modify-instance-metadata-options --region eu-north-1 \ + --instance-id i-0ecac4cbc6b5f8adc --http-tokens required \ + --http-put-response-hop-limit 1 + ``` + + Check from the host that a bridged container is refused: + `docker run --rm curlimages/curl -s -m 3 -X PUT + http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60'` + must time out or fail. + +4. **The host.** Through SSM, with the key's ARN: + + ```bash + aws ssm send-command --region eu-north-1 --instance-ids i-0ecac4cbc6b5f8adc \ + --document-name AWS-RunShellScript \ + --parameters "$(jq -n --rawfile s deploy/keys.sh --arg k "$key" \ + '{commands: ["set -- \($k)", $s]}')" + ``` + + SSM runs the lines as one script, so `set --` hands the script its + argument. It makes `/run/ftw-cloud-keys` at every boot and sets + `FTW_CLOUD_KMS_KEY` and `COMPOSE_PROFILES=agents` in + `/etc/ftw-cloud/compose.env`. + +5. **Deploy** as usual (`ftw-cloud-deploy sha256:`). Compose starts + `ftwkeysd` beside `ftwcloudd`. Its log says `ftwkeysd listening`. + +To turn assistants off again, remove `COMPOSE_PROFILES=agents` and deploy. +Approved agents stay sealed in the volume and work again when it comes back, +unless their owners revoked them meanwhile. + +## Development + +```bash +go run ./cmd/ftwkeysd filekey -out dev-keys.key +go run ./cmd/ftwkeysd -socket /tmp/ftwkeys/keys.sock -state /tmp/ftwkeys/state -file-key dev-keys.key +``` + +A file key is for development only: whoever reads it and the state directory +has every agent key. diff --git a/deploy/keys.sh b/deploy/keys.sh new file mode 100755 index 0000000..ad8a8d1 --- /dev/null +++ b/deploy/keys.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Prepare the FTW Cloud host for ftwkeysd, the keys part. Run as root on the +# host through SSM, after the KMS key exists (deploy/keys.md). Safe to re-run. +# +# It makes the socket's directory at every boot, owned by ftwkeysd's user +# and readable by ftwcloudd's group, and turns ftwkeysd on in compose.env. +# It touches no AWS resource. +set -euo pipefail +key_arn="${1:?usage: keys.sh }" +case "$key_arn" in + arn:aws:kms:eu-north-1:*:key/*) ;; + *) echo "not a KMS key ARN in eu-north-1: $key_arn" >&2; exit 1 ;; +esac + +# ftwkeysd runs as 65533 in ftwcloudd's group, 65532. The directory is the +# only way to the socket: ftwcloudd may enter it and connect, and nothing +# else on the host but root may. +cat > /etc/tmpfiles.d/ftw-cloud-keys.conf <<'TMPFILES' +d /run/ftw-cloud-keys 0750 65533 65532 - +TMPFILES +systemd-tmpfiles --create /etc/tmpfiles.d/ftw-cloud-keys.conf + +env=/etc/ftw-cloud/compose.env +touch "$env" +sed -i '/^FTW_CLOUD_KMS_KEY=/d; /^COMPOSE_PROFILES=/d' "$env" +printf 'FTW_CLOUD_KMS_KEY=%s\nCOMPOSE_PROFILES=agents\n' "$key_arn" >> "$env" +echo "ftwkeysd is on; the next ftw-cloud-deploy (or a restart of ftw-cloud.service) starts it" diff --git a/go.mod b/go.mod index fbaf99f..60a74e4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/srcfl/ftw-cloud go 1.26.0 require ( + github.com/aws/aws-sdk-go-v2 v1.47.3 + github.com/aws/aws-sdk-go-v2/config v1.33.9 + github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1 @@ -12,6 +15,18 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect + github.com/aws/smithy-go v1.28.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect diff --git a/go.sum b/go.sum index 83b0615..68aa95a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,33 @@ +github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= +github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= +github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0= +github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w= +github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 h1:vW7497x7W6ucLJAqDt96jIdO4+IWLMB3p98UubwjdqM= +github.com/aws/aws-sdk-go-v2/service/kms v1.61.4/go.mod h1:Vz59N7x42VGLXZ5dGL9b10QhrU4GQv7MqnO7wc8bl9U= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM= +github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k= +github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= diff --git a/internal/keys/client.go b/internal/keys/client.go new file mode 100644 index 0000000..77fc564 --- /dev/null +++ b/internal/keys/client.go @@ -0,0 +1,174 @@ +package keys + +import ( + "context" + "errors" + "net" + "sync" + "time" +) + +// Client is ftwcloudd's side of the keys socket. One connection carries +// every request; it is dialled on first use and again after it breaks. Safe +// for concurrent use. Requests on one session must not overlap: the caller +// keeps each session's frames in order. +type Client struct { + dial func() (net.Conn, error) + + mu sync.Mutex + conn net.Conn + next uint64 + waiting map[uint64]chan response + wmu sync.Mutex +} + +// Dial is a client for the socket at path. Nothing connects until the first +// request. +func Dial(path string) *Client { + return newClient(func() (net.Conn, error) { + var d net.Dialer + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return d.DialContext(ctx, "unix", path) + }) +} + +func newClient(dial func() (net.Conn, error)) *Client { + return &Client{dial: dial, waiting: map[uint64]chan response{}} +} + +// ErrUnavailable is a keys part that cannot be reached. +var ErrUnavailable = errors.New("keys: not reachable") + +func (c *Client) call(ctx context.Context, req request) (response, error) { + c.mu.Lock() + if c.conn == nil { + conn, err := c.dial() + if err != nil { + c.mu.Unlock() + return response{}, ErrUnavailable + } + c.conn = conn + go c.read(conn) + } + conn := c.conn + c.next++ + req.ID = c.next + ch := make(chan response, 1) + c.waiting[req.ID] = ch + c.mu.Unlock() + + c.wmu.Lock() + err := writeFrame(conn, req) + c.wmu.Unlock() + if err != nil { + c.drop(conn) + return response{}, ErrUnavailable + } + select { + case resp, ok := <-ch: + if !ok { + return response{}, ErrUnavailable + } + return resp, errorFrom(resp.Err) + case <-ctx.Done(): + c.mu.Lock() + delete(c.waiting, req.ID) + c.mu.Unlock() + return response{}, ctx.Err() + } +} + +func (c *Client) read(conn net.Conn) { + for { + var resp response + if err := readFrame(conn, &resp); err != nil { + c.drop(conn) + return + } + c.mu.Lock() + ch := c.waiting[resp.ID] + delete(c.waiting, resp.ID) + c.mu.Unlock() + if ch != nil { + ch <- resp + } + } +} + +// drop forgets a broken connection and fails everything waiting on it. +func (c *Client) drop(conn net.Conn) { + _ = conn.Close() + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != conn { + return + } + c.conn = nil + for id, ch := range c.waiting { + close(ch) + delete(c.waiting, id) + } +} + +// Close closes the connection. +func (c *Client) Close() error { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + if conn != nil { + c.drop(conn) + } + return nil +} + +// New makes an agent key pair and returns its id and public half. The +// private half stays sealed in the keys part. Until Keep, it opens no +// session, and it is deleted within PendingFor. +func (c *Client) New(ctx context.Context) (kid string, pub []byte, err error) { + resp, err := c.call(ctx, request{Op: opNew}) + return resp.Kid, resp.Pub, err +} + +// Keep keeps a key whose agent the owner approved. +func (c *Client) Keep(ctx context.Context, kid string) error { + _, err := c.call(ctx, request{Op: opKeep, Kid: kid}) + return err +} + +// Forget deletes a key and ends its sessions. +func (c *Client) Forget(ctx context.Context, kid string) error { + _, err := c.call(ctx, request{Op: opForget, Kid: kid}) + return err +} + +// Open starts a session from agent key kid to the box whose Noise static key +// is boxStatic, and returns its id and handshake message 1. +func (c *Client) Open(ctx context.Context, kid string, boxStatic []byte) (sess string, msg1 []byte, err error) { + resp, err := c.call(ctx, request{Op: opOpen, Kid: kid, RS: boxStatic}) + return resp.Sess, resp.D, err +} + +// Finish reads the box's handshake message 2. The session is then ready. +func (c *Client) Finish(ctx context.Context, sess string, msg2 []byte) error { + _, err := c.call(ctx, request{Op: opFinish, Sess: sess, D: msg2}) + return err +} + +// Seal encrypts one frame of the session for the box. +func (c *Client) Seal(ctx context.Context, sess string, frame []byte) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opSeal, Sess: sess, D: frame}) + return resp.D, err +} + +// Unseal decrypts one message from the box. +func (c *Client) Unseal(ctx context.Context, sess string, msg []byte) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opUnseal, Sess: sess, D: msg}) + return resp.D, err +} + +// End closes a session. +func (c *Client) End(ctx context.Context, sess string) error { + _, err := c.call(ctx, request{Op: opClose, Sess: sess}) + return err +} diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go new file mode 100644 index 0000000..31edde4 --- /dev/null +++ b/internal/keys/keys_test.go @@ -0,0 +1,511 @@ +package keys + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "errors" + "log/slog" + "maps" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol/appwire" +) + +func newTestServer(t *testing.T) (*Server, *FileSealer, *bytes.Buffer) { + t.Helper() + master := bytes.Repeat([]byte{7}, dataKeyBytes) + sealer, err := FileSealerFromKey(master) + if err != nil { + t.Fatal(err) + } + var logs bytes.Buffer + log := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + s, err := NewServer(t.TempDir(), sealer, log) + if err != nil { + t.Fatal(err) + } + return s, sealer, &logs +} + +// box is the responder half of one session, as the box runs it. +type box struct { + static appwire.KeyPair +} + +func newBox(t *testing.T) box { + kp, err := appwire.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + return box{static: kp} +} + +func (b box) answer(t *testing.T, msg1 []byte) (msg2 []byte, tr *appwire.Transport, agent []byte) { + t.Helper() + hs, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: b.static, Prologue: SessionPrologue(b.static.Public())}) + if err != nil { + t.Fatal(err) + } + payload, err := hs.ReadMessage(msg1) + if err != nil { + t.Fatalf("the box cannot read message 1: %v", err) + } + if len(payload) != 0 { + t.Fatalf("message 1 carries %d bytes; a paired device sends none", len(payload)) + } + if msg2, err = hs.WriteMessage(nil); err != nil { + t.Fatal(err) + } + split, err := hs.Split() + if err != nil { + t.Fatal(err) + } + return msg2, appwire.NewTransport(split), split.RemoteStatic +} + +var ctx = context.Background() + +// A key opens a session only once its agent is approved, and the box then +// sees the key the owner approved: the public half the keys part handed out. +func TestAKeyOpensASessionOnlyAfterApproval(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + b := newBox(t) + + kid, pub, err := c.New(ctx) + if err != nil || len(pub) != appwire.DHBytes || !validKid(kid) { + t.Fatalf("new: %q %d %v", kid, len(pub), err) + } + if _, _, err := c.Open(ctx, kid, b.static.Public()); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("open before approval: %v", err) + } + if err := c.Keep(ctx, kid); err != nil { + t.Fatal(err) + } + if err := c.Keep(ctx, kid); err != nil { + t.Fatalf("keep is idempotent: %v", err) + } + sess, msg1, err := c.Open(ctx, kid, b.static.Public()) + if err != nil { + t.Fatal(err) + } + msg2, boxTr, agent := b.answer(t, msg1) + if !bytes.Equal(agent, pub) { + t.Fatal("the box sees another key than the one approved") + } + if err := c.Finish(ctx, sess, msg2); err != nil { + t.Fatal(err) + } + + // Both ways, in order and out of it. + for i := range 3 { + frame := []byte{byte(i), 'a', 'g', 'e', 'n', 't'} + msg, err := c.Seal(ctx, sess, frame) + if err != nil { + t.Fatal(err) + } + got, err := boxTr.Decrypt(msg) + if err != nil || !bytes.Equal(got, frame) { + t.Fatalf("box read %q, %v", got, err) + } + reply, _ := boxTr.Encrypt([]byte("box")) + back, err := c.Unseal(ctx, sess, reply) + if err != nil || string(back) != "box" { + t.Fatalf("agent read %q, %v", back, err) + } + } + // A replayed box message is refused. + reply, _ := boxTr.Encrypt([]byte("once")) + if _, err := c.Unseal(ctx, sess, reply); err != nil { + t.Fatal(err) + } + if _, err := c.Unseal(ctx, sess, reply); err == nil { + t.Fatal("a replay was accepted") + } + + if err := c.End(ctx, sess); err != nil { + t.Fatal(err) + } + if _, err := c.Seal(ctx, sess, []byte("x")); !errors.Is(err, ErrUnknownSession) { + t.Fatalf("seal after end: %v", err) + } +} + +// Message 1 is bound to one box: another box's key cannot read it, and the +// prologue names the box, so a session cannot be moved between them. +func TestASessionIsBoundToItsBox(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + kid, _, _ := c.New(ctx) + _ = c.Keep(ctx, kid) + a, other := newBox(t), newBox(t) + _, msg1, err := c.Open(ctx, kid, a.static.Public()) + if err != nil { + t.Fatal(err) + } + hs, _ := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: other.static, Prologue: SessionPrologue(other.static.Public())}) + if _, err := hs.ReadMessage(msg1); err == nil { + t.Fatal("another box read message 1") + } + if _, _, err := c.Open(ctx, kid, []byte("short")); !errors.Is(err, ErrRefused) { + t.Fatalf("open with a malformed box key: %v", err) + } +} + +// A bad message 2 ends the attempt rather than leaving a half-open session. +func TestABadMessage2EndsTheAttempt(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + kid, _, _ := c.New(ctx) + _ = c.Keep(ctx, kid) + b := newBox(t) + sess, _, _ := c.Open(ctx, kid, b.static.Public()) + if err := c.Finish(ctx, sess, bytes.Repeat([]byte{1}, 48)); !errors.Is(err, ErrRefused) { + t.Fatalf("finish with noise: %v", err) + } + if s.Sessions() != 0 { + t.Fatal("the failed attempt is still held") + } + // Sealing before the handshake finished is refused too. + sess, _, _ = c.Open(ctx, kid, b.static.Public()) + if _, err := c.Seal(ctx, sess, []byte("early")); !errors.Is(err, ErrRefused) { + t.Fatalf("seal before finish: %v", err) + } +} + +// Forget deletes the key and ends its sessions at once: revoking an agent +// leaves nothing in the keys part that could act for it. +func TestForgetEndsEverySessionOfTheKey(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + kid, _, _ := c.New(ctx) + _ = c.Keep(ctx, kid) + b := newBox(t) + sess, msg1, _ := c.Open(ctx, kid, b.static.Public()) + msg2, _, _ := b.answer(t, msg1) + _ = c.Finish(ctx, sess, msg2) + + other, _, _ := c.New(ctx) + _ = c.Keep(ctx, other) + osess, _, _ := c.Open(ctx, other, b.static.Public()) + + if err := c.Forget(ctx, kid); err != nil { + t.Fatal(err) + } + if _, err := c.Seal(ctx, sess, []byte("x")); !errors.Is(err, ErrUnknownSession) { + t.Fatalf("seal after forget: %v", err) + } + if _, _, err := c.Open(ctx, kid, b.static.Public()); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("open after forget: %v", err) + } + if _, err := os.Stat(filepath.Join(s.dir, "kept", kid)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("the sealed key is still on disk") + } + if _, err := c.Seal(ctx, osess, []byte("x")); errors.Is(err, ErrUnknownSession) { + t.Fatal("forgetting one key ended another key's session") + } +} + +// No key id names a file outside the state directory. +func TestKidsAreNeverPaths(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + for _, kid := range []string{"", "../kept/x", "..", "a/b", "AAAAAAAAAAAAAAAAAAAAAA==", "kept"} { + if err := c.Keep(ctx, kid); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("keep %q: %v", kid, err) + } + if err := c.Forget(ctx, kid); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("forget %q: %v", kid, err) + } + if _, _, err := c.Open(ctx, kid, make([]byte, 32)); !errors.Is(err, ErrUnknownKey) { + t.Fatalf("open %q: %v", kid, err) + } + } +} + +// The secret is on disk only sealed, a record moved to another kid does not +// open, and nothing the keys part logs or answers carries it. +func TestTheKeyIsSealedAndNeverLogged(t *testing.T) { + s, sealer, logs := newTestServer(t) + c := s.Local() + b := newBox(t) + kid, pub, _ := c.New(ctx) + _ = c.Keep(ctx, kid) + raw, err := os.ReadFile(filepath.Join(s.dir, "kept", kid)) + if err != nil { + t.Fatal(err) + } + var rec record + if err := decMode.Unmarshal(raw, &rec); err != nil { + t.Fatal(err) + } + dek, err := sealer.Unwrap(ctx, kid, rec.DEK) + if err != nil { + t.Fatal(err) + } + aead, _ := newGCM(dek) + secret, err := open(aead, rec.CT, keyAAD(kid, pub)) + if err != nil || len(secret) != 32 { + t.Fatalf("the test cannot unseal its own key: %v", err) + } + if bytes.Contains(raw, secret) { + t.Fatal("the secret is on disk in the clear") + } + + // The same record under another kid does not open: the kid is bound + // into both the data key and the sealed secret. + other, _, _ := c.New(ctx) + _ = c.Keep(ctx, other) + if err := os.WriteFile(filepath.Join(s.dir, "kept", other), bytes.ReplaceAll(raw, []byte(kid), []byte(other)), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := c.Open(ctx, other, b.static.Public()); err == nil { + t.Fatal("a sealed key opened under another kid") + } + + // Run every operation, then look for the secret in what was said. + sess, msg1, _ := c.Open(ctx, kid, b.static.Public()) + msg2, _, _ := b.answer(t, msg1) + _ = c.Finish(ctx, sess, msg2) + _, _ = c.Seal(ctx, sess, []byte("frame")) + _, _ = c.Unseal(ctx, sess, []byte("not a message at all, at least 24 bytes")) + _ = c.Forget(ctx, kid) + for _, form := range [][]byte{secret, []byte(hex.EncodeToString(secret)), []byte(base64.StdEncoding.EncodeToString(secret)), + []byte(base64.RawURLEncoding.EncodeToString(secret)), dek, []byte(hex.EncodeToString(dek))} { + if bytes.Contains(logs.Bytes(), form) { + t.Fatal("the log carries key material") + } + } +} + +// An error crossing the socket is one of a few fixed words. +func TestErrorsSayNothingAboutKeys(t *testing.T) { + failing := errors.New("kms said: key material 0123456789abcdef") + if got := errorText(failing); got != "failed" { + t.Fatalf("errorText = %q", got) + } + if got := errorText(errors.Join(ErrBusy, failing)); got != ErrBusy.Error() { + t.Fatalf("errorText = %q", got) + } +} + +// fakeKMS checks the encryption context the way KMS does: a blob opens only +// under the context it was made with. +type fakeKMS struct { + mu sync.Mutex + blobs map[string]struct { + plain []byte + ctx map[string]string + key string + } + calls int +} + +func (f *fakeKMS) GenerateDataKey(_ context.Context, keyID string, c map[string]string) ([]byte, []byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + plain := bytes.Repeat([]byte{byte(f.calls)}, 32) + blob := []byte("blob-" + string(rune('a'+f.calls))) + if f.blobs == nil { + f.blobs = map[string]struct { + plain []byte + ctx map[string]string + key string + }{} + } + f.blobs[string(blob)] = struct { + plain []byte + ctx map[string]string + key string + }{bytes.Clone(plain), maps.Clone(c), keyID} + return plain, blob, nil +} + +func (f *fakeKMS) Decrypt(_ context.Context, keyID string, blob []byte, c map[string]string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + b, ok := f.blobs[string(blob)] + if !ok || b.key != keyID || !maps.Equal(b.ctx, c) { + return nil, errors.New("InvalidCiphertextException") + } + return bytes.Clone(b.plain), nil +} + +// With KMS, each key has its own data key, wrapped under a context that +// names the purpose and the kid; the key policy can require the purpose. +func TestKMSSealerBindsTheContext(t *testing.T) { + kms := &fakeKMS{} + sealer := KMSSealer{KMS: kms, KeyID: "alias/ftw-cloud-agent-keys"} + s, err := NewServer(t.TempDir(), sealer, nil) + if err != nil { + t.Fatal(err) + } + c := s.Local() + b := newBox(t) + kid, _, err := c.New(ctx) + if err != nil { + t.Fatal(err) + } + _ = c.Keep(ctx, kid) + for _, blob := range kms.blobs { + if blob.ctx["purpose"] != EncryptionContextPurpose || blob.ctx["kid"] != kid { + t.Fatalf("encryption context %v", blob.ctx) + } + } + before := kms.calls + sess, msg1, err := c.Open(ctx, kid, b.static.Public()) + if err != nil || sess == "" { + t.Fatalf("open through KMS: %v", err) + } + if kms.calls != before+1 { + t.Fatalf("opening a session made %d KMS calls, want 1", kms.calls-before) + } + _, _, _ = b.answer(t, msg1) + + // The wrapped data key does not open for another kid. + var wrapped []byte + for blob := range kms.blobs { + wrapped = []byte(blob) + } + if _, err := sealer.Unwrap(ctx, "AAAAAAAAAAAAAAAAAAAAAA", wrapped); err == nil { + t.Fatal("a data key opened under another kid") + } +} + +// Keys whose approval never came go after PendingFor, and idle sessions +// after IdleFor. +func TestSweepDropsStalePendingKeysAndIdleSessions(t *testing.T) { + s, _, _ := newTestServer(t) + now := time.Now() + s.now = func() time.Time { return now } + c := s.Local() + stale, _, _ := c.New(ctx) + fresh, _, _ := c.New(ctx) + kept, _, _ := c.New(ctx) + _ = c.Keep(ctx, kept) + old := now.Add(-PendingFor - time.Minute) + _ = os.Chtimes(filepath.Join(s.dir, "pending", stale), old, old) + _ = os.Chtimes(filepath.Join(s.dir, "kept", kept), old, old) + b := newBox(t) + sess, _, _ := c.Open(ctx, kept, b.static.Public()) + + now = now.Add(IdleFor + time.Second) + s.Sweep() + if _, err := os.Stat(filepath.Join(s.dir, "pending", stale)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a stale pending key is still there") + } + for _, p := range []string{filepath.Join("pending", fresh), filepath.Join("kept", kept)} { + if _, err := os.Stat(filepath.Join(s.dir, p)); err != nil { + t.Fatalf("%s went: %v", p, err) + } + } + if _, err := c.Seal(ctx, sess, []byte("x")); !errors.Is(err, ErrUnknownSession) { + t.Fatal("an idle session outlived the sweep") + } +} + +// The socket: the peer's user is checked, and the client dials again after +// ftwkeysd restarts. A session does not survive the restart. +func TestTheSocketChecksThePeerAndSurvivesARestart(t *testing.T) { + dir, err := os.MkdirTemp("", "k") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "s") + s, _, _ := newTestServer(t) + serve := func(srv *Server) net.Listener { + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(ln) }() + return ln + } + ln := serve(s) + c := Dial(path) + defer c.Close() + kid, _, err := c.New(ctx) + if err != nil { + t.Fatalf("own user refused: %v", err) + } + _ = c.Keep(ctx, kid) + b := newBox(t) + sess, _, err := c.Open(ctx, kid, b.static.Public()) + if err != nil { + t.Fatal(err) + } + + // Restart: same state directory, new process, sessions gone. + _ = ln.Close() + _ = c.Close() + s2, err := NewServer(s.dir, s.sealer, nil) + if err != nil { + t.Fatal(err) + } + ln = serve(s2) + if _, err := c.Seal(ctx, sess, []byte("x")); !errors.Is(err, ErrUnknownSession) { + t.Fatalf("a session survived the restart: %v", err) + } + if _, _, err := c.Open(ctx, kid, b.static.Public()); err != nil { + t.Fatalf("a kept key did not survive the restart: %v", err) + } + + // Another user is turned away before a single request is read. + _ = ln.Close() + _ = c.Close() + s3, _ := NewServer(s.dir, s.sealer, nil) + s3.PeerUID = os.Getuid() + 1 + ln = serve(s3) + defer ln.Close() + if _, _, err := c.New(ctx); err == nil { + t.Fatal("a peer of another user was served") + } +} + +// Many sessions at once, each kept in order by its caller. +func TestManySessionsAtOnce(t *testing.T) { + s, _, _ := newTestServer(t) + c := s.Local() + kid, _, _ := c.New(ctx) + _ = c.Keep(ctx, kid) + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + b := newBox(t) + sess, msg1, err := c.Open(ctx, kid, b.static.Public()) + if err != nil { + t.Error(err) + return + } + msg2, tr, _ := b.answer(t, msg1) + if err := c.Finish(ctx, sess, msg2); err != nil { + t.Error(err) + return + } + for i := range 20 { + msg, err := c.Seal(ctx, sess, []byte{byte(i)}) + if err != nil { + t.Error(err) + return + } + if got, err := tr.Decrypt(msg); err != nil || got[0] != byte(i) { + t.Errorf("frame %d: %v", i, err) + return + } + } + }() + } + wg.Wait() +} diff --git a/internal/keys/peer_darwin.go b/internal/keys/peer_darwin.go new file mode 100644 index 0000000..52533c9 --- /dev/null +++ b/internal/keys/peer_darwin.go @@ -0,0 +1,35 @@ +package keys + +import ( + "errors" + "net" + "os" + + "golang.org/x/sys/unix" +) + +// checkPeer refuses a unix connection from any user but uid, or from any +// user but this process's own when uid is negative. +func checkPeer(c *net.UnixConn, uid int) error { + if uid < 0 { + uid = os.Getuid() + } + raw, err := c.SyscallConn() + if err != nil { + return err + } + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return err + } + if credErr != nil { + return credErr + } + if int(cred.Uid) != uid { + return errors.New("keys: a peer of another user") + } + return nil +} diff --git a/internal/keys/peer_linux.go b/internal/keys/peer_linux.go new file mode 100644 index 0000000..b690437 --- /dev/null +++ b/internal/keys/peer_linux.go @@ -0,0 +1,35 @@ +package keys + +import ( + "errors" + "net" + "os" + + "golang.org/x/sys/unix" +) + +// checkPeer refuses a unix connection from any user but uid, or from any +// user but this process's own when uid is negative. +func checkPeer(c *net.UnixConn, uid int) error { + if uid < 0 { + uid = os.Getuid() + } + raw, err := c.SyscallConn() + if err != nil { + return err + } + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return err + } + if credErr != nil { + return credErr + } + if int(cred.Uid) != uid { + return errors.New("keys: a peer of another user") + } + return nil +} diff --git a/internal/keys/peer_other.go b/internal/keys/peer_other.go new file mode 100644 index 0000000..2127653 --- /dev/null +++ b/internal/keys/peer_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !darwin + +package keys + +import ( + "errors" + "net" +) + +// checkPeer cannot tell who is on the other end here, so it refuses. +func checkPeer(*net.UnixConn, int) error { + return errors.New("keys: peer credentials are not available on this system") +} diff --git a/internal/keys/seal.go b/internal/keys/seal.go new file mode 100644 index 0000000..910812b --- /dev/null +++ b/internal/keys/seal.go @@ -0,0 +1,178 @@ +package keys + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" +) + +// A Sealer makes and unwraps the data keys that seal agent keys at rest: +// envelope encryption, one data key per agent key. In production the data +// keys are wrapped by one AWS KMS key that only ftwkeysd's role may use; in +// development, by a key in a local file. +type Sealer interface { + // DataKey returns a new data key and its wrapped form, bound to kid. + DataKey(ctx context.Context, kid string) (plain, wrapped []byte, err error) + // Unwrap returns the data key wrapped for kid. + Unwrap(ctx context.Context, kid string, wrapped []byte) ([]byte, error) +} + +// dataKeyBytes is an AES-256 key. +const dataKeyBytes = 32 + +// KMS is the part of AWS KMS the sealer uses. cmd/ftwkeysd adapts the AWS +// SDK to it, so this package and its tests do not link the SDK. +type KMS interface { + // GenerateDataKey asks for a new AES-256 data key under keyID with the + // encryption context, as kms:GenerateDataKey. + GenerateDataKey(ctx context.Context, keyID string, context map[string]string) (plain, blob []byte, err error) + // Decrypt unwraps blob under keyID with the same context, as + // kms:Decrypt. + Decrypt(ctx context.Context, keyID string, blob []byte, context map[string]string) ([]byte, error) +} + +// KMSSealer wraps data keys with one AWS KMS key. The encryption context +// names the purpose and the key id, so a wrapped data key opens only for the +// agent key it was made for, and the key policy can require the purpose. +type KMSSealer struct { + KMS KMS + KeyID string +} + +// EncryptionContextPurpose is the purpose in every encryption context. The +// KMS key policy allows ftwkeysd's role only with it. +const EncryptionContextPurpose = "ftw-agent-key" + +func kmsContext(kid string) map[string]string { + return map[string]string{"purpose": EncryptionContextPurpose, "kid": kid} +} + +func (s KMSSealer) DataKey(ctx context.Context, kid string) ([]byte, []byte, error) { + plain, blob, err := s.KMS.GenerateDataKey(ctx, s.KeyID, kmsContext(kid)) + if err != nil { + return nil, nil, err + } + if len(plain) != dataKeyBytes || len(blob) == 0 { + return nil, nil, errors.New("keys: KMS returned a data key of the wrong size") + } + return plain, blob, nil +} + +func (s KMSSealer) Unwrap(ctx context.Context, kid string, wrapped []byte) ([]byte, error) { + plain, err := s.KMS.Decrypt(ctx, s.KeyID, wrapped, kmsContext(kid)) + if err != nil { + return nil, err + } + if len(plain) != dataKeyBytes { + return nil, errors.New("keys: KMS returned a data key of the wrong size") + } + return plain, nil +} + +// FileSealer wraps data keys with a 32-byte key from a local file. It is for +// development and tests: whoever reads the file and the state directory has +// every agent key. +type FileSealer struct { + aead cipher.AEAD +} + +// NewFileSealer reads a key file of 32 bytes, raw or as 64 hex digits. +func NewFileSealer(path string) (*FileSealer, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("file key: %w", err) + } + key := raw + if s := strings.TrimSpace(string(raw)); len(s) == 2*dataKeyBytes { + if key, err = hex.DecodeString(s); err != nil { + return nil, fmt.Errorf("file key: %w", err) + } + } + if len(key) != dataKeyBytes { + return nil, fmt.Errorf("file key: %s holds %d bytes, want %d", path, len(key), dataKeyBytes) + } + return FileSealerFromKey(key) +} + +// FileSealerFromKey is a FileSealer around key. +func FileSealerFromKey(key []byte) (*FileSealer, error) { + aead, err := newGCM(key) + if err != nil { + return nil, err + } + return &FileSealer{aead: aead}, nil +} + +// MakeFileKey writes a new file key that only its owner can read, and never +// over an existing file. +func MakeFileKey(path string) error { + key := make([]byte, dataKeyBytes) + if _, err := rand.Read(key); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := f.Write([]byte(hex.EncodeToString(key) + "\n")); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +func fileAAD(kid string) []byte { return []byte("ftw.agent-dek.v1\x00" + kid) } + +func (s *FileSealer) DataKey(_ context.Context, kid string) ([]byte, []byte, error) { + plain := make([]byte, dataKeyBytes) + if _, err := rand.Read(plain); err != nil { + return nil, nil, err + } + wrapped, err := seal(s.aead, plain, fileAAD(kid)) + if err != nil { + return nil, nil, err + } + return plain, wrapped, nil +} + +func (s *FileSealer) Unwrap(_ context.Context, kid string, wrapped []byte) ([]byte, error) { + return open(s.aead, wrapped, fileAAD(kid)) +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// seal is nonce ‖ AES-GCM(plain, aad). +func seal(aead cipher.AEAD, plain, aad []byte) ([]byte, error) { + nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(plain)+aead.Overhead()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return aead.Seal(nonce, nonce, plain, aad), nil +} + +func open(aead cipher.AEAD, sealed, aad []byte) ([]byte, error) { + if len(sealed) < aead.NonceSize()+aead.Overhead() { + return nil, errors.New("keys: sealed data too short") + } + n := aead.NonceSize() + return aead.Open(nil, sealed[:n], sealed[n:], aad) +} + +// wipe overwrites a secret held in a slice. Go may have copied it elsewhere, +// so this narrows how long a key lingers in memory; it does not promise +// that no copy is left. +func wipe(b []byte) { + clear(b) +} diff --git a/internal/keys/server.go b/internal/keys/server.go new file mode 100644 index 0000000..1788a73 --- /dev/null +++ b/internal/keys/server.go @@ -0,0 +1,513 @@ +package keys + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "io/fs" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// Limits. +const ( + // MaxPending bounds the keys made for consents the owner has not + // approved yet. Each authorize request makes one, so this bounds what a + // stranger can make the keys part hold. + MaxPending = 10_000 + // PendingFor is how long an unapproved key is kept. A consent page + // lasts far less. + PendingFor = time.Hour + // MaxSessions bounds the open sessions, and IdleFor how long one lasts + // unused. ftwcloudd closes its warm sessions sooner; these catch one it + // lost track of. + MaxSessions = 4096 + IdleFor = 10 * time.Minute + // unwrapTimeout bounds one KMS call. + unwrapTimeout = 5 * time.Second +) + +// SessionPrologue is the box session's prologue, as the box spells it +// (appuplink.prologue in srcfl/ftw). The keys part sets it itself, so it +// opens sessions for this protocol and no other. +func SessionPrologue(boxStatic []byte) []byte { + return append([]byte("ftw.session.v1:"), boxStatic...) +} + +// Server is ftwkeysd: the sealed keys on disk and the open sessions in +// memory. +type Server struct { + dir string + sealer Sealer + log *slog.Logger + now func() time.Time + // PeerUID is the one user allowed to connect, checked on each accepted + // unix connection. Negative allows the server's own user only. + PeerUID int + + mu sync.Mutex + sessions map[string]*session +} + +type session struct { + kid string + mu sync.Mutex + init *appwire.InitiatorState + tr *appwire.Transport + used time.Time +} + +// NewServer keeps sealed keys under dir: pending/ until the owner approves, +// then kept/. +func NewServer(dir string, sealer Sealer, log *slog.Logger) (*Server, error) { + if log == nil { + log = slog.Default() + } + for _, d := range []string{"pending", "kept"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o700); err != nil { + return nil, err + } + } + return &Server{dir: dir, sealer: sealer, log: log, now: time.Now, PeerUID: -1, sessions: map[string]*session{}}, nil +} + +// Serve accepts connections until ln closes. Each must come from PeerUID. +func (s *Server) Serve(ln net.Listener) error { + for { + conn, err := ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return err + } + if uc, ok := conn.(*net.UnixConn); ok { + if err := checkPeer(uc, s.PeerUID); err != nil { + s.log.Warn("refused a connection from another user") + _ = conn.Close() + continue + } + } + go s.serveConn(conn) + } +} + +// Local is a client wired to this server in memory, for tests and for a +// development build that runs both parts in one process. +func (s *Server) Local() *Client { + a, b := net.Pipe() + go s.serveConn(b) + return newClient(func() (net.Conn, error) { return a, nil }) +} + +func (s *Server) serveConn(conn net.Conn) { + defer conn.Close() + var wmu sync.Mutex + for { + var req request + if err := readFrame(conn, &req); err != nil { + return + } + go func() { + resp := s.handle(req) + resp.ID = req.ID + wmu.Lock() + defer wmu.Unlock() + _ = writeFrame(conn, resp) + }() + } +} + +func (s *Server) handle(req request) response { + var ( + resp response + err error + ) + switch req.Op { + case opNew: + resp.Kid, resp.Pub, err = s.newKey() + case opKeep: + err = s.keep(req.Kid) + case opForget: + err = s.forget(req.Kid) + case opOpen: + resp.Sess, resp.D, err = s.open(req.Kid, req.RS) + case opFinish: + resp.D, err = s.finish(req.Sess, req.D) + case opSeal: + err = s.with(req.Sess, func(x *session) error { + if x.tr == nil { + return ErrRefused + } + resp.D, err = x.tr.Encrypt(req.D) + return err + }) + case opUnseal: + err = s.with(req.Sess, func(x *session) error { + if x.tr == nil { + return ErrRefused + } + resp.D, err = x.tr.Decrypt(req.D) + return err + }) + case opClose: + s.end(req.Sess) + default: + err = ErrRefused + } + if err != nil { + return response{Err: errorText(err)} + } + return resp +} + +// -------------------------------------------------------------------------- +// Keys at rest +// -------------------------------------------------------------------------- + +// record is one sealed key on disk. +type record struct { + V int `cbor:"v"` + Kid string `cbor:"kid"` + Pub []byte `cbor:"pub"` + // DEK is the data key, wrapped by the sealer; CT the X25519 secret + // sealed with it. + DEK []byte `cbor:"dek"` + CT []byte `cbor:"ct"` + At int64 `cbor:"at"` +} + +func keyAAD(kid string, pub []byte) []byte { + return append([]byte("ftw.agent-key.v1\x00"+kid+"\x00"), pub...) +} + +// validKid is the shape newKey makes: 16 random bytes in base64url. Nothing +// else is a file name here. +func validKid(kid string) bool { + b, err := base64.RawURLEncoding.DecodeString(kid) + return err == nil && len(b) == 16 && base64.RawURLEncoding.EncodeToString(b) == kid +} + +func (s *Server) newKey() (string, []byte, error) { + if n, _ := countDir(filepath.Join(s.dir, "pending")); n >= MaxPending { + return "", nil, ErrBusy + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return "", nil, err + } + kid := base64.RawURLEncoding.EncodeToString(id) + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", nil, err + } + pub := priv.PublicKey().Bytes() + secret := priv.Bytes() + defer wipe(secret) + + ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) + defer cancel() + dek, wrapped, err := s.sealer.DataKey(ctx, kid) + if err != nil { + s.log.Warn("could not make a data key", "err", err) + return "", nil, err + } + defer wipe(dek) + aead, err := newGCM(dek) + if err != nil { + return "", nil, err + } + ct, err := seal(aead, secret, keyAAD(kid, pub)) + if err != nil { + return "", nil, err + } + raw, err := encMode.Marshal(record{V: 1, Kid: kid, Pub: pub, DEK: wrapped, CT: ct, At: s.now().UnixMilli()}) + if err != nil { + return "", nil, err + } + if err := writeFile(filepath.Join(s.dir, "pending", kid), raw); err != nil { + return "", nil, err + } + return kid, pub, nil +} + +func (s *Server) keep(kid string) error { + if !validKid(kid) { + return ErrUnknownKey + } + from, to := filepath.Join(s.dir, "pending", kid), filepath.Join(s.dir, "kept", kid) + if err := os.Rename(from, to); err != nil { + if _, statErr := os.Stat(to); statErr == nil { + return nil // kept already + } + return ErrUnknownKey + } + return syncDir(filepath.Join(s.dir, "kept")) +} + +func (s *Server) forget(kid string) error { + if !validKid(kid) { + return ErrUnknownKey + } + for _, d := range []string{"pending", "kept"} { + if err := os.Remove(filepath.Join(s.dir, d, kid)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + s.mu.Lock() + var ending []*session + for id, x := range s.sessions { + if x.kid == kid { + ending = append(ending, x) + delete(s.sessions, id) + } + } + s.mu.Unlock() + for _, x := range ending { + x.close() + } + return nil +} + +// unseal reads a kept key. A key whose approval is still pending opens no +// session. +func (s *Server) unseal(kid string) ([]byte, error) { + if !validKid(kid) { + return nil, ErrUnknownKey + } + raw, err := os.ReadFile(filepath.Join(s.dir, "kept", 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 + } + ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) + defer cancel() + dek, err := s.sealer.Unwrap(ctx, kid, rec.DEK) + if err != nil { + s.log.Warn("could not unwrap a data key", "err", err) + return nil, err + } + defer wipe(dek) + aead, err := newGCM(dek) + if err != nil { + return nil, err + } + secret, err := open(aead, rec.CT, keyAAD(kid, rec.Pub)) + if err != nil { + return nil, ErrUnknownKey + } + return secret, nil +} + +// -------------------------------------------------------------------------- +// Sessions +// -------------------------------------------------------------------------- + +// open unseals kid and starts a session to the box whose static key is rs. +// 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) { + if len(rs) != appwire.DHBytes { + return "", nil, ErrRefused + } + s.mu.Lock() + full := len(s.sessions) >= MaxSessions + s.mu.Unlock() + if full { + return "", nil, ErrBusy + } + secret, err := s.unseal(kid) + if err != nil { + return "", nil, err + } + kp, err := appwire.KeyPairFromSecret(secret) + wipe(secret) + if err != nil { + return "", nil, err + } + 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) + if err != nil { + return "", nil, err + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return "", nil, err + } + sess := base64.RawURLEncoding.EncodeToString(id) + 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()} + return sess, msg1, nil +} + +func (s *Server) finish(sess string, msg2 []byte) ([]byte, error) { + var payload []byte + err := s.with(sess, func(x *session) error { + if x.init == nil { + return ErrRefused + } + p, err := x.init.ReadMessage(msg2) + if err != nil { + return ErrRefused + } + split, err := x.init.Split() + if err != nil { + return err + } + x.init, x.tr, payload = nil, appwire.NewTransport(split), p + return nil + }) + if errors.Is(err, ErrRefused) { + // A message 2 that does not read ends the attempt: the handshake + // state cannot take another. + s.end(sess) + } + return payload, err +} + +// with runs fn on one session under its own lock. +func (s *Server) with(sess string, fn func(*session) error) error { + s.mu.Lock() + x := s.sessions[sess] + s.mu.Unlock() + if x == nil { + return ErrUnknownSession + } + x.mu.Lock() + defer x.mu.Unlock() + x.used = s.now() + return fn(x) +} + +func (s *Server) end(sess string) { + s.mu.Lock() + x := s.sessions[sess] + delete(s.sessions, sess) + s.mu.Unlock() + if x != nil { + x.close() + } +} + +func (x *session) close() { + x.mu.Lock() + defer x.mu.Unlock() + if x.tr != nil { + x.tr.Close() + } + x.init, x.tr = nil, nil +} + +// Sweep closes idle sessions and deletes keys whose approval never came. Run +// it every minute or so. +func (s *Server) Sweep() { + now := s.now() + s.mu.Lock() + var idle []*session + for id, x := range s.sessions { + if x.mu.TryLock() { + stale := now.Sub(x.used) > IdleFor + x.mu.Unlock() + if stale { + idle = append(idle, x) + delete(s.sessions, id) + } + } + } + s.mu.Unlock() + for _, x := range idle { + x.close() + } + dir := filepath.Join(s.dir, "pending") + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + info, err := e.Info() + if err == nil && now.Sub(info.ModTime()) > PendingFor { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} + +// Sessions is how many sessions are open, for the log. +func (s *Server) Sessions() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.sessions) +} + +// -------------------------------------------------------------------------- +// Files +// -------------------------------------------------------------------------- + +// writeFile writes a new file whole or not at all. +func writeFile(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + return err + } + return syncDir(dir) +} + +func syncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +func countDir(dir string) (int, error) { + entries, err := os.ReadDir(dir) + n := 0 + for _, e := range entries { + if !strings.HasPrefix(e.Name(), ".") { + n++ + } + } + return n, err +} diff --git a/internal/keys/wire.go b/internal/keys/wire.go new file mode 100644 index 0000000..aee917b --- /dev/null +++ b/internal/keys/wire.go @@ -0,0 +1,157 @@ +// Package keys is the one part of FTW Cloud that can act on a home. It runs +// as its own process, ftwkeysd, on the same host as ftwcloudd, and answers +// only over a local unix socket. +// +// It holds each agent's X25519 Noise static key, sealed at rest (AWS KMS +// envelope encryption in production, a local file key in development). It +// unseals a key only to open one Noise session with one box, and drops the +// key once the handshake has used it. It keeps the session's transport keys +// and seals and opens each frame of it, so neither an agent's static key nor +// its session keys ever sit in ftwcloudd's memory. +// +// It has no path to a box of its own. It writes and reads Noise messages, +// and ftwcloudd carries them as tun through the box's own uplink, the same +// way it carries a phone's session. The box admits the agent only while its +// signed roster lists the agent's key, and decides what each request may do. +// +// The wire between the two processes is a sequence of frames, each a 4-byte +// big-endian length and one CBOR map: a request {id, op, ...} one way and a +// response {id, err?, ...} the other. Many requests may be in flight on one +// connection. +package keys + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/fxamacker/cbor/v2" +) + +// 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 +) + +// maxFrame bounds one frame between the processes: one Noise transport +// message around the largest bulk frame, and room for the map around it. +const maxFrame = 64 << 10 + +type request struct { + ID uint64 `cbor:"id"` + Op string `cbor:"op"` + Kid string `cbor:"kid,omitempty"` + Sess string `cbor:"sess,omitempty"` + // RS is the box's Noise static key, for open. + RS []byte `cbor:"rs,omitempty"` + D []byte `cbor:"d,omitempty"` +} + +type response struct { + ID uint64 `cbor:"id"` + Err string `cbor:"err,omitempty"` + Kid string `cbor:"kid,omitempty"` + Pub []byte `cbor:"pub,omitempty"` + Sess string `cbor:"sess,omitempty"` + D []byte `cbor:"d,omitempty"` +} + +// Errors a caller can act on. Each crosses the socket as its text. +var ( + // ErrUnknownKey is a kid the keys part does not hold, or holds only + // while its approval is pending. + ErrUnknownKey = errors.New("keys: unknown key") + // ErrUnknownSession is a session the keys part does not hold: it ended, + // went idle, or ftwkeysd restarted. Open a new one. + ErrUnknownSession = errors.New("keys: unknown session") + // ErrBusy is a keys part at its limit of keys or sessions. + ErrBusy = errors.New("keys: busy") + // ErrRefused is a request the keys part will not do as asked. + ErrRefused = errors.New("keys: refused") +) + +var known = []error{ErrUnknownKey, ErrUnknownSession, ErrBusy, ErrRefused} + +func errorFrom(s string) error { + if s == "" { + return nil + } + for _, e := range known { + if e.Error() == s { + return e + } + } + return fmt.Errorf("keys: %s", s) +} + +// errorText is what crosses the socket for err: one of the known errors, or +// a fixed word. Nothing else, so no detail about a key can leave by way of an +// error message. +func errorText(err error) string { + for _, e := range known { + if errors.Is(err, e) { + return e.Error() + } + } + return "failed" +} + +var ( + encMode = mustEnc() + decMode = mustDec() +) + +func mustEnc() cbor.EncMode { + m, err := cbor.CoreDetEncOptions().EncMode() + if err != nil { + panic(err) + } + return m +} + +func mustDec() cbor.DecMode { + m, err := cbor.DecOptions{MaxArrayElements: 16, MaxMapPairs: 16, MaxNestedLevels: 4}.DecMode() + if err != nil { + panic(err) + } + return m +} + +func writeFrame(w io.Writer, v any) error { + body, err := encMode.Marshal(v) + if err != nil { + return err + } + if len(body) > maxFrame { + return fmt.Errorf("keys: a frame of %d bytes", len(body)) + } + buf := make([]byte, 4+len(body)) + binary.BigEndian.PutUint32(buf, uint32(len(body))) + copy(buf[4:], body) + _, err = w.Write(buf) + return err +} + +func readFrame(r io.Reader, v any) error { + var n [4]byte + if _, err := io.ReadFull(r, n[:]); err != nil { + return err + } + size := binary.BigEndian.Uint32(n[:]) + if size > maxFrame { + return fmt.Errorf("keys: a frame of %d bytes", size) + } + body := make([]byte, size) + if _, err := io.ReadFull(r, body); err != nil { + return err + } + return decMode.Unmarshal(body, v) +} diff --git a/scripts/check-imports.sh b/scripts/check-imports.sh index fc0315a..e2e3f5b 100755 --- a/scripts/check-imports.sh +++ b/scripts/check-imports.sh @@ -17,3 +17,13 @@ if [ -n "${reached}" ]; then fi echo "ftw-cloud: only go/protocol from srcfl/ftw ($(go list -deps ./... | grep -c '^github.com/srcfl/ftw/') packages)" + +# Only ftwkeysd talks to KMS. ftwcloudd links no AWS SDK, so a bug in the +# door cannot reach for the agent keys' data keys itself. +aws=$(go list -deps ./cmd/ftwcloudd | grep '^github.com/aws/' || true) +if [ -n "${aws}" ]; then + echo "ftwcloudd links the AWS SDK; only ftwkeysd may:" >&2 + printf '%s\n' "${aws}" >&2 + exit 1 +fi +echo "ftwcloudd: no AWS SDK" From 3952606d991b8358d6bbb920bdf823e8159c184c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:41:37 +0200 Subject: [PATCH 02/28] feat(keys): an offer key that signs consent offers, and nothing else 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 --- deploy/keys.md | 8 ++ internal/keys/keys_test.go | 61 ++++++++++-- internal/keys/offer.go | 186 +++++++++++++++++++++++++++++++++++++ internal/keys/server.go | 13 ++- internal/keys/wire.go | 1 + 5 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 internal/keys/offer.go diff --git a/deploy/keys.md b/deploy/keys.md index 2e55618..022d68f 100644 --- a/deploy/keys.md +++ b/deploy/keys.md @@ -35,6 +35,14 @@ and turns `ftwkeysd` on. - **Revoking deletes the key.** When the box's roster drops an agent, or it expires, `ftwcloudd` asks `ftwkeysd` to forget the key, which ends its sessions too. +- **The offer key.** One P-256 key signs every grant offer the consent page + hands the owner's app, so the app can tell an offer from FTW Cloud from a + copy. It is made at the first start, sealed like an agent key (its data + key's context names `offer-key`), and kept in memory while `ftwkeysd` + runs. It signs only arrays that start with `ftw.offer.v1`. The app pins + its public half: `ftwkeysd` logs it when it makes the key, and + `ftwcloudd` serves it at `/.well-known/ftw-offer-key`. Losing the volume + makes a new offer key, which the app must then pin anew. - **Nothing logs a key.** Errors on the socket are a few fixed words. - **The volume is not backed up.** Losing it means every assistant must be approved again; no home is exposed by it. diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go index 31edde4..cd8cb65 100644 --- a/internal/keys/keys_test.go +++ b/internal/keys/keys_test.go @@ -356,10 +356,17 @@ func TestKMSSealerBindsTheContext(t *testing.T) { t.Fatal(err) } _ = c.Keep(ctx, kid) - for _, blob := range kms.blobs { - if blob.ctx["purpose"] != EncryptionContextPurpose || blob.ctx["kid"] != kid { + var wrapped []byte + for b, blob := range kms.blobs { + if blob.ctx["purpose"] != EncryptionContextPurpose || (blob.ctx["kid"] != kid && blob.ctx["kid"] != offerKid) { t.Fatalf("encryption context %v", blob.ctx) } + if blob.ctx["kid"] == kid { + wrapped = []byte(b) + } + } + if wrapped == nil { + t.Fatal("no data key for the agent key") } before := kms.calls sess, msg1, err := c.Open(ctx, kid, b.static.Public()) @@ -372,10 +379,6 @@ func TestKMSSealerBindsTheContext(t *testing.T) { _, _, _ = b.answer(t, msg1) // The wrapped data key does not open for another kid. - var wrapped []byte - for blob := range kms.blobs { - wrapped = []byte(blob) - } if _, err := sealer.Unwrap(ctx, "AAAAAAAAAAAAAAAAAAAAAA", wrapped); err == nil { t.Fatal("a data key opened under another kid") } @@ -509,3 +512,49 @@ func TestManySessionsAtOnce(t *testing.T) { } wg.Wait() } + +// The offer key: made once, sealed, the same after a restart, and it signs +// only offers. A signature verifies with its public half and nothing else +// does. +func TestTheOfferKeySignsOffers(t *testing.T) { + s, sealer, logs := newTestServer(t) + c := s.Local() + pub, err := c.OfferKey(ctx) + if err != nil || len(pub) != 65 || pub[0] != 4 { + t.Fatalf("offer key: %x %v", pub, err) + } + offer := []byte{0xa1, 0x61, 0x76, 0x01} // {"v": 1} + sig, err := c.SignOffer(ctx, offer) + if err != nil { + t.Fatal(err) + } + if err := VerifyOffer(pub, offer, sig); err != nil { + t.Fatal(err) + } + if VerifyOffer(pub, []byte{0xa1, 0x61, 0x76, 0x02}, sig) == nil { + t.Fatal("a signature verified for another offer") + } + if _, err := c.SignOffer(ctx, nil); !errors.Is(err, ErrRefused) { + t.Fatalf("an empty offer: %v", err) + } + // A restart reads the same key back; it is not on disk in the clear. + s2, err := NewServer(s.dir, sealer, nil) + if err != nil { + t.Fatal(err) + } + pub2, _ := s2.Local().OfferKey(ctx) + if !bytes.Equal(pub, pub2) { + t.Fatal("the offer key changed across a restart") + } + raw, _ := os.ReadFile(filepath.Join(s.dir, "offer.key")) + d, _ := s.offer.Bytes() + if bytes.Contains(raw, d) || bytes.Contains(logs.Bytes(), d) || bytes.Contains(logs.Bytes(), []byte(hex.EncodeToString(d))) { + t.Fatal("the offer key's secret is on disk or in the log") + } + // Its data key is bound to the offer key, not to any agent kid. + var rec offerRecord + _ = decMode.Unmarshal(raw, &rec) + if _, err := sealer.Unwrap(ctx, "AAAAAAAAAAAAAAAAAAAAAA", rec.DEK); err == nil { + t.Fatal("the offer key's data key opens under an agent kid") + } +} diff --git a/internal/keys/offer.go b/internal/keys/offer.go new file mode 100644 index 0000000..3c469bc --- /dev/null +++ b/internal/keys/offer.go @@ -0,0 +1,186 @@ +package keys + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io/fs" + "math/big" + "os" + "path/filepath" +) + +// The offer key. FTW Cloud's consent page hands the owner's app a grant +// offer: the agent's public key and what its client asks for. The app shows +// it before Face ID, so it must know the offer came from FTW Cloud and not +// from a page that copied the look. The keys part signs each offer with one +// P-256 key that the app pins. It lives here, apart from the agent keys and +// sealed the same way, and it signs offers and nothing else: the signed +// bytes always start with OfferTag. + +// OfferTag starts every signed offer array. +const OfferTag = "ftw.offer.v1" + +// offerKid binds the offer key's data key in the sealer's context. It is no +// agent kid's shape, so no agent operation can name it. +const offerKid = "offer-key" + +const ( + opOfferPub = "offer.pub" // the offer key's public half, SEC1 uncompressed + opOfferSign = "offer.sign" // sign offer bytes d; answers r ‖ s +) + +// OfferBytes is what the offer key signs: the deterministic CBOR array +// [OfferTag, offer], where offer is the offer's own CBOR encoding. +func OfferBytes(offer []byte) ([]byte, error) { + return encMode.Marshal([]any{OfferTag, offer}) +} + +// VerifyOffer checks an offer's signature, r ‖ s, against the offer key's +// public half. The app does the same with the key it pins. +func VerifyOffer(pub, offer, sig []byte) error { + key, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), pub) + if err != nil || len(sig) != 64 { + return errors.New("keys: not an offer signature") + } + msg, err := OfferBytes(offer) + if err != nil { + return err + } + digest := sha256.Sum256(msg) + if !ecdsa.Verify(key, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) { + return errors.New("keys: the offer signature does not verify") + } + return nil +} + +// offerRecord is the sealed offer key on disk. +type offerRecord struct { + V int `cbor:"v"` + Pub []byte `cbor:"pub"` + DEK []byte `cbor:"dek"` + CT []byte `cbor:"ct"` +} + +// loadOfferKey unseals the offer key, making it on first start. It stays in +// memory: it signs every consent page, and vouches only for what a page +// shows. +func (s *Server) loadOfferKey() error { + path := filepath.Join(s.dir, "offer.key") + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return s.makeOfferKey(path) + } + if err != nil { + return err + } + var rec offerRecord + if err := decMode.Unmarshal(raw, &rec); err != nil { + return fmt.Errorf("keys: offer key: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) + defer cancel() + dek, err := s.sealer.Unwrap(ctx, offerKid, rec.DEK) + if err != nil { + return fmt.Errorf("keys: offer key: %w", err) + } + defer wipe(dek) + aead, err := newGCM(dek) + if err != nil { + return err + } + d, err := open(aead, rec.CT, append([]byte(OfferTag+"\x00"), rec.Pub...)) + if err != nil { + return errors.New("keys: the offer key does not open") + } + defer wipe(d) + key, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), d) + if err != nil { + return err + } + s.offer = key + return nil +} + +func (s *Server) makeOfferKey(path string) error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + pub, err := key.PublicKey.Bytes() + if err != nil { + return err + } + d, err := key.Bytes() + if err != nil { + return err + } + defer wipe(d) + ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) + defer cancel() + dek, wrapped, err := s.sealer.DataKey(ctx, offerKid) + if err != nil { + return err + } + defer wipe(dek) + aead, err := newGCM(dek) + if err != nil { + return err + } + ct, err := seal(aead, d, append([]byte(OfferTag+"\x00"), pub...)) + if err != nil { + return err + } + raw, err := encMode.Marshal(offerRecord{V: 1, Pub: pub, DEK: wrapped, CT: ct}) + if err != nil { + return err + } + if err := writeFile(path, raw); err != nil { + return err + } + s.offer = key + s.log.Info("made the offer key; pin its public half in the app", "pub", fmt.Sprintf("%x", pub)) + return nil +} + +func (s *Server) offerPub() ([]byte, error) { + if s.offer == nil { + return nil, ErrRefused + } + return s.offer.PublicKey.Bytes() +} + +func (s *Server) signOffer(offer []byte) ([]byte, error) { + if s.offer == nil || len(offer) == 0 || len(offer) > 8<<10 { + return nil, ErrRefused + } + msg, err := OfferBytes(offer) + if err != nil { + return nil, err + } + digest := sha256.Sum256(msg) + r, sv, err := ecdsa.Sign(rand.Reader, s.offer, digest[:]) + if err != nil { + return nil, err + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + sv.FillBytes(sig[32:]) + return sig, nil +} + +// OfferKey is the offer key's public half, SEC1 uncompressed. +func (c *Client) OfferKey(ctx context.Context) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opOfferPub}) + return resp.Pub, err +} + +// SignOffer signs one offer's CBOR with the offer key. +func (c *Client) SignOffer(ctx context.Context, offer []byte) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opOfferSign, D: offer}) + return resp.D, err +} diff --git a/internal/keys/server.go b/internal/keys/server.go index 1788a73..69175b9 100644 --- a/internal/keys/server.go +++ b/internal/keys/server.go @@ -3,6 +3,7 @@ package keys import ( "context" "crypto/ecdh" + "crypto/ecdsa" "crypto/rand" "encoding/base64" "errors" @@ -56,6 +57,8 @@ type Server struct { mu sync.Mutex sessions map[string]*session + // offer signs consent offers (offer.go). + offer *ecdsa.PrivateKey } type session struct { @@ -77,7 +80,11 @@ func NewServer(dir string, sealer Sealer, log *slog.Logger) (*Server, error) { return nil, err } } - return &Server{dir: dir, sealer: sealer, log: log, now: time.Now, PeerUID: -1, sessions: map[string]*session{}}, nil + s := &Server{dir: dir, sealer: sealer, log: log, now: time.Now, PeerUID: -1, sessions: map[string]*session{}} + if err := s.loadOfferKey(); err != nil { + return nil, err + } + return s, nil } // Serve accepts connections until ln closes. Each must come from PeerUID. @@ -161,6 +168,10 @@ func (s *Server) handle(req request) response { }) case opClose: s.end(req.Sess) + case opOfferPub: + resp.Pub, err = s.offerPub() + case opOfferSign: + resp.D, err = s.signOffer(req.D) default: err = ErrRefused } diff --git a/internal/keys/wire.go b/internal/keys/wire.go index aee917b..9f18101 100644 --- a/internal/keys/wire.go +++ b/internal/keys/wire.go @@ -39,6 +39,7 @@ const ( 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. ) // maxFrame bounds one frame between the processes: one Noise transport From 1c6978d879b45364fe0cd292c0d3d7407aa7c4b7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:11:31 +0200 Subject: [PATCH 03/28] feat(keys): name the offer key by a kid, and print it for pinning 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 --- cmd/ftwkeysd/main.go | 97 ++++++++++++++++++++++++++++---------- deploy/keys.md | 17 +++++-- internal/keys/keys_test.go | 7 +++ internal/keys/offer.go | 12 ++++- 4 files changed, 103 insertions(+), 30 deletions(-) diff --git a/cmd/ftwkeysd/main.go b/cmd/ftwkeysd/main.go index a266083..7c33561 100644 --- a/cmd/ftwkeysd/main.go +++ b/cmd/ftwkeysd/main.go @@ -4,11 +4,13 @@ // internal/keys and deploy/keys.md. // // ftwkeysd filekey -out dev.key make a development file key +// ftwkeysd offerkey [flags] print the offer key's kid and public half, making it if need be // ftwkeysd [flags] serve package main import ( "context" + "encoding/base64" "errors" "flag" "fmt" @@ -42,47 +44,94 @@ func main() { } return } + if len(os.Args) > 1 && os.Args[1] == "offerkey" { + if err := offerKey(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } if err := serve(os.Args[1:]); err != nil { slog.Error("ftwkeysd stopped", "err", err) os.Exit(1) } } +// sealerFlags are the flags that choose where keys are sealed. +type sealerFlags struct { + state, kmsKey, region, fileKey *string +} + +func addSealerFlags(fs *flag.FlagSet) sealerFlags { + return sealerFlags{ + state: fs.String("state", "/state/keys", "directory for the sealed keys"), + kmsKey: fs.String("kms-key", "", "AWS KMS key id, ARN or alias that wraps the data keys"), + region: fs.String("region", "eu-north-1", "AWS region of the KMS key"), + fileKey: fs.String("file-key", "", "a local file key instead of KMS (development only)"), + } +} + +func (f sealerFlags) sealer(log *slog.Logger) (keys.Sealer, error) { + switch { + case *f.kmsKey != "" && *f.fileKey != "": + return nil, errors.New("choose -kms-key or -file-key, not both") + case *f.kmsKey != "": + cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(*f.region)) + if err != nil { + return nil, fmt.Errorf("aws config: %w", err) + } + return keys.KMSSealer{KMS: kmsClient{kms.NewFromConfig(cfg)}, KeyID: *f.kmsKey}, nil + case *f.fileKey != "": + s, err := keys.NewFileSealer(*f.fileKey) + if err != nil { + return nil, err + } + log.Warn("agent keys sealed with a local file key; for development only") + return s, nil + } + return nil, errors.New("no sealer: set -kms-key, or -file-key in development") +} + +// offerKey prints the offer key's kid and SEC1 public half, base64url, for +// Core and the apps to pin. It makes the key on first use, in the same state +// directory the server uses. +func offerKey(args []string) error { + fs := flag.NewFlagSet("offerkey", flag.ExitOnError) + sf := addSealerFlags(fs) + _ = fs.Parse(args) + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + sealer, err := sf.sealer(log) + if err != nil { + return err + } + srv, err := keys.NewServer(*sf.state, sealer, log) + if err != nil { + return err + } + pub, err := srv.Local().OfferKey(context.Background()) + if err != nil { + return err + } + fmt.Printf("kid %s\npub %s\n", keys.OfferKid(pub), base64.RawURLEncoding.EncodeToString(pub)) + return nil +} + func serve(args []string) error { fs := flag.NewFlagSet("ftwkeysd", flag.ExitOnError) socket := fs.String("socket", "/run/ftw-cloud-keys/keys.sock", "unix socket to answer on") - state := fs.String("state", "/state/keys", "directory for the sealed keys") - kmsKey := fs.String("kms-key", "", "AWS KMS key id, ARN or alias that wraps the data keys") - region := fs.String("region", "eu-north-1", "AWS region of the KMS key") - fileKey := fs.String("file-key", "", "a local file key instead of KMS (development only)") + sf := addSealerFlags(fs) peerUID := fs.Int("peer-uid", -1, "the one user allowed to connect; -1 is this process's own") socketMode := fs.Uint("socket-mode", 0o660, "permissions of the socket file") _ = fs.Parse(args) harden() log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - var sealer keys.Sealer - switch { - case *kmsKey != "" && *fileKey != "": - return errors.New("choose -kms-key or -file-key, not both") - case *kmsKey != "": - cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(*region)) - if err != nil { - return fmt.Errorf("aws config: %w", err) - } - sealer = keys.KMSSealer{KMS: kmsClient{kms.NewFromConfig(cfg)}, KeyID: *kmsKey} - case *fileKey != "": - s, err := keys.NewFileSealer(*fileKey) - if err != nil { - return err - } - log.Warn("agent keys sealed with a local file key; for development only") - sealer = s - default: - return errors.New("no sealer: set -kms-key, or -file-key in development") + sealer, err := sf.sealer(log) + if err != nil { + return err } - srv, err := keys.NewServer(*state, sealer, log) + srv, err := keys.NewServer(*sf.state, sealer, log) if err != nil { return err } diff --git a/deploy/keys.md b/deploy/keys.md index 022d68f..e1fdf39 100644 --- a/deploy/keys.md +++ b/deploy/keys.md @@ -39,10 +39,12 @@ and turns `ftwkeysd` on. hands the owner's app, so the app can tell an offer from FTW Cloud from a copy. It is made at the first start, sealed like an agent key (its data key's context names `offer-key`), and kept in memory while `ftwkeysd` - runs. It signs only arrays that start with `ftw.offer.v1`. The app pins - its public half: `ftwkeysd` logs it when it makes the key, and - `ftwcloudd` serves it at `/.well-known/ftw-offer-key`. Losing the volume - makes a new offer key, which the app must then pin anew. + runs. It signs only arrays that start with `ftw.offer.v1`. Core and the + apps pin it by its kid, the first 16 characters of the base64url SHA-256 + of its public half. `ftwkeysd` logs both when it makes the key, + `ftwkeysd offerkey` prints them, and `ftwcloudd` serves them at + `/.well-known/ftw-offer-key`. Losing the volume makes a new offer key, + which Core and the apps must then pin beside the old one. - **Nothing logs a key.** Errors on the socket are a few fixed words. - **The volume is not backed up.** Losing it means every assistant must be approved again; no home is exposed by it. @@ -120,7 +122,12 @@ cannot read the apps' sessions with their boxes. `/etc/ftw-cloud/compose.env`. 5. **Deploy** as usual (`ftw-cloud-deploy sha256:`). Compose starts - `ftwkeysd` beside `ftwcloudd`. Its log says `ftwkeysd listening`. + `ftwkeysd` beside `ftwcloudd`. Its log says `ftwkeysd listening`, and on + its first start, `made the offer key` with the kid and public half. + +6. **Pin the offer key** in Core and the apps (srcfl/ftw `docs/agents.md`, + "The grant offer"): until a build pins it, no offer opens. Read it from + `https://cloud.ftw.energy/.well-known/ftw-offer-key` or the log. To turn assistants off again, remove `COMPOSE_PROFILES=agents` and deploy. Approved agents stay sealed in the volume and work again when it comes back, diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go index cd8cb65..4b11b4a 100644 --- a/internal/keys/keys_test.go +++ b/internal/keys/keys_test.go @@ -558,3 +558,10 @@ func TestTheOfferKeySignsOffers(t *testing.T) { t.Fatal("the offer key's data key opens under an agent kid") } } + +func TestOfferKidNamesTheKey(t *testing.T) { + a, b := bytes.Repeat([]byte{4}, 65), bytes.Repeat([]byte{5}, 65) + if len(OfferKid(a)) != 16 || OfferKid(a) == OfferKid(b) || OfferKid(a) != OfferKid(bytes.Clone(a)) { + t.Fatal("kid") + } +} diff --git a/internal/keys/offer.go b/internal/keys/offer.go index 3c469bc..1dce650 100644 --- a/internal/keys/offer.go +++ b/internal/keys/offer.go @@ -6,6 +6,7 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/sha256" + "encoding/base64" "errors" "fmt" "io/fs" @@ -34,6 +35,14 @@ const ( opOfferSign = "offer.sign" // sign offer bytes d; answers r ‖ s ) +// OfferKid names an offer key by its public half: the first 16 characters +// of the base64url SHA-256 of the SEC1 point. The app and the box pin keys +// by it, so a new key can be added beside the old one. +func OfferKid(pub []byte) string { + sum := sha256.Sum256(pub) + return base64.RawURLEncoding.EncodeToString(sum[:])[:16] +} + // OfferBytes is what the offer key signs: the deterministic CBOR array // [OfferTag, offer], where offer is the offer's own CBOR encoding. func OfferBytes(offer []byte) ([]byte, error) { @@ -143,7 +152,8 @@ func (s *Server) makeOfferKey(path string) error { return err } s.offer = key - s.log.Info("made the offer key; pin its public half in the app", "pub", fmt.Sprintf("%x", pub)) + s.log.Info("made the offer key; pin it in Core and the apps", "kid", OfferKid(pub), + "pub", base64.RawURLEncoding.EncodeToString(pub)) return nil } From 5a08a11e50e554755d7172a117b0b42a93a2a77f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:49:06 +0200 Subject: [PATCH 04/28] fix(keys): review of #18: sign only offers it can vouch for, the offer 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 --- Dockerfile | 9 +- cmd/ftwkeysd/dev_off.go | 13 ++ cmd/ftwkeysd/dev_on.go | 39 ++++ cmd/ftwkeysd/main.go | 210 ++++++++++++++++----- cmd/ftwkeysd/main_test.go | 61 ++++++ deploy/apns.sh | 1 + deploy/bootstrap.sh | 33 +++- deploy/compose.yml | 24 ++- deploy/keys-host-policy.json | 9 +- deploy/keys-kms-policy.json | 15 +- deploy/keys-offer-kms-policy.json | 52 ++++++ deploy/keys-role-policy.json | 20 ++ deploy/keys-role-trust.json | 11 ++ deploy/keys.md | 165 ++++++++++++----- deploy/keys.sh | 96 ++++++++-- deploy/noise.sh | 1 + go.mod | 2 +- go.sum | 4 +- internal/keys/keys_test.go | 171 ++++++++++++++++- internal/keys/offer.go | 299 ++++++++++++++++++++++-------- internal/keys/server.go | 48 +++-- 21 files changed, 1047 insertions(+), 236 deletions(-) create mode 100644 cmd/ftwkeysd/dev_off.go create mode 100644 cmd/ftwkeysd/dev_on.go create mode 100644 cmd/ftwkeysd/main_test.go create mode 100644 deploy/keys-offer-kms-policy.json create mode 100644 deploy/keys-role-policy.json create mode 100644 deploy/keys-role-trust.json diff --git a/Dockerfile b/Dockerfile index 88dafd6..e78cbd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,13 @@ COPY --from=build /out/ftwcloudd /usr/local/bin/ftwcloudd # The keys part, run as its own container and user (deploy/keys.md). COPY --from=build /out/ftwkeysd /usr/local/bin/ftwkeysd COPY --from=build /out/share/source.tar.gz /usr/share/ftw-cloud/source.tar.gz -COPY --from=build --chown=nonroot:nonroot /out/state /state +# ftwcloudd runs as a user of its own, 65530, not distroless's shared +# nonroot. The host gives its files to the user the image names +# (ftw-cloud-own in deploy/bootstrap.sh). +COPY --from=build --chown=65530:65530 /out/state /state # Owned by ftwkeysd's user, so a new volume mounted here starts out its own # and ftwcloudd cannot read it. -COPY --from=build --chown=65533:65532 /out/keys /keys -USER nonroot +COPY --from=build --chown=65533:65533 /out/keys /keys +USER 65530:65530 EXPOSE 443 ENTRYPOINT ["/usr/local/bin/ftwcloudd"] diff --git a/cmd/ftwkeysd/dev_off.go b/cmd/ftwkeysd/dev_off.go new file mode 100644 index 0000000..d8a5116 --- /dev/null +++ b/cmd/ftwkeysd/dev_off.go @@ -0,0 +1,13 @@ +//go:build !ftwclouddev + +package main + +import "flag" + +// A production build has no file key and no local offer key: both keys live +// in KMS. +const devBuild = false + +func addFileKeyFlag(*flag.FlagSet) *string { return new(string) } + +func devCommand(string, []string) bool { return false } diff --git a/cmd/ftwkeysd/dev_on.go b/cmd/ftwkeysd/dev_on.go new file mode 100644 index 0000000..52fa23d --- /dev/null +++ b/cmd/ftwkeysd/dev_on.go @@ -0,0 +1,39 @@ +//go:build ftwclouddev + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/srcfl/ftw-cloud/internal/keys" +) + +// A development build seals keys with a local file key and signs offers with +// a local key. Whoever reads the file and the state directory has every +// agent key, so a production build has neither. +const devBuild = true + +func addFileKeyFlag(fs *flag.FlagSet) *string { + return fs.String("file-key", "", "a local file key instead of KMS (development only)") +} + +// devCommand runs "ftwkeysd filekey -out dev.key". +func devCommand(name string, args []string) bool { + if name != "filekey" { + return false + } + fs := flag.NewFlagSet("filekey", flag.ExitOnError) + out := fs.String("out", "", "file to write the key to") + _ = fs.Parse(args) + if *out == "" { + fmt.Fprintln(os.Stderr, "filekey: -out is required") + os.Exit(1) + } + if err := keys.MakeFileKey(*out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return true +} diff --git a/cmd/ftwkeysd/main.go b/cmd/ftwkeysd/main.go index 7c33561..9bade86 100644 --- a/cmd/ftwkeysd/main.go +++ b/cmd/ftwkeysd/main.go @@ -3,14 +3,17 @@ // as another user, and answers only on a local unix socket. See // internal/keys and deploy/keys.md. // -// ftwkeysd filekey -out dev.key make a development file key -// ftwkeysd offerkey [flags] print the offer key's kid and public half, making it if need be +// ftwkeysd offerkey [flags] print the offer key's kid and public half // ftwkeysd [flags] serve +// +// A development build (-tags ftwclouddev) also takes -file-key and has +// "ftwkeysd filekey -out dev.key"; a production build has neither. package main import ( "context" "encoding/base64" + "encoding/json" "errors" "flag" "fmt" @@ -30,18 +33,7 @@ import ( ) func main() { - if len(os.Args) > 1 && os.Args[1] == "filekey" { - fs := flag.NewFlagSet("filekey", flag.ExitOnError) - out := fs.String("out", "", "file to write the key to") - _ = fs.Parse(os.Args[2:]) - if *out == "" { - fmt.Fprintln(os.Stderr, "filekey: -out is required") - os.Exit(1) - } - if err := keys.MakeFileKey(*out); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + if len(os.Args) > 1 && devCommand(os.Args[1], os.Args[2:]) { return } if len(os.Args) > 1 && os.Args[1] == "offerkey" { @@ -57,60 +49,129 @@ func main() { } } -// sealerFlags are the flags that choose where keys are sealed. -type sealerFlags struct { - state, kmsKey, region, fileKey *string +// keyFlags are the flags that choose where keys are sealed and what signs +// offers. +type keyFlags struct { + state, kmsKey, offerKMSKey, region, awsCreds, fileKey *string } -func addSealerFlags(fs *flag.FlagSet) sealerFlags { - return sealerFlags{ - state: fs.String("state", "/state/keys", "directory for the sealed keys"), - kmsKey: fs.String("kms-key", "", "AWS KMS key id, ARN or alias that wraps the data keys"), - region: fs.String("region", "eu-north-1", "AWS region of the KMS key"), - fileKey: fs.String("file-key", "", "a local file key instead of KMS (development only)"), +func addKeyFlags(fs *flag.FlagSet) keyFlags { + return keyFlags{ + state: fs.String("state", "/state/keys", "directory for the sealed keys"), + kmsKey: fs.String("kms-key", "", "AWS KMS key id, ARN or alias that wraps the data keys"), + offerKMSKey: fs.String("offer-kms-key", "", "AWS KMS key id, ARN or alias of the offer key (ECC_NIST_P256, SIGN_VERIFY)"), + region: fs.String("region", "eu-north-1", "AWS region of the KMS keys"), + awsCreds: fs.String("aws-credentials", "", + "a JSON file of the keys role's short-lived credentials, as aws sts assume-role answers them; the host refreshes it"), + fileKey: addFileKeyFlag(fs), } } -func (f sealerFlags) sealer(log *slog.Logger) (keys.Sealer, error) { +// aws is the SDK's configuration, made once. +func (f keyFlags) aws(ctx context.Context) (aws.Config, error) { + opts := []func(*config.LoadOptions) error{config.WithRegion(*f.region)} + if *f.awsCreds != "" { + opts = append(opts, config.WithCredentialsProvider(aws.NewCredentialsCache(fileCredentials(*f.awsCreds), + func(o *aws.CredentialsCacheOptions) { o.ExpiryWindow = 5 * time.Minute }))) + } + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return aws.Config{}, fmt.Errorf("aws config: %w", err) + } + return cfg, nil +} + +// options builds the sealer and the offer key. A production build needs both +// in KMS. +func (f keyFlags) options(ctx context.Context, log *slog.Logger) (keys.Sealer, []keys.Option, error) { + var cfg *aws.Config + awsCfg := func() (aws.Config, error) { + if cfg == nil { + c, err := f.aws(ctx) + if err != nil { + return aws.Config{}, err + } + cfg = &c + } + return *cfg, nil + } + + var sealer keys.Sealer switch { case *f.kmsKey != "" && *f.fileKey != "": - return nil, errors.New("choose -kms-key or -file-key, not both") + return nil, nil, errors.New("choose -kms-key or -file-key, not both") case *f.kmsKey != "": - cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(*f.region)) + c, err := awsCfg() if err != nil { - return nil, fmt.Errorf("aws config: %w", err) + return nil, nil, err } - return keys.KMSSealer{KMS: kmsClient{kms.NewFromConfig(cfg)}, KeyID: *f.kmsKey}, nil + sealer = keys.KMSSealer{KMS: kmsClient{kms.NewFromConfig(c)}, KeyID: *f.kmsKey} case *f.fileKey != "": s, err := keys.NewFileSealer(*f.fileKey) if err != nil { - return nil, err + return nil, nil, err } log.Warn("agent keys sealed with a local file key; for development only") - return s, nil + sealer = s + default: + return nil, nil, errors.New("no sealer: set -kms-key") } - return nil, errors.New("no sealer: set -kms-key, or -file-key in development") + + var opts []keys.Option + switch { + case *f.offerKMSKey != "": + c, err := awsCfg() + if err != nil { + return nil, nil, err + } + tctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + k, err := keys.NewKMSOfferKey(tctx, kmsClient{kms.NewFromConfig(c)}, *f.offerKMSKey) + if err != nil { + return nil, nil, err + } + opts = append(opts, keys.WithOfferKey(k)) + case devBuild: + log.Warn("offers signed by a local key; for development only") + default: + return nil, nil, errors.New("no offer key: set -offer-kms-key") + } + return sealer, opts, nil } // offerKey prints the offer key's kid and SEC1 public half, base64url, for -// Core and the apps to pin. It makes the key on first use, in the same state -// directory the server uses. +// Core and the apps to pin. Run it on the host (deploy/keys.md), so that what +// is pinned comes from KMS and not from a public endpoint. A development +// build prints its local key instead. func offerKey(args []string) error { fs := flag.NewFlagSet("offerkey", flag.ExitOnError) - sf := addSealerFlags(fs) + kf := addKeyFlags(fs) _ = fs.Parse(args) - log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - sealer, err := sf.sealer(log) - if err != nil { - return err - } - srv, err := keys.NewServer(*sf.state, sealer, log) - if err != nil { - return err - } - pub, err := srv.Local().OfferKey(context.Background()) - if err != nil { - return err + ctx := context.Background() + var pub []byte + if *kf.offerKMSKey != "" { + cfg, err := kf.aws(ctx) + if err != nil { + return err + } + k, err := keys.NewKMSOfferKey(ctx, kmsClient{kms.NewFromConfig(cfg)}, *kf.offerKMSKey) + if err != nil { + return err + } + pub = k.Public() + } else { + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + sealer, opts, err := kf.options(ctx, log) + if err != nil { + return err + } + srv, err := keys.NewServer(*kf.state, sealer, log, opts...) + if err != nil { + return err + } + if pub, err = srv.Local().OfferKey(ctx); err != nil { + return err + } } fmt.Printf("kid %s\npub %s\n", keys.OfferKid(pub), base64.RawURLEncoding.EncodeToString(pub)) return nil @@ -119,19 +180,19 @@ func offerKey(args []string) error { func serve(args []string) error { fs := flag.NewFlagSet("ftwkeysd", flag.ExitOnError) socket := fs.String("socket", "/run/ftw-cloud-keys/keys.sock", "unix socket to answer on") - sf := addSealerFlags(fs) + kf := addKeyFlags(fs) peerUID := fs.Int("peer-uid", -1, "the one user allowed to connect; -1 is this process's own") socketMode := fs.Uint("socket-mode", 0o660, "permissions of the socket file") _ = fs.Parse(args) harden() log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - sealer, err := sf.sealer(log) + sealer, opts, err := kf.options(context.Background(), log) if err != nil { return err } - srv, err := keys.NewServer(*sf.state, sealer, log) + srv, err := keys.NewServer(*kf.state, sealer, log, opts...) if err != nil { return err } @@ -171,11 +232,36 @@ func serve(args []string) error { } } }() - log.Info("ftwkeysd listening", "socket", *socket) + pub, _ := srv.Local().OfferKey(ctx) + log.Info("ftwkeysd listening", "socket", *socket, "offer_kid", keys.OfferKid(pub)) return srv.Serve(ln) } -// kmsClient adapts the AWS SDK to keys.KMS. +// fileCredentials reads the keys role's credentials from the file the host +// refreshes, each time the SDK's cache runs out. +type fileCredentials string + +func (p fileCredentials) Retrieve(context.Context) (aws.Credentials, error) { + raw, err := os.ReadFile(string(p)) + if err != nil { + return aws.Credentials{}, fmt.Errorf("aws credentials: %w", err) + } + var c struct { + AccessKeyID string `json:"AccessKeyId"` + SecretAccessKey string `json:"SecretAccessKey"` + SessionToken string `json:"SessionToken"` + Expiration time.Time `json:"Expiration"` + } + if err := json.Unmarshal(raw, &c); err != nil || c.AccessKeyID == "" || c.SecretAccessKey == "" || c.Expiration.IsZero() { + return aws.Credentials{}, errors.New("aws credentials: the file is not an assume-role answer") + } + return aws.Credentials{ + AccessKeyID: c.AccessKeyID, SecretAccessKey: c.SecretAccessKey, SessionToken: c.SessionToken, + Source: "ftwkeysd-file", CanExpire: true, Expires: c.Expiration, + }, nil +} + +// kmsClient adapts the AWS SDK to keys.KMS and keys.KMSSigner. type kmsClient struct{ c *kms.Client } func (k kmsClient) GenerateDataKey(ctx context.Context, keyID string, ec map[string]string) ([]byte, []byte, error) { @@ -197,3 +283,25 @@ func (k kmsClient) Decrypt(ctx context.Context, keyID string, blob []byte, ec ma } return out.Plaintext, nil } + +func (k kmsClient) PublicKey(ctx context.Context, keyID string) ([]byte, error) { + out, err := k.c.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: aws.String(keyID)}) + if err != nil { + return nil, err + } + if out.KeySpec != types.KeySpecEccNistP256 || out.KeyUsage != types.KeyUsageTypeSignVerify { + return nil, fmt.Errorf("the offer key is %s for %s, want ECC_NIST_P256 for SIGN_VERIFY", out.KeySpec, out.KeyUsage) + } + return out.PublicKey, nil +} + +func (k kmsClient) Sign(ctx context.Context, keyID string, digest []byte) ([]byte, error) { + out, err := k.c.Sign(ctx, &kms.SignInput{ + KeyId: aws.String(keyID), Message: digest, + MessageType: types.MessageTypeDigest, SigningAlgorithm: types.SigningAlgorithmSpecEcdsaSha256, + }) + if err != nil { + return nil, err + } + return out.Signature, nil +} diff --git a/cmd/ftwkeysd/main_test.go b/cmd/ftwkeysd/main_test.go new file mode 100644 index 0000000..9150de2 --- /dev/null +++ b/cmd/ftwkeysd/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "flag" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A production build has no file key and no local offer key: both keys must +// be in KMS. +func TestAProductionBuildNeedsBothKeysInKMS(t *testing.T) { + if devBuild { + t.Skip("a development build") + } + parse := func(args ...string) (keyFlags, error) { + fs := flag.NewFlagSet("ftwkeysd", flag.ContinueOnError) + fs.SetOutput(io.Discard) + kf := addKeyFlags(fs) + return kf, fs.Parse(args) + } + if _, err := parse("-file-key=dev.key"); err == nil { + t.Fatal("a production build takes -file-key") + } + if devCommand("filekey", []string{"-out", filepath.Join(t.TempDir(), "k")}) { + t.Fatal("a production build makes file keys") + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + kf, _ := parse() + if _, _, err := kf.options(context.Background(), log); err == nil || !strings.Contains(err.Error(), "-kms-key") { + t.Fatalf("no sealer: %v", err) + } + kf, _ = parse("-kms-key=alias/ftw-cloud-agent-keys") + if _, _, err := kf.options(context.Background(), log); err == nil || !strings.Contains(err.Error(), "-offer-kms-key") { + t.Fatalf("no offer key: %v", err) + } +} + +// The keys role's credentials come from the file the host renews, and +// expire when it says. +func TestCredentialsComeFromTheHostsFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + exp := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + body := `{"AccessKeyId":"ASIAEXAMPLE","SecretAccessKey":"s","SessionToken":"t","Expiration":"` + exp.Format("2006-01-02T15:04:05+00:00") + `"}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + c, err := fileCredentials(path).Retrieve(context.Background()) + if err != nil || c.AccessKeyID != "ASIAEXAMPLE" || !c.CanExpire || !c.Expires.Equal(exp) { + t.Fatalf("%+v %v", c, err) + } + _ = os.WriteFile(path, []byte(`{"AccessKeyId":"x"}`), 0o600) + if _, err := fileCredentials(path).Retrieve(context.Background()); err == nil { + t.Fatal("a file without an expiry was taken") + } +} diff --git a/deploy/apns.sh b/deploy/apns.sh index ea8b554..c9a98c0 100755 --- a/deploy/apns.sh +++ b/deploy/apns.sh @@ -43,6 +43,7 @@ if ! get /ftw-cloud/apns/key key.p8 || ! get /ftw-cloud/apns/key-id key-id || ! exit 0 fi grep -q '^-----BEGIN PRIVATE KEY-----$' "$new/key.p8" +# ftw-cloud-own gives it to the user the image runs as, before compose starts. chown -R 65532:65532 "$new" chmod 0400 "$new"/* chmod 0500 "$new" diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 70a6e86..a8e7506 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -67,6 +67,7 @@ for try in $(seq 30); do sleep 2 done grep -q '^-----BEGIN EC PRIVATE KEY-----$' "$tmp" +# ftw-cloud-own gives it to the user the image runs as, before compose starts. chown 65532:65532 "$tmp" chmod 0400 "$tmp" mv -f "$tmp" /run/ftw-cloud/service.key @@ -101,6 +102,7 @@ Wants=network-online.target Type=oneshot RemainAfterExit=yes WorkingDirectory=/opt/ftw-cloud/deploy +ExecStartPre=/usr/local/sbin/ftw-cloud-own ExecStart=/usr/bin/docker compose --env-file /etc/ftw-cloud/compose.env up -d ExecStop=/usr/bin/docker compose --env-file /etc/ftw-cloud/compose.env down @@ -108,6 +110,32 @@ ExecStop=/usr/bin/docker compose --env-file /etc/ftw-cloud/compose.env down WantedBy=multi-user.target UNIT +# ftwcloudd's files belong to the user its image runs as: the state volume +# and the secrets in tmpfs. Images before 65530 ran as distroless's nonroot, +# 65532, so a deploy either way, or back, finds its files its own. +cat > /usr/local/sbin/ftw-cloud-own <<'OWN' +#!/bin/bash +# Give ftwcloudd's files to the user the configured image runs as. +set -euo pipefail +image=$(sed -n 's/^FTW_CLOUD_IMAGE=//p' /etc/ftw-cloud/compose.env) +[ -n "$image" ] || exit 0 +user=$(docker image inspect -f '{{.Config.User}}' "$image") +case "$user" in + nonroot|nonroot:nonroot) user=65532:65532 ;; +esac +if ! [[ $user =~ ^[0-9]+:[0-9]+$ ]]; then + echo "$image runs as '$user', not uid:gid" >&2 + exit 1 +fi +vol=$(docker volume inspect -f '{{.Mountpoint}}' ftw-cloud_ftw-cloud-state 2>/dev/null || true) +for p in "$vol" /run/ftw-cloud/service.key /run/ftw-cloud/apns /run/ftw-cloud/noise; do + if [ -n "$p" ] && [ -e "$p" ]; then + chown -R "$user" "$p" + fi +done +OWN +chmod 0755 /usr/local/sbin/ftw-cloud-own + # One command updates the host to an image CI built, named by its digest. cat > /usr/local/sbin/ftw-cloud-deploy <<'DEPLOY' #!/bin/bash @@ -142,7 +170,10 @@ if ! [[ $rev =~ ^[0-9a-f]{40}$ ]] || ! git merge-base --is-ancestor "$rev" origi fi git checkout --quiet --detach "$rev" -printf 'FTW_CLOUD_IMAGE=%s\n' "$image" > "$env" +# Only the image line changes; what keys.sh set stays. +touch "$env" +sed -i '/^FTW_CLOUD_IMAGE=/d' "$env" +printf 'FTW_CLOUD_IMAGE=%s\n' "$image" >> "$env" # One transaction, in order: the key comes fresh from Secrets Manager, so a # new version takes effect, then ftw-cloud.service takes the container down # and starts a new one. Always a new container: a file mounted on its own, diff --git a/deploy/compose.yml b/deploy/compose.yml index 2d06f07..96c90e2 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -56,7 +56,7 @@ services: # The keys part: the only process that can act on a home, as an agent its # owner approved (deploy/keys.md). It starts with COMPOSE_PROFILES=agents - # in compose.env, once the KMS key exists. + # in compose.env, once the KMS keys and the keys role exist. ftwkeysd: image: ${FTW_CLOUD_IMAGE:?set FTW_CLOUD_IMAGE to an image digest} profiles: [agents] @@ -66,21 +66,26 @@ services: - -socket=/run/ftw-cloud-keys/keys.sock - -state=/keys - -kms-key=${FTW_CLOUD_KMS_KEY:-} + - -offer-kms-key=${FTW_CLOUD_OFFER_KEY:-} - -region=eu-north-1 + - -aws-credentials=/run/secrets/aws/credentials.json # ftwcloudd's user, the only one allowed on the socket. - - -peer-uid=65532 + - -peer-uid=65530 # Its own user, in ftwcloudd's group so that ftwcloudd may connect to # the socket and nothing more. - user: '65533:65532' - # The host's network, because the instance's credentials answer one hop - # away and the bridged containers sit two hops off (deploy/keys.md): - # this is the one container that can reach KMS. It listens on nothing - # but the unix socket. - network_mode: host + user: '65533:65530' + # A network of its own, apart from ftwcloudd's. Like every container it + # cannot reach the instance's credentials (hop limit 1); it reaches KMS + # with the keys role's short-lived credentials, which may use the two + # KMS keys and nothing else. It listens on nothing but the unix socket. + networks: [keys] volumes: # Agent keys, sealed under KMS. ftwcloudd never mounts this. - ftw-cloud-keys:/keys - /run/ftw-cloud-keys:/run/ftw-cloud-keys + # The keys role's credentials, renewed by the host every 15 minutes + # (deploy/keys.sh). + - /run/ftw-cloud-keys-aws:/run/secrets/aws:ro read_only: true ulimits: core: 0 @@ -99,6 +104,9 @@ secrets: ftw-cloud-service.key: file: /run/ftw-cloud/service.key +networks: + keys: + volumes: ftw-cloud-state: ftw-cloud-keys: diff --git a/deploy/keys-host-policy.json b/deploy/keys-host-policy.json index cd7d468..c8ca926 100644 --- a/deploy/keys-host-policy.json +++ b/deploy/keys-host-policy.json @@ -2,13 +2,10 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "SealAndOpenAgentKeys", + "Sid": "FetchCredentialsForFtwkeysd", "Effect": "Allow", - "Action": ["kms:GenerateDataKey", "kms:Decrypt"], - "Resource": "arn:aws:kms:eu-north-1:976075258475:key/KEY-ID", - "Condition": { - "StringEquals": { "kms:EncryptionContext:purpose": "ftw-agent-key" } - } + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::976075258475:role/ftw-cloud-keys" } ] } diff --git a/deploy/keys-kms-policy.json b/deploy/keys-kms-policy.json index bcc2acf..efddf16 100644 --- a/deploy/keys-kms-policy.json +++ b/deploy/keys-kms-policy.json @@ -3,11 +3,11 @@ "Id": "ftw-cloud-agent-keys", "Statement": [ { - "Sid": "AdministerButNeverUse", + "Sid": "AdministerButNotUse", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::976075258475:root" }, "Action": [ - "kms:Create*", + "kms:CreateAlias", "kms:Describe*", "kms:Enable*", "kms:List*", @@ -25,15 +25,22 @@ "Resource": "*" }, { - "Sid": "TheHostSealsAndOpensAgentKeys", + "Sid": "OnlyTheKeysRoleSealsAndOpensAgentKeys", "Effect": "Allow", - "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-host" }, + "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-keys" }, "Action": ["kms:GenerateDataKey", "kms:Decrypt"], "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:purpose": "ftw-agent-key" }, "ForAllValues:StringEquals": { "kms:EncryptionContextKeys": ["purpose", "kid"] } } + }, + { + "Sid": "NoGrants", + "Effect": "Deny", + "Principal": { "AWS": "*" }, + "Action": "kms:CreateGrant", + "Resource": "*" } ] } diff --git a/deploy/keys-offer-kms-policy.json b/deploy/keys-offer-kms-policy.json new file mode 100644 index 0000000..2cfa293 --- /dev/null +++ b/deploy/keys-offer-kms-policy.json @@ -0,0 +1,52 @@ +{ + "Version": "2012-10-17", + "Id": "ftw-cloud-offer-key", + "Statement": [ + { + "Sid": "AdministerButNotSign", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:root" }, + "Action": [ + "kms:CreateAlias", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Resource": "*" + }, + { + "Sid": "OnlyTheKeysRoleSignsOffers", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-keys" }, + "Action": "kms:Sign", + "Resource": "*", + "Condition": { + "StringEquals": { "kms:SigningAlgorithm": "ECDSA_SHA_256", "kms:MessageType": "DIGEST" } + } + }, + { + "Sid": "TheKeysRoleReadsThePublicHalf", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-keys" }, + "Action": "kms:GetPublicKey", + "Resource": "*" + }, + { + "Sid": "NoGrants", + "Effect": "Deny", + "Principal": { "AWS": "*" }, + "Action": "kms:CreateGrant", + "Resource": "*" + } + ] +} diff --git a/deploy/keys-role-policy.json b/deploy/keys-role-policy.json new file mode 100644 index 0000000..6d506c1 --- /dev/null +++ b/deploy/keys-role-policy.json @@ -0,0 +1,20 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SealAndOpenAgentKeys", + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt"], + "Resource": "arn:aws:kms:eu-north-1:976075258475:key/AGENT-KEY-ID", + "Condition": { + "StringEquals": { "kms:EncryptionContext:purpose": "ftw-agent-key" } + } + }, + { + "Sid": "SignOffers", + "Effect": "Allow", + "Action": ["kms:Sign", "kms:GetPublicKey"], + "Resource": "arn:aws:kms:eu-north-1:976075258475:key/OFFER-KEY-ID" + } + ] +} diff --git a/deploy/keys-role-trust.json b/deploy/keys-role-trust.json new file mode 100644 index 0000000..ead3c7b --- /dev/null +++ b/deploy/keys-role-trust.json @@ -0,0 +1,11 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "TheHostFetchesCredentialsForFtwkeysd", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::976075258475:role/ftw-cloud-host" }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/deploy/keys.md b/deploy/keys.md index e1fdf39..4c1075d 100644 --- a/deploy/keys.md +++ b/deploy/keys.md @@ -13,9 +13,9 @@ and turns `ftwkeysd` on. | | `ftwcloudd` | `ftwkeysd` | |---|---|---| -| User | 65532 (`nonroot`) | 65533, group 65532 | -| Network | bridge, 443 published | the host's, listens on nothing | -| Reaches | boxes, phones, assistants | KMS, through the instance role | +| User | 65530, its own (not distroless's shared `nonroot`) | 65533, group 65530 | +| Network | the default bridge, 443 published | a bridge of its own, listens on nothing | +| Reaches | boxes, phones, assistants | KMS, as the `ftw-cloud-keys` role | | State | `ftw-cloud-state` (registry, OAuth token hashes) | `ftw-cloud-keys` (sealed agent keys) | | Socket | connects to `/run/ftw-cloud-keys/keys.sock` | owns it, mode 0660, checks the peer's uid | @@ -34,34 +34,44 @@ and turns `ftwkeysd` on. deleted after an hour. - **Revoking deletes the key.** When the box's roster drops an agent, or it expires, `ftwcloudd` asks `ftwkeysd` to forget the key, which ends its - sessions too. -- **The offer key.** One P-256 key signs every grant offer the consent page - hands the owner's app, so the app can tell an offer from FTW Cloud from a - copy. It is made at the first start, sealed like an agent key (its data - key's context names `offer-key`), and kept in memory while `ftwkeysd` - runs. It signs only arrays that start with `ftw.offer.v1`. Core and the - apps pin it by its kid, the first 16 characters of the base64url SHA-256 - of its public half. `ftwkeysd` logs both when it makes the key, - `ftwkeysd offerkey` prints them, and `ftwcloudd` serves them at - `/.well-known/ftw-offer-key`. Losing the volume makes a new offer key, - which Core and the apps must then pin beside the old one. + sessions too. `ftwcloudd` keeps asking, across restarts, until `ftwkeysd` + has done it. +- **A second KMS key signs offers.** The consent page hands the owner's app a + grant offer, and the app must tell an offer from FTW Cloud from a copy. An + asymmetric KMS key (`ECC_NIST_P256`, `SIGN_VERIFY`) signs every offer with + `kms:Sign`; its private half never leaves KMS. `ftwkeysd` signs only an + offer it can vouch for: a well-formed offer for the agent role, good for at + most ten minutes, whose key is one that `ftwkeysd` made and that waits for + approval. So `ftwcloudd` cannot get an owner to approve a key `ftwcloudd` + holds. Core and the apps pin the offer key by its kid, the first 16 + characters of the base64url SHA-256 of its public half. - **Nothing logs a key.** Errors on the socket are a few fixed words. - **The volume is not backed up.** Losing it means every assistant must be approved again; no home is exposed by it. -### Only `ftwkeysd` reaches KMS +A production build of `ftwkeysd` has no file key and no local offer key. A +development build (`-tags ftwclouddev`) has both; see Development below. -The instance role can call KMS, so the separation rests on who can reach the -role's credentials. The instance metadata service answers IMDSv2 token -requests one network hop away when the hop limit is 1. A container on Docker's -bridge network is two hops away and gets no credentials; `ftwkeysd` uses the -host's network and gets them. `ftwcloudd` needs no AWS credentials: the host -fetches its secrets before Docker starts. +### Only `ftwkeysd` reaches KMS, and it reaches nothing else -The KMS key's policy lets only the host role use it, and only with the -`ftw-agent-key` purpose. The account root can administer the key but not use -it, so another role in the shared account cannot be granted decrypt by IAM -alone. +The instance role reads the service key and the parameters at boot, so no +container may hold its credentials. The instance metadata service answers +IMDSv2 token requests one network hop away when the hop limit is 1; every +container sits on a Docker bridge, two hops away, and gets nothing. + +`ftwkeysd` uses a role of its own, `ftw-cloud-keys`, which may use the two KMS +keys and nothing else. The host assumes it every 15 minutes and leaves the +hour-long credentials in tmpfs, readable only by `ftwkeysd`'s user, which +mounts them read-only. + +Each KMS key's policy lets only `ftw-cloud-keys` use it: the agent key only +with the `ftw-agent-key` purpose, the offer key only to sign SHA-256 digests +and read its public half. An explicit deny on `kms:CreateGrant` means no grant +opens either key beside its policy. The account root may administer the keys +but not use them, so another role in the shared account gains nothing from +IAM alone. This guards against mistakes; it is not a wall: whoever may put a +key's policy (`kms:PutKeyPolicy`) can write themselves in, and that change +shows in CloudTrail. If the cloud is fully broken into, the attacker can act as the existing agents, within the limits their boxes enforce, until the owners revoke them @@ -70,30 +80,60 @@ cannot read the apps' sessions with their boxes. ## Turning it on -1. **The KMS key** (once, from a workstation with admin rights): +0. **The host's tooling.** This image runs `ftwcloudd` as 65530, and + `ftw-cloud-deploy` now keeps the lines `keys.sh` adds to `compose.env`. + Re-run [`bootstrap.sh`](bootstrap.sh) through SSM before deploying it; it + installs `ftw-cloud-own`, which gives `ftwcloudd`'s state and secrets to + the user its image runs as at every start, so deploying this image, or + going back to an older one, finds its files its own. + +1. **The keys role** (once, from a workstation with admin rights): ```bash - key=$(aws kms create-key --region eu-north-1 \ + aws iam create-role --role-name ftw-cloud-keys \ + --assume-role-policy-document file://deploy/keys-role-trust.json \ + --max-session-duration 3600 --tags Key=Project,Value=ftw-cloud + aws iam put-role-policy --role-name ftw-cloud-host --policy-name ftw-cloud-keys-assume \ + --policy-document file://deploy/keys-host-policy.json + ``` + +2. **The two KMS keys:** + + ```bash + agent=$(aws kms create-key --region eu-north-1 \ --description 'FTW Cloud agent keys (ftwkeysd)' \ --key-spec SYMMETRIC_DEFAULT --key-usage ENCRYPT_DECRYPT \ --policy file://deploy/keys-kms-policy.json \ --tags TagKey=Project,TagValue=ftw-cloud \ --query KeyMetadata.Arn --output text) aws kms create-alias --region eu-north-1 --alias-name alias/ftw-cloud-agent-keys \ - --target-key-id "$key" - aws kms enable-key-rotation --region eu-north-1 --key-id "$key" - echo "$key" + --target-key-id "$agent" + aws kms enable-key-rotation --region eu-north-1 --key-id "$agent" + + offer=$(aws kms create-key --region eu-north-1 \ + --description 'FTW Cloud grant offers (ftwkeysd)' \ + --key-spec ECC_NIST_P256 --key-usage SIGN_VERIFY \ + --policy file://deploy/keys-offer-kms-policy.json \ + --tags TagKey=Project,TagValue=ftw-cloud \ + --query KeyMetadata.Arn --output text) + aws kms create-alias --region eu-north-1 --alias-name alias/ftw-cloud-offer-key \ + --target-key-id "$offer" + echo "$agent $offer" ``` -2. **The role's policy.** Put the key's id into - [`keys-host-policy.json`](keys-host-policy.json) in place of `KEY-ID`, then: + An asymmetric key does not rotate. A new offer key is a new key, pinned + beside the old one until every build has it. + +3. **The role's policy.** Put the two keys' ids into + [`keys-role-policy.json`](keys-role-policy.json) in place of `AGENT-KEY-ID` + and `OFFER-KEY-ID`, then: ```bash - aws iam put-role-policy --role-name ftw-cloud-host --policy-name ftw-cloud-agent-keys \ - --policy-document file://deploy/keys-host-policy.json + aws iam put-role-policy --role-name ftw-cloud-keys --policy-name ftw-cloud-keys \ + --policy-document file://deploy/keys-role-policy.json ``` -3. **One hop to the metadata service**, so no bridged container gets the +4. **One hop to the metadata service**, so no container gets the instance role's credentials: ```bash @@ -107,27 +147,49 @@ cannot read the apps' sessions with their boxes. http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60'` must time out or fail. -4. **The host.** Through SSM, with the key's ARN: +5. **The host.** Through SSM, with both keys' ARNs: ```bash aws ssm send-command --region eu-north-1 --instance-ids i-0ecac4cbc6b5f8adc \ --document-name AWS-RunShellScript \ - --parameters "$(jq -n --rawfile s deploy/keys.sh --arg k "$key" \ - '{commands: ["set -- \($k)", $s]}')" + --parameters "$(jq -n --rawfile s deploy/keys.sh --arg a "$agent" --arg o "$offer" \ + '{commands: ["set -- \($a) \($o)", $s]}')" ``` SSM runs the lines as one script, so `set --` hands the script its - argument. It makes `/run/ftw-cloud-keys` at every boot and sets - `FTW_CLOUD_KMS_KEY` and `COMPOSE_PROFILES=agents` in + arguments. It makes `/run/ftw-cloud-keys` at every boot, installs the + timer that renews the keys role's credentials in + `/run/ftw-cloud-keys-aws`, and sets `FTW_CLOUD_KMS_KEY`, + `FTW_CLOUD_OFFER_KEY` and `COMPOSE_PROFILES=agents` in `/etc/ftw-cloud/compose.env`. -5. **Deploy** as usual (`ftw-cloud-deploy sha256:`). Compose starts - `ftwkeysd` beside `ftwcloudd`. Its log says `ftwkeysd listening`, and on - its first start, `made the offer key` with the kid and public half. +6. **Deploy** as usual (`ftw-cloud-deploy sha256:`). Compose starts + `ftwkeysd` beside `ftwcloudd`. Its log says `ftwkeysd listening` with the + offer key's kid. + +7. **Pin the offer key** in Core and the apps (srcfl/ftw `docs/agents.md`, + "The grant offer"): until a build pins it, no offer opens. Take the kid and + public half from `ftwkeysd` on the host, through SSM, not from the public + `/.well-known/ftw-offer-key`: + + ```bash + id=$(aws ssm send-command --region eu-north-1 --instance-ids i-0ecac4cbc6b5f8adc \ + --document-name AWS-RunShellScript \ + --parameters 'commands=["cd /opt/ftw-cloud/deploy && docker compose --env-file /etc/ftw-cloud/compose.env exec -T ftwkeysd /usr/local/bin/ftwkeysd offerkey -offer-kms-key=alias/ftw-cloud-offer-key -aws-credentials=/run/secrets/aws/credentials.json"]' \ + --query Command.CommandId --output text) + aws ssm get-command-invocation --region eu-north-1 --instance-id i-0ecac4cbc6b5f8adc \ + --command-id "$id" --query StandardOutputContent --output text + ``` + + It prints `kid …` and `pub …`. KMS gives the same public half to an + admin, as a cross-check that needs no host: -6. **Pin the offer key** in Core and the apps (srcfl/ftw `docs/agents.md`, - "The grant offer"): until a build pins it, no offer opens. Read it from - `https://cloud.ftw.energy/.well-known/ftw-offer-key` or the log. + ```bash + aws kms get-public-key --region eu-north-1 --key-id alias/ftw-cloud-offer-key \ + --query PublicKey --output text | python3 -c 'import base64,hashlib,sys + p=base64.b64decode(sys.stdin.read())[-65:]; b=lambda x: base64.urlsafe_b64encode(x).rstrip(b"=").decode() + print("kid", b(hashlib.sha256(p).digest())[:16]); print("pub", b(p))' + ``` To turn assistants off again, remove `COMPOSE_PROFILES=agents` and deploy. Approved agents stay sealed in the volume and work again when it comes back, @@ -136,9 +198,12 @@ unless their owners revoked them meanwhile. ## Development ```bash -go run ./cmd/ftwkeysd filekey -out dev-keys.key -go run ./cmd/ftwkeysd -socket /tmp/ftwkeys/keys.sock -state /tmp/ftwkeys/state -file-key dev-keys.key +go run -tags ftwclouddev ./cmd/ftwkeysd filekey -out dev-keys.key +go run -tags ftwclouddev ./cmd/ftwkeysd -socket /tmp/ftwkeys/keys.sock \ + -state /tmp/ftwkeys/state -file-key dev-keys.key ``` -A file key is for development only: whoever reads it and the state directory -has every agent key. +A development build seals keys with the file key and signs offers with a +local key in the state directory. Whoever reads the file and the state +directory has every agent key, so a production build refuses both: it needs +`-kms-key` and `-offer-kms-key`. diff --git a/deploy/keys.sh b/deploy/keys.sh index ad8a8d1..f6d6677 100755 --- a/deploy/keys.sh +++ b/deploy/keys.sh @@ -1,27 +1,97 @@ #!/bin/bash # Prepare the FTW Cloud host for ftwkeysd, the keys part. Run as root on the -# host through SSM, after the KMS key exists (deploy/keys.md). Safe to re-run. +# host through SSM, after the two KMS keys and the keys role exist +# (deploy/keys.md). Safe to re-run. # -# It makes the socket's directory at every boot, owned by ftwkeysd's user -# and readable by ftwcloudd's group, and turns ftwkeysd on in compose.env. -# It touches no AWS resource. +# It makes the socket's directory at every boot, keeps short-lived +# credentials of the keys role in tmpfs for ftwkeysd, and turns ftwkeysd on +# in compose.env. It touches no AWS resource. set -euo pipefail -key_arn="${1:?usage: keys.sh }" -case "$key_arn" in - arn:aws:kms:eu-north-1:*:key/*) ;; - *) echo "not a KMS key ARN in eu-north-1: $key_arn" >&2; exit 1 ;; -esac +agent_key="${1:?usage: keys.sh }" +offer_key="${2:?usage: keys.sh }" +for arn in "$agent_key" "$offer_key"; do + case "$arn" in + arn:aws:kms:eu-north-1:*:key/*) ;; + *) echo "not a KMS key ARN in eu-north-1: $arn" >&2; exit 1 ;; + esac +done -# ftwkeysd runs as 65533 in ftwcloudd's group, 65532. The directory is the +# ftwkeysd runs as 65533 in ftwcloudd's group, 65530. The directory is the # only way to the socket: ftwcloudd may enter it and connect, and nothing # else on the host but root may. cat > /etc/tmpfiles.d/ftw-cloud-keys.conf <<'TMPFILES' -d /run/ftw-cloud-keys 0750 65533 65532 - +d /run/ftw-cloud-keys 0750 65533 65530 - +d /run/ftw-cloud-keys-aws 0700 65533 65533 - TMPFILES systemd-tmpfiles --create /etc/tmpfiles.d/ftw-cloud-keys.conf +# No container reaches the instance's credentials (hop limit 1), and the +# instance role may do more than ftwkeysd needs. So the host assumes the +# keys role, which may only use the two KMS keys, and leaves its credentials +# for ftwkeysd in tmpfs. They last an hour and are renewed every 15 minutes. +cat > /usr/local/sbin/ftw-cloud-keys-aws <<'FETCH' +#!/bin/bash +# The keys role's credentials into tmpfs for ftwkeysd. +set -euo pipefail +export AWS_PAGER="" +umask 077 +dir=/run/ftw-cloud-keys-aws +install -d -m 0700 -o 65533 -g 65533 "$dir" +tmp=$(mktemp "$dir/.credentials.XXXXXX") +trap 'rm -f "$tmp"' EXIT +# At boot the network and the instance's credentials may lag a little. +for try in $(seq 30); do + if aws sts assume-role --region eu-north-1 \ + --role-arn arn:aws:iam::976075258475:role/ftw-cloud-keys \ + --role-session-name ftwkeysd --duration-seconds 3600 \ + --query Credentials --output json >"$tmp"; then + break + fi + [ "$try" -lt 30 ] || exit 1 + sleep 2 +done +chown 65533:65533 "$tmp" +chmod 0400 "$tmp" +mv -f "$tmp" "$dir/credentials.json" +trap - EXIT +FETCH +chmod 0755 /usr/local/sbin/ftw-cloud-keys-aws + +cat > /etc/systemd/system/ftw-cloud-keys-aws.service <<'UNIT' +[Unit] +Description=FTW Cloud keys role credentials into tmpfs for ftwkeysd +Wants=network-online.target +After=network-online.target +Before=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/ftw-cloud-keys-aws + +[Install] +WantedBy=docker.service +UNIT + +cat > /etc/systemd/system/ftw-cloud-keys-aws.timer <<'UNIT' +[Unit] +Description=Renew the FTW Cloud keys role credentials + +[Timer] +OnActiveSec=15min +OnUnitActiveSec=15min +AccuracySec=1min + +[Install] +WantedBy=timers.target +UNIT + +systemctl daemon-reload +systemctl enable ftw-cloud-keys-aws.service +systemctl enable --now ftw-cloud-keys-aws.timer +systemctl start ftw-cloud-keys-aws.service + env=/etc/ftw-cloud/compose.env touch "$env" -sed -i '/^FTW_CLOUD_KMS_KEY=/d; /^COMPOSE_PROFILES=/d' "$env" -printf 'FTW_CLOUD_KMS_KEY=%s\nCOMPOSE_PROFILES=agents\n' "$key_arn" >> "$env" +sed -i '/^FTW_CLOUD_KMS_KEY=/d; /^FTW_CLOUD_OFFER_KEY=/d; /^COMPOSE_PROFILES=/d' "$env" +printf 'FTW_CLOUD_KMS_KEY=%s\nFTW_CLOUD_OFFER_KEY=%s\nCOMPOSE_PROFILES=agents\n' "$agent_key" "$offer_key" >> "$env" echo "ftwkeysd is on; the next ftw-cloud-deploy (or a restart of ftw-cloud.service) starts it" diff --git a/deploy/noise.sh b/deploy/noise.sh index 0b62b70..7ba2c5b 100755 --- a/deploy/noise.sh +++ b/deploy/noise.sh @@ -35,6 +35,7 @@ for try in $(seq 30); do sleep 2 done grep -q '^-----BEGIN PRIVATE KEY-----$' "$new/noise.key" +# ftw-cloud-own gives it to the user the image runs as, before compose starts. chown -R 65532:65532 "$new" chmod 0400 "$new/noise.key" chmod 0500 "$new" diff --git a/go.mod b/go.mod index 60a74e4..66184cb 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 - github.com/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1 + github.com/srcfl/ftw/go/protocol v0.0.0-20261015184016-e25596322c66 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.59.0 diff --git a/go.sum b/go.sum index 68aa95a..1c9ac7b 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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-20261015164244-f5cdb92058c1 h1:JLItMdT1tX3ejgmW80VXcwZ77wBPY7WT2ewMGfPLDIc= -github.com/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261015184016-e25596322c66 h1:ZFXkeVj4a828TZ5U9c4q7iC2IzLMf9pLSmcKS3gBpsk= +github.com/srcfl/ftw/go/protocol v0.0.0-20261015184016-e25596322c66/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go index 4b11b4a..a8f9b8c 100644 --- a/internal/keys/keys_test.go +++ b/internal/keys/keys_test.go @@ -3,6 +3,10 @@ package keys import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" "encoding/base64" "encoding/hex" "errors" @@ -15,6 +19,7 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/appwire" ) @@ -513,30 +518,92 @@ func TestManySessionsAtOnce(t *testing.T) { wg.Wait() } -// The offer key: made once, sealed, the same after a restart, and it signs -// only offers. A signature verifies with its public half and nothing else -// does. -func TestTheOfferKeySignsOffers(t *testing.T) { +// offerFor is a well-formed offer for key pub, as FTW Cloud makes one. +func offerFor(t *testing.T, pub []byte, edit func(*protocol.GrantOffer)) []byte { + t.Helper() + o := protocol.GrantOffer{ + V: protocol.GrantOfferVersion, Key: pub, Role: protocol.RoleAgent, + Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-oauth-client-metadata", ClientHost: "claude.ai", + Scopes: []string{"ftw.live.read"}, TTLS: 3600, Ticket: "t", Code: "ABCD-EFGH", + ExpMs: time.Now().Add(5 * time.Minute).UnixMilli(), + } + if edit != nil { + edit(&o) + } + raw, err := encMode.Marshal(o) + if err != nil { + t.Fatal(err) + } + return raw +} + +// The offer key: made once, sealed, the same after a restart. It signs an +// offer only for a key that the keys part made and that waits for approval, +// for the agent role and for no longer than an offer may live. What it signs +// opens with the box's own check. +func TestTheOfferKeySignsOnlyOffersItCanVouchFor(t *testing.T) { s, sealer, logs := newTestServer(t) c := s.Local() pub, err := c.OfferKey(ctx) if err != nil || len(pub) != 65 || pub[0] != 4 { t.Fatalf("offer key: %x %v", pub, err) } - offer := []byte{0xa1, 0x61, 0x76, 0x01} // {"v": 1} - sig, err := c.SignOffer(ctx, offer) + kid, agent, _ := c.New(ctx) + other, otherPub, _ := c.New(ctx) + offer := offerFor(t, agent, nil) + sig, err := c.SignOffer(ctx, kid, offer) if err != nil { t.Fatal(err) } if err := VerifyOffer(pub, offer, sig); err != nil { t.Fatal(err) } - if VerifyOffer(pub, []byte{0xa1, 0x61, 0x76, 0x02}, sig) == nil { + so := protocol.SignedOffer{O: offer, Kid: OfferKid(pub), Sig: sig} + if _, err := protocol.OpenOffer(map[string][]byte{OfferKid(pub): pub}, so, time.Now().UnixMilli()); err != nil { + t.Fatalf("the box does not open the offer: %v", err) + } + if VerifyOffer(pub, offerFor(t, agent, func(o *protocol.GrantOffer) { o.Code = "OTHER" }), sig) == nil { t.Fatal("a signature verified for another offer") } - if _, err := c.SignOffer(ctx, nil); !errors.Is(err, ErrRefused) { - t.Fatalf("an empty offer: %v", err) + + type extra struct { + protocol.GrantOffer + X int `cbor:"x"` + } + withExtra, _ := encMode.Marshal(extra{GrantOffer: protocol.GrantOffer{ + V: 1, Key: agent, Role: protocol.RoleAgent, ExpMs: time.Now().Add(time.Minute).UnixMilli()}, X: 1}) + refused := map[string]struct { + kid string + offer []byte + }{ + "empty": {kid, nil}, + "not CBOR": {kid, []byte("{}")}, + "an unknown field": {kid, withExtra}, + "another pending key": {other, offer}, + "another key's offer": {kid, offerFor(t, otherPub, nil)}, + "a made-up key": {kid, offerFor(t, bytes.Repeat([]byte{1}, 32), nil)}, + "no kid": {"", offer}, + "the support role": {kid, offerFor(t, agent, func(o *protocol.GrantOffer) { o.Role = protocol.RoleSupport })}, + "the owner role": {kid, offerFor(t, agent, func(o *protocol.GrantOffer) { o.Role = protocol.RoleOwner })}, + "another version": {kid, offerFor(t, agent, func(o *protocol.GrantOffer) { o.V = 2 })}, + "an expired offer": {kid, offerFor(t, agent, func(o *protocol.GrantOffer) { o.ExpMs = time.Now().UnixMilli() - 1 })}, + "an offer for too long": {kid, offerFor(t, agent, func(o *protocol.GrantOffer) { + o.ExpMs = time.Now().UnixMilli() + protocol.OfferTTLMs + 60_000 + })}, + } + for name, r := range refused { + if _, err := c.SignOffer(ctx, r.kid, r.offer); !errors.Is(err, ErrRefused) { + t.Errorf("%s: %v", name, err) + } } + // A key the owner approved is no longer offered. + if err := c.Keep(ctx, kid); err != nil { + t.Fatal(err) + } + if _, err := c.SignOffer(ctx, kid, offer); !errors.Is(err, ErrRefused) { + t.Fatalf("a kept key's offer: %v", err) + } + // A restart reads the same key back; it is not on disk in the clear. s2, err := NewServer(s.dir, sealer, nil) if err != nil { @@ -547,7 +614,7 @@ func TestTheOfferKeySignsOffers(t *testing.T) { t.Fatal("the offer key changed across a restart") } raw, _ := os.ReadFile(filepath.Join(s.dir, "offer.key")) - d, _ := s.offer.Bytes() + d, _ := s.offer.(*localOfferKey).key.Bytes() if bytes.Contains(raw, d) || bytes.Contains(logs.Bytes(), d) || bytes.Contains(logs.Bytes(), []byte(hex.EncodeToString(d))) { t.Fatal("the offer key's secret is on disk or in the log") } @@ -559,6 +626,90 @@ func TestTheOfferKeySignsOffers(t *testing.T) { } } +// fakeSigner stands in for an asymmetric KMS key. +type fakeSigner struct { + key *ecdsa.PrivateKey + wrong bool + calls int +} + +func (f *fakeSigner) PublicKey(_ context.Context, keyID string) ([]byte, error) { + if keyID != "alias/ftw-cloud-offer-key" { + return nil, errors.New("NotFoundException") + } + return x509.MarshalPKIXPublicKey(&f.key.PublicKey) +} + +func (f *fakeSigner) Sign(_ context.Context, _ string, digest []byte) ([]byte, error) { + f.calls++ + if f.wrong { + digest = bytes.Repeat([]byte{1}, 32) + } + return ecdsa.SignASN1(rand.Reader, f.key, digest) +} + +// In production the offer key is in KMS: the keys part asks KMS to sign, +// checks the answer and never holds the private half. +func TestTheOfferKeyCanLiveInKMS(t *testing.T) { + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + signer := &fakeSigner{key: priv} + k, err := NewKMSOfferKey(ctx, signer, "alias/ftw-cloud-offer-key") + if err != nil { + t.Fatal(err) + } + sealer, _ := FileSealerFromKey(bytes.Repeat([]byte{7}, dataKeyBytes)) + s, err := NewServer(t.TempDir(), sealer, nil, WithOfferKey(k)) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(s.dir, "offer.key")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a local offer key was made beside the KMS one") + } + c := s.Local() + pub, _ := c.OfferKey(ctx) + want, _ := priv.PublicKey.Bytes() + if !bytes.Equal(pub, want) { + t.Fatal("the public half is not KMS's") + } + kid, agent, _ := c.New(ctx) + offer := offerFor(t, agent, nil) + sig, err := c.SignOffer(ctx, kid, offer) + if err != nil || VerifyOffer(pub, offer, sig) != nil || signer.calls != 1 { + t.Fatalf("signed through KMS: %v (%d calls)", err, signer.calls) + } + // A refused offer never reaches KMS, and a wrong answer from KMS is not + // passed on. + _, _ = c.SignOffer(ctx, kid, offerFor(t, agent, func(o *protocol.GrantOffer) { o.Role = protocol.RoleSupport })) + if signer.calls != 1 { + t.Fatal("a refused offer reached KMS") + } + signer.wrong = true + if _, err := c.SignOffer(ctx, kid, offer); err == nil { + t.Fatal("a signature that does not verify was passed on") + } + if _, err := NewKMSOfferKey(ctx, signer, "alias/another"); err == nil { + t.Fatal("a missing key was taken") + } +} + +// Only a unix socket's peer is served. +func TestServeRefusesAConnectionThatIsNotUnix(t *testing.T) { + s, _, _ := newTestServer(t) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { _ = s.Serve(ln) }() + c := newClient(func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }) + defer c.Close() + tctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if _, _, err := c.New(tctx); err == nil { + t.Fatal("a TCP peer was served") + } +} + func TestOfferKidNamesTheKey(t *testing.T) { a, b := bytes.Repeat([]byte{4}, 65), bytes.Repeat([]byte{5}, 65) if len(OfferKid(a)) != 16 || OfferKid(a) == OfferKid(b) || OfferKid(a) != OfferKid(bytes.Clone(a)) { diff --git a/internal/keys/offer.go b/internal/keys/offer.go index 1dce650..33f5f8b 100644 --- a/internal/keys/offer.go +++ b/internal/keys/offer.go @@ -1,53 +1,73 @@ package keys import ( + "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" + "crypto/x509" + "encoding/asn1" "encoding/base64" "errors" "fmt" "io/fs" + "log/slog" "math/big" "os" "path/filepath" + + "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw/go/protocol" ) // The offer key. FTW Cloud's consent page hands the owner's app a grant // offer: the agent's public key and what its client asks for. The app shows // it before Face ID, so it must know the offer came from FTW Cloud and not // from a page that copied the look. The keys part signs each offer with one -// P-256 key that the app pins. It lives here, apart from the agent keys and -// sealed the same way, and it signs offers and nothing else: the signed -// bytes always start with OfferTag. +// P-256 key that Core and the apps pin. +// +// In production the key is an AWS KMS key of its own (ECC_NIST_P256, +// SIGN_VERIFY): its private half never leaves KMS, and only ftwkeysd's role +// may sign with it. In development and tests it is a local P-256 key, sealed +// in the state directory like an agent key. +// +// The keys part signs only an offer it can vouch for: a well-formed +// protocol.GrantOffer for the agent role, short-lived, whose key is one it +// made and that is still waiting for approval. So ftwcloudd cannot get an +// owner to approve a key that ftwcloudd holds. // OfferTag starts every signed offer array. -const OfferTag = "ftw.offer.v1" +const OfferTag = protocol.OfferTag -// offerKid binds the offer key's data key in the sealer's context. It is no -// agent kid's shape, so no agent operation can name it. +// offerKid binds the local offer key's data key in the sealer's context. It +// is no agent kid's shape, so no agent operation can name it. const offerKid = "offer-key" const ( opOfferPub = "offer.pub" // the offer key's public half, SEC1 uncompressed - opOfferSign = "offer.sign" // sign offer bytes d; answers r ‖ s + opOfferSign = "offer.sign" // sign offer d for pending key kid; answers r ‖ s ) -// OfferKid names an offer key by its public half: the first 16 characters -// of the base64url SHA-256 of the SEC1 point. The app and the box pin keys -// by it, so a new key can be added beside the old one. -func OfferKid(pub []byte) string { - sum := sha256.Sum256(pub) - return base64.RawURLEncoding.EncodeToString(sum[:])[:16] +// maxOffer bounds one offer's CBOR. +const maxOffer = 8 << 10 + +// An OfferSigner holds the offer key. +type OfferSigner interface { + // Public is the key's public half, SEC1 uncompressed. + Public() []byte + // Sign signs a SHA-256 digest and answers r ‖ s. + Sign(ctx context.Context, digest []byte) ([]byte, error) } +// OfferKid names an offer key by its public half (protocol.OfferKid). Core +// and the apps pin keys by it, so a new key can be added beside the old one. +func OfferKid(pub []byte) string { return protocol.OfferKid(pub) } + // OfferBytes is what the offer key signs: the deterministic CBOR array // [OfferTag, offer], where offer is the offer's own CBOR encoding. -func OfferBytes(offer []byte) ([]byte, error) { - return encMode.Marshal([]any{OfferTag, offer}) -} +func OfferBytes(offer []byte) ([]byte, error) { return protocol.OfferBytes(offer) } // VerifyOffer checks an offer's signature, r ‖ s, against the offer key's // public half. The app does the same with the key it pins. @@ -67,7 +87,165 @@ func VerifyOffer(pub, offer, sig []byte) error { return nil } -// offerRecord is the sealed offer key on disk. +// offerDec reads an offer as the box does: a key it does not know, or a key +// twice, is refused. +var offerDec = func() cbor.DecMode { + m, err := cbor.DecOptions{ + DupMapKey: cbor.DupMapKeyEnforcedAPF, + ExtraReturnErrors: cbor.ExtraDecErrorUnknownField, + IndefLength: cbor.IndefLengthForbidden, + MaxArrayElements: 64, + MaxMapPairs: 32, + MaxNestedLevels: 4, + }.DecMode() + if err != nil { + panic(err) + } + return m +}() + +// signOffer signs offer if the keys part can vouch for it: a GrantOffer of +// this version, for the agent role, good for no longer than OfferTTLMs, whose +// key is kid's and kid is still pending. +func (s *Server) signOffer(kid string, offer []byte) ([]byte, error) { + if s.offer == nil || !validKid(kid) || len(offer) == 0 || len(offer) > maxOffer { + return nil, ErrRefused + } + var o protocol.GrantOffer + if err := offerDec.Unmarshal(offer, &o); err != nil { + return nil, ErrRefused + } + now := s.now().UnixMilli() + if o.V != protocol.GrantOfferVersion || o.Role != protocol.RoleAgent || + o.ExpMs <= now || o.ExpMs > now+protocol.OfferTTLMs { + return nil, ErrRefused + } + pub, err := s.pendingPub(kid) + if err != nil || !bytes.Equal(pub, o.Key) { + return nil, ErrRefused + } + msg, err := OfferBytes(offer) + if err != nil { + return nil, err + } + digest := sha256.Sum256(msg) + ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) + defer cancel() + sig, err := s.offer.Sign(ctx, digest[:]) + if err != nil { + s.log.Warn("could not sign an offer", "err", err) + return nil, err + } + return sig, nil +} + +// pendingPub is the public half of a key still waiting for approval. +func (s *Server) pendingPub(kid string) ([]byte, error) { + raw, err := os.ReadFile(filepath.Join(s.dir, "pending", kid)) + if err != nil { + return nil, ErrUnknownKey + } + var rec record + if err := decMode.Unmarshal(raw, &rec); err != nil || rec.Kid != kid { + return nil, ErrUnknownKey + } + return rec.Pub, nil +} + +func (s *Server) offerPub() ([]byte, error) { + if s.offer == nil { + return nil, ErrRefused + } + return bytes.Clone(s.offer.Public()), nil +} + +// OfferKey is the offer key's public half, SEC1 uncompressed. +func (c *Client) OfferKey(ctx context.Context) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opOfferPub}) + return resp.Pub, err +} + +// SignOffer signs one offer's CBOR with the offer key. The offer's key must +// be the public half of kid, a key New made that is not yet kept. +func (c *Client) SignOffer(ctx context.Context, kid string, offer []byte) ([]byte, error) { + resp, err := c.call(ctx, request{Op: opOfferSign, Kid: kid, D: offer}) + return resp.D, err +} + +// -------------------------------------------------------------------------- +// The offer key in KMS +// -------------------------------------------------------------------------- + +// KMSSigner is the part of AWS KMS the offer key uses. cmd/ftwkeysd adapts +// the AWS SDK to it. +type KMSSigner interface { + // PublicKey answers the key's public half as DER SubjectPublicKeyInfo, as + // kms:GetPublicKey. It refuses a key that is not ECC_NIST_P256 for + // signing. + PublicKey(ctx context.Context, keyID string) ([]byte, error) + // Sign signs a SHA-256 digest with ECDSA_SHA_256, as kms:Sign with + // MessageType DIGEST, and answers the DER signature. + Sign(ctx context.Context, keyID string, digest []byte) ([]byte, error) +} + +type kmsOfferKey struct { + kms KMSSigner + id string + key *ecdsa.PublicKey + pub []byte +} + +// NewKMSOfferKey is the offer key held in KMS under keyID. It reads the +// public half once. +func NewKMSOfferKey(ctx context.Context, k KMSSigner, keyID string) (OfferSigner, error) { + der, err := k.PublicKey(ctx, keyID) + if err != nil { + return nil, fmt.Errorf("keys: offer key: %w", err) + } + parsed, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("keys: offer key: %w", err) + } + key, ok := parsed.(*ecdsa.PublicKey) + if !ok || key.Curve != elliptic.P256() { + return nil, errors.New("keys: the offer key is not a P-256 key") + } + pub, err := key.Bytes() + if err != nil { + return nil, err + } + return &kmsOfferKey{kms: k, id: keyID, key: key, pub: pub}, nil +} + +func (k *kmsOfferKey) Public() []byte { return k.pub } + +// Sign asks KMS and checks what comes back against the public half. +func (k *kmsOfferKey) Sign(ctx context.Context, digest []byte) ([]byte, error) { + der, err := k.kms.Sign(ctx, k.id, digest) + if err != nil { + return nil, err + } + var sig struct{ R, S *big.Int } + rest, err := asn1.Unmarshal(der, &sig) + if err != nil || len(rest) != 0 || sig.R == nil || sig.S == nil || !ecdsa.Verify(k.key, digest, sig.R, sig.S) { + return nil, errors.New("keys: KMS answered a signature that does not verify") + } + out := make([]byte, 64) + sig.R.FillBytes(out[:32]) + sig.S.FillBytes(out[32:]) + return out, nil +} + +// -------------------------------------------------------------------------- +// The local offer key, for development and tests +// -------------------------------------------------------------------------- + +type localOfferKey struct { + key *ecdsa.PrivateKey + pub []byte +} + +// offerRecord is the sealed local offer key on disk. type offerRecord struct { V int `cbor:"v"` Pub []byte `cbor:"pub"` @@ -75,105 +253,92 @@ type offerRecord struct { CT []byte `cbor:"ct"` } -// loadOfferKey unseals the offer key, making it on first start. It stays in -// memory: it signs every consent page, and vouches only for what a page -// shows. -func (s *Server) loadOfferKey() error { - path := filepath.Join(s.dir, "offer.key") +// LocalOfferKey is an offer key in dir/offer.key, sealed by sealer, made on +// first use. It is for development and tests: production signs in KMS. +func LocalOfferKey(dir string, sealer Sealer, log *slog.Logger) (OfferSigner, error) { + if log == nil { + log = slog.Default() + } + path := filepath.Join(dir, "offer.key") raw, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { - return s.makeOfferKey(path) + return makeLocalOfferKey(path, sealer, log) } if err != nil { - return err + return nil, err } var rec offerRecord if err := decMode.Unmarshal(raw, &rec); err != nil { - return fmt.Errorf("keys: offer key: %w", err) + return nil, fmt.Errorf("keys: offer key: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) defer cancel() - dek, err := s.sealer.Unwrap(ctx, offerKid, rec.DEK) + dek, err := sealer.Unwrap(ctx, offerKid, rec.DEK) if err != nil { - return fmt.Errorf("keys: offer key: %w", err) + return nil, fmt.Errorf("keys: offer key: %w", err) } defer wipe(dek) aead, err := newGCM(dek) if err != nil { - return err + return nil, err } d, err := open(aead, rec.CT, append([]byte(OfferTag+"\x00"), rec.Pub...)) if err != nil { - return errors.New("keys: the offer key does not open") + return nil, errors.New("keys: the offer key does not open") } defer wipe(d) key, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), d) if err != nil { - return err + return nil, err } - s.offer = key - return nil + return &localOfferKey{key: key, pub: rec.Pub}, nil } -func (s *Server) makeOfferKey(path string) error { +func makeLocalOfferKey(path string, sealer Sealer, log *slog.Logger) (OfferSigner, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - return err + return nil, err } pub, err := key.PublicKey.Bytes() if err != nil { - return err + return nil, err } d, err := key.Bytes() if err != nil { - return err + return nil, err } defer wipe(d) ctx, cancel := context.WithTimeout(context.Background(), unwrapTimeout) defer cancel() - dek, wrapped, err := s.sealer.DataKey(ctx, offerKid) + dek, wrapped, err := sealer.DataKey(ctx, offerKid) if err != nil { - return err + return nil, err } defer wipe(dek) aead, err := newGCM(dek) if err != nil { - return err + return nil, err } ct, err := seal(aead, d, append([]byte(OfferTag+"\x00"), pub...)) if err != nil { - return err + return nil, err } raw, err := encMode.Marshal(offerRecord{V: 1, Pub: pub, DEK: wrapped, CT: ct}) if err != nil { - return err + return nil, err } if err := writeFile(path, raw); err != nil { - return err + return nil, err } - s.offer = key - s.log.Info("made the offer key; pin it in Core and the apps", "kid", OfferKid(pub), + log.Info("made a local offer key, for development only", "kid", OfferKid(pub), "pub", base64.RawURLEncoding.EncodeToString(pub)) - return nil + return &localOfferKey{key: key, pub: pub}, nil } -func (s *Server) offerPub() ([]byte, error) { - if s.offer == nil { - return nil, ErrRefused - } - return s.offer.PublicKey.Bytes() -} +func (k *localOfferKey) Public() []byte { return k.pub } -func (s *Server) signOffer(offer []byte) ([]byte, error) { - if s.offer == nil || len(offer) == 0 || len(offer) > 8<<10 { - return nil, ErrRefused - } - msg, err := OfferBytes(offer) - if err != nil { - return nil, err - } - digest := sha256.Sum256(msg) - r, sv, err := ecdsa.Sign(rand.Reader, s.offer, digest[:]) +func (k *localOfferKey) Sign(_ context.Context, digest []byte) ([]byte, error) { + r, sv, err := ecdsa.Sign(rand.Reader, k.key, digest) if err != nil { return nil, err } @@ -182,15 +347,3 @@ func (s *Server) signOffer(offer []byte) ([]byte, error) { sv.FillBytes(sig[32:]) return sig, nil } - -// OfferKey is the offer key's public half, SEC1 uncompressed. -func (c *Client) OfferKey(ctx context.Context) ([]byte, error) { - resp, err := c.call(ctx, request{Op: opOfferPub}) - return resp.Pub, err -} - -// SignOffer signs one offer's CBOR with the offer key. -func (c *Client) SignOffer(ctx context.Context, offer []byte) ([]byte, error) { - resp, err := c.call(ctx, request{Op: opOfferSign, D: offer}) - return resp.D, err -} diff --git a/internal/keys/server.go b/internal/keys/server.go index 69175b9..2d8d37b 100644 --- a/internal/keys/server.go +++ b/internal/keys/server.go @@ -3,7 +3,6 @@ package keys import ( "context" "crypto/ecdh" - "crypto/ecdsa" "crypto/rand" "encoding/base64" "errors" @@ -58,7 +57,16 @@ type Server struct { mu sync.Mutex sessions map[string]*session // offer signs consent offers (offer.go). - offer *ecdsa.PrivateKey + offer OfferSigner +} + +// An Option sets up a Server. +type Option func(*Server) + +// WithOfferKey signs offers with k. Without it, the server makes a local +// offer key in its state directory, which is for development and tests. +func WithOfferKey(k OfferSigner) Option { + return func(s *Server) { s.offer = k } } type session struct { @@ -71,7 +79,7 @@ type session struct { // NewServer keeps sealed keys under dir: pending/ until the owner approves, // then kept/. -func NewServer(dir string, sealer Sealer, log *slog.Logger) (*Server, error) { +func NewServer(dir string, sealer Sealer, log *slog.Logger, opts ...Option) (*Server, error) { if log == nil { log = slog.Default() } @@ -81,13 +89,21 @@ func NewServer(dir string, sealer Sealer, log *slog.Logger) (*Server, error) { } } s := &Server{dir: dir, sealer: sealer, log: log, now: time.Now, PeerUID: -1, sessions: map[string]*session{}} - if err := s.loadOfferKey(); err != nil { - return nil, err + for _, o := range opts { + o(s) + } + if s.offer == nil { + k, err := LocalOfferKey(dir, sealer, log) + if err != nil { + return nil, err + } + s.offer = k } return s, nil } -// Serve accepts connections until ln closes. Each must come from PeerUID. +// Serve accepts connections until ln closes. Each must be a unix connection +// from PeerUID. func (s *Server) Serve(ln net.Listener) error { for { conn, err := ln.Accept() @@ -97,12 +113,16 @@ func (s *Server) Serve(ln net.Listener) error { } return err } - if uc, ok := conn.(*net.UnixConn); ok { - if err := checkPeer(uc, s.PeerUID); err != nil { - s.log.Warn("refused a connection from another user") - _ = conn.Close() - continue - } + uc, ok := conn.(*net.UnixConn) + if !ok { + s.log.Warn("refused a connection that is not a unix socket's") + _ = conn.Close() + continue + } + if err := checkPeer(uc, s.PeerUID); err != nil { + s.log.Warn("refused a connection from another user") + _ = conn.Close() + continue } go s.serveConn(conn) } @@ -171,7 +191,7 @@ func (s *Server) handle(req request) response { case opOfferPub: resp.Pub, err = s.offerPub() case opOfferSign: - resp.D, err = s.signOffer(req.D) + resp.D, err = s.signOffer(req.Kid, req.D) default: err = ErrRefused } @@ -384,7 +404,7 @@ func (s *Server) finish(sess string, msg2 []byte) ([]byte, error) { } split, err := x.init.Split() if err != nil { - return err + return ErrRefused } x.init, x.tr, payload = nil, appwire.NewTransport(split), p return nil From 0cddf80871dd1ff22fa9955b446e7906b8346316 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:51:57 +0200 Subject: [PATCH 05/28] chore(keys): pin the merged agent contract, srcfl/ftw#1642 at bf5bf50 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 66184cb..580f8ca 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 - github.com/srcfl/ftw/go/protocol v0.0.0-20261015184016-e25596322c66 + github.com/srcfl/ftw/go/protocol v0.0.0-20261015203548-bf5bf50bade6 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.59.0 diff --git a/go.sum b/go.sum index 1c9ac7b..a5ebb88 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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-20261015184016-e25596322c66 h1:ZFXkeVj4a828TZ5U9c4q7iC2IzLMf9pLSmcKS3gBpsk= -github.com/srcfl/ftw/go/protocol v0.0.0-20261015184016-e25596322c66/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261015203548-bf5bf50bade6 h1:+VacTKhwAs9LnKb0TZztvxEIVV4OXxOIzeHKDWKAw6I= +github.com/srcfl/ftw/go/protocol v0.0.0-20261015203548-bf5bf50bade6/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= From 5018c96390794df5ee14312deeccefb8acf35c0e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:14:43 +0200 Subject: [PATCH 06/28] feat(oauth): assistants sign in with OAuth, and the owner approves at home The authorization server for MCP clients, after the MCP authorization spec, revision 2026-07-28, with the issuer at https://cloud.ftw.energy: - OAuth 2.1, authorization code with PKCE S256 only; every authorization response, errors too, carries iss (RFC 9207), and the metadata says so (RFC 8414). - Protected resource metadata for https://cloud.ftw.energy/mcp (RFC 9728), at the path-inserted and the root well-known URIs. - resource (RFC 8707): only the MCP resource, at authorize and token; each token records its audience and /mcp checks it. - Client ID metadata documents, fetched behind a fence: https on 443 to a DNS name, every resolved address public (checked again as the socket connects), no proxy, no redirects, 16 KiB, 5 s, eight at once, cached by Cache-Control within a minute and a day, failures for a minute. No shared secret may sit in a public document. - Dynamic registration kept for ChatGPT and older clients: public, or client_secret_post/basic with the secret stored as a hash. - Access tokens for 10 minutes; refresh tokens rotate within a family, and a reused one revokes the family; a reused code revokes what it got. Tokens, codes and request ids are kept as SHA-256 only. - 401 and 403 with WWW-Authenticate: resource_metadata, scope, and insufficient_scope for step-up. Consent has no account and no password. /oauth/authorize asks ftwkeysd for an agent key, then shows the client's name (checked for a metadata document, marked unchecked for a registration), the host the browser goes back to (with a warning for loopback), the rights in plain words, a short code, a QR and https://app.ftw.energy/a/. The app reads /oauth/requests/, the box's page /oauth/requests?code=. The page waits on a server-sent event. When the box's signed roster lists the agent's key as a device that may open a session, the request is approved, the key is kept, and only the browser that started it gets a fresh code. Denied and expired requests return access_denied and the key is forgotten. A token maps to (shard, home, agent id). The subject has a kind, so a staff principal fits later without changing what an agent's token is. A roster that drops an agent, or the end the box set for it, deletes its tokens and its key and tells the MCP side. ftwcloudd mounts it with -keys-socket; state in oauth.db beside the registry. No route reads a home (TestOAuthRoutesReadNoHome). Co-Authored-By: Claude Opus 5.5 --- README.md | 22 + cmd/ftwcloudd/main.go | 15 + deploy/compose.yml | 1 + go.mod | 1 + go.sum | 2 + internal/oauth/authorize.go | 400 ++++++++++++ internal/oauth/cimd.go | 375 ++++++++++++ internal/oauth/cimd_test.go | 215 +++++++ internal/oauth/clients.go | 249 ++++++++ internal/oauth/oauth.go | 453 ++++++++++++++ internal/oauth/oauth_test.go | 878 +++++++++++++++++++++++++++ internal/oauth/page.go | 154 +++++ internal/oauth/store.go | 512 ++++++++++++++++ internal/oauth/token.go | 283 +++++++++ internal/server/oauth_routes_test.go | 53 ++ internal/server/server.go | 11 +- 16 files changed, 3622 insertions(+), 2 deletions(-) create mode 100644 internal/oauth/authorize.go create mode 100644 internal/oauth/cimd.go create mode 100644 internal/oauth/cimd_test.go create mode 100644 internal/oauth/clients.go create mode 100644 internal/oauth/oauth.go create mode 100644 internal/oauth/oauth_test.go create mode 100644 internal/oauth/page.go create mode 100644 internal/oauth/store.go create mode 100644 internal/oauth/token.go create mode 100644 internal/server/oauth_routes_test.go diff --git a/README.md b/README.md index f096d50..e16bb69 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,28 @@ to it yet. sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn it on. +- **Assistants sign in with OAuth, and the owner approves at home.** The + authorization server follows the MCP authorization spec, revision + 2026-07-28: OAuth 2.1 with PKCE S256 only, metadata at + `/.well-known/oauth-authorization-server` with `iss` in every + authorization response (RFC 9207), protected resource metadata for + `https://cloud.ftw.energy/mcp` (RFC 9728), and tokens bound to that + resource (RFC 8707). A client names itself by a client ID metadata + document, fetched behind a fence (https on 443 to a public address checked + as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or + registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older + clients. There is no account and no password: `/oauth/authorize` asks + `ftwkeysd` for a new agent key, then shows the client's name, where the + browser goes back, the rights asked for, a short code, a QR and the link + `https://app.ftw.energy/a/`. The owner approves in the app or on + the box's page; the box lists the agent's key in the roster it signs, and + when that roster arrives the page, waiting on a server-sent event, returns + to the client with a code. Only the browser that started the request gets + it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one + revokes its family. The service keeps hashes, never tokens. A token is + bound to one agent of one home; a roster without the agent, or the end the + box set for it, ends its tokens and its key. + Not built yet: | Step | What | Where | diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 2bc334f..9dde1cd 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -33,6 +33,8 @@ import ( "github.com/srcfl/ftw-cloud/internal/apns" "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw-cloud/internal/oauth" "github.com/srcfl/ftw-cloud/internal/push" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/server" @@ -73,6 +75,7 @@ func serve(args []string) error { apnsWatch := fs.String("apns-watch-topic", "energy.ftw.app.watchkitapp", "the watch app's bundle id") devPush := fs.String("dev-push-listen", "", "loopback address for registering a token by hand (development builds only)") devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)") + keysSocket := fs.String("keys-socket", "/run/ftw-cloud-keys/keys.sock", "ftwkeysd's socket; assistants can connect only while it answers") _ = fs.Parse(args) harden() @@ -110,6 +113,16 @@ func serve(args []string) error { tokens := token.New(st, reg, log) srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log} + // Assistants: the authorization server, with agent keys in ftwkeysd. + agentKeys := keys.Dial(*keysSocket) + defer agentKeys.Close() + auth, err := oauth.New(filepath.Join(*stateDir, "oauth.db"), agentKeys, log) + if err != nil { + return err + } + defer auth.Close() + srv.OAuth = auth + // Alerts: the box's signed events to its phones through Apple, and // box.unreachable when a box is away. Widgets: a push with no data when // prices or a car change. Without a key the service keeps registrations @@ -133,6 +146,7 @@ func serve(args []string) error { up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) tokens.Roster(home, r) + auth.Roster(home, r) } up.OnGone = pushSvc.BoxGone @@ -188,6 +202,7 @@ func serve(args []string) error { defer stop() go srv.Upkeep(ctx, time.Minute) go pushSvc.Run(ctx) + go auth.Run(ctx) if err := pushSvc.Resume(); err != nil { log.Warn("could not restart the dead man's switch", "err", err) } diff --git a/deploy/compose.yml b/deploy/compose.yml index 96c90e2..352a6b2 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -21,6 +21,7 @@ services: - -service-key=/run/secrets/ftw-cloud-service.key - -acme-email=${FTW_CLOUD_ACME_EMAIL:-} - -noise-key=/run/secrets/noise/noise.key + - -keys-socket=/run/ftw-cloud-keys/keys.sock environment: # /source publishes it, so anyone can compare it with what CI built. FTW_CLOUD_IMAGE: ${FTW_CLOUD_IMAGE} diff --git a/go.mod b/go.mod index 580f8ca..38c7f22 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,5 @@ require ( modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index a5ebb88..73618ac 100644 --- a/go.sum +++ b/go.sum @@ -92,3 +92,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go new file mode 100644 index 0000000..4d906f7 --- /dev/null +++ b/internal/oauth/authorize.go @@ -0,0 +1,400 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// codeAlphabet makes the short code: no 0/O, 1/I or L to misread. +const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +// codeLen gives about 29 bits. A code finds a request for the box's page and +// proves nothing: the owner still approves on their own box. +const codeLen = 6 + +var challengeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + +// authorize request parameters, after the checks that allow a redirect. +type authRequest struct { + client client + redirectURI string + state string + challenge string + scope []string +} + +func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + for k, v := range q { + if len(v) > 1 { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This link is not valid", + Text: fmt.Sprintf("The parameter %s appears more than once.", k)}) + return + } + } + ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) + defer cancel() + + // Until client and redirect URI check out, nothing goes back to the + // client: a bad request could otherwise send the browser anywhere. + c, err := s.resolveClient(ctx, q.Get("client_id")) + if err != nil { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This assistant is not known here", + Text: "The link names a client that FTW Cloud cannot check. Ask the assistant to connect again."}) + return + } + redirect := q.Get("redirect_uri") + if redirect == "" && len(c.RedirectURIs) == 1 { + redirect = c.RedirectURIs[0] + } + if redirect == "" || checkRedirect(redirect) != nil || !redirectMatches(c.RedirectURIs, redirect) { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This link is not valid", + Text: "The assistant asked to return somewhere it did not register."}) + return + } + req := authRequest{client: c, redirectURI: redirect, state: q.Get("state")} + fail := func(code, desc string) { + http.Redirect(w, r, authorizationError(redirect, code, desc, req.state), http.StatusFound) + } + if len(req.state) > 1024 { + fail("invalid_request", "state is too long") + return + } + if q.Get("response_type") != "code" { + fail("unsupported_response_type", "only response_type=code") + return + } + if q.Get("code_challenge_method") != "S256" || !challengeRE.MatchString(q.Get("code_challenge")) { + fail("invalid_request", "PKCE with code_challenge_method=S256 is required") + return + } + req.challenge = q.Get("code_challenge") + if res := q.Get("resource"); res != "" && !sameResource(res) { + fail("invalid_target", "this server issues tokens only for "+Resource) + return + } + for _, sc := range splitScope(q.Get("scope")) { + switch { + case sc == "offline_access": + // Refresh tokens come anyway. + case validScope(sc): + if !slices.Contains(req.scope, sc) { + req.scope = append(req.scope, sc) + } + default: + fail("invalid_scope", "unknown scope "+sc) + return + } + } + if len(req.scope) == 0 { + req.scope = slices.Clone(ReadScopes) + } + if !s.authorizeLimit.allow(s.now()) { + fail("temporarily_unavailable", "too many requests; try again shortly") + return + } + + // The agent's key pair comes first; only its public half leaves the + // keys part, to be shown to the owner and listed by the box. + kid, pub, err := s.keys.New(ctx) + if err != nil { + s.log.Warn("the keys part could not make an agent key", "err", err) + fail("temporarily_unavailable", "assistants cannot connect right now") + return + } + now := s.now() + rid, browser := randomToken(16), randomToken(32) + con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: c.Host, Verified: c.Verified, RedirectURI: redirect, + State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, + Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} + for try := 0; ; try++ { + con.Code = shortCode() + err = s.st.putConsent(rid, browser, con) + if err == nil || try == 3 { + break + } + } + if err != nil { + _ = s.keys.Forget(ctx, kid) + fail("server_error", "could not start the request") + return + } + http.SetCookie(w, &http.Cookie{Name: cookieName(rid), Value: browser, Path: "/", MaxAge: int(RequestTTL.Seconds()), + Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + s.consentPage(w, rid, con) +} + +// sameResource compares a resource parameter with Resource, taking +// uppercase in the scheme and host, and a trailing slash, as the same. +func sameResource(res string) bool { + u, err := url.Parse(res) + if err != nil || u.Fragment != "" || u.RawQuery != "" { + return false + } + u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host) + u.Path = strings.TrimSuffix(u.Path, "/") + return u.String() == Resource +} + +func validScope(sc string) bool { return slices.Contains(AllScopes, sc) } + +func cookieName(rid string) string { return "__Host-ftw-consent-" + rid[:8] } + +func shortCode() string { + b := make([]byte, codeLen) + if _, err := rand.Read(b); err != nil { + panic(err) + } + for i := range b { + b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] + } + return string(b) +} + +// normalizeCode reads a code as an owner may type it. +func normalizeCode(s string) string { + s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(s)) + if len(s) != codeLen || strings.Trim(s, codeAlphabet) != "" { + return "" + } + return s +} + +// authorizationResponse adds the parameters to the client's redirect URI, +// with iss, as RFC 9207 asks of every response. +func authorizationResponse(redirect string, params url.Values) string { + u, _ := url.Parse(redirect) + q := u.Query() + for k, v := range params { + q[k] = v + } + q.Set("iss", Issuer) + u.RawQuery = q.Encode() + return u.String() +} + +func authorizationError(redirect, code, desc, state string) string { + p := url.Values{"error": {code}, "error_description": {desc}} + if state != "" { + p.Set("state", state) + } + return authorizationResponse(redirect, p) +} + +// -------------------------------------------------------------------------- +// The request, for the app and the box's page +// -------------------------------------------------------------------------- + +type requestView struct { + Request string `json:"request,omitempty"` + Status string `json:"status"` + Code string `json:"code"` + Client clientView `json:"client"` + Redirect redirectView `json:"redirect"` + Scopes []string `json:"scopes"` + Agent agentKeyView `json:"agent"` + Expires int64 `json:"expiresAtMs"` +} + +type clientView struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + Verified bool `json:"verified"` +} + +type redirectView struct { + Host string `json:"host"` + // Local is a client on the owner's own computer. + Local bool `json:"local"` +} + +type agentKeyView struct { + // Key is the agent's X25519 public key, base64url: what the box lists. + Key string `json:"key"` + ID string `json:"id"` +} + +func (s *Server) view(c consent) requestView { + u, _ := url.Parse(c.RedirectURI) + status := c.Status + if status == statusPending && !s.now().Before(c.Expires) { + status = "expired" + } + return requestView{Status: status, Code: c.Code, + Client: clientView{ID: c.ClientID, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified}, + Redirect: redirectView{Host: u.Hostname(), Local: u.Scheme == "http" && isLoopback(u.Hostname())}, + Scopes: splitScope(c.Scope), + Agent: agentKeyView{Key: base64.RawURLEncoding.EncodeToString(c.AgentKey), ID: cloudwire.DeviceID(c.AgentKey)}, + Expires: c.Expires.UnixMilli()} +} + +// serveRequest gives the app what it shows the owner before they approve. +func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { + c, ok, err := s.st.consentByRID(r.PathValue("rid")) + if err != nil || !ok { + http.NotFound(w, r) + return + } + allowApp(w, r) + v := s.view(c) + v.Request = r.PathValue("rid") + writeJSON(w, http.StatusOK, v, false) +} + +// serveRequestByCode finds a request by the code an owner typed on the box's +// page. Lookups are rate limited for everyone together: a code lasts ten +// minutes and has about 29 bits, and finding one only shows a client's name. +func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { + if !s.lookupLimit.allow(s.now()) { + w.Header().Set("Retry-After", "1") + http.Error(w, "too many lookups", http.StatusTooManyRequests) + return + } + code := normalizeCode(r.URL.Query().Get("code")) + if code == "" { + http.NotFound(w, r) + return + } + c, ok, err := s.st.consentByCode(code) + if err != nil || !ok || c.Status != statusPending || !s.now().Before(c.Expires) { + http.NotFound(w, r) + return + } + allowApp(w, r) + writeJSON(w, http.StatusOK, s.view(c), false) +} + +func allowApp(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == AppOrigin { + w.Header().Set("Access-Control-Allow-Origin", AppOrigin) + w.Header().Set("Vary", "Origin") + } +} + +// serveDeny ends a pending request without approval: the owner said no, or +// the person at the computer cancelled. +func (s *Server) serveDeny(w http.ResponseWriter, r *http.Request) { + rid := r.PathValue("rid") + c, ok, err := s.st.deny(rid) + if err != nil { + http.Error(w, "could not deny", http.StatusInternalServerError) + return + } + if ok { + _ = s.keys.Forget(r.Context(), c.Kid) + s.hub.notify(string(hash(rid))) + } + allowApp(w, r) + w.WriteHeader(http.StatusNoContent) +} + +// -------------------------------------------------------------------------- +// The event stream the page waits on +// -------------------------------------------------------------------------- + +// keepAlive is how often a quiet stream sends a comment, and looks at the +// request again in case another process changed it. +const keepAlive = 20 * time.Second + +func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { + rid := r.PathValue("rid") + if _, ok, err := s.st.consentByRID(rid); err != nil || !ok { + http.NotFound(w, r) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + // Only the browser that started the request gets the code. + var browser string + if ck, err := r.Cookie(cookieName(rid)); err == nil && s.st.browserMatches(rid, ck.Value) { + browser = ck.Value + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + wake, done := s.hub.subscribe(string(hash(rid))) + defer done() + tick := time.NewTicker(keepAlive) + defer tick.Stop() + // A pending request's end does not move; once it changes state the + // stream is over. + expiry := time.NewTimer(RequestTTL) + defer expiry.Stop() + for { + c, ok, err := s.st.consentByRID(rid) + if err != nil || !ok { + return + } + if end := s.event(w, rid, browser, c); end { + flusher.Flush() + return + } + flusher.Flush() + expiry.Reset(max(c.Expires.Sub(s.now()), 0)) + select { + case <-r.Context().Done(): + return + case <-wake: + case <-expiry.C: + case <-tick.C: + _, _ = fmt.Fprint(w, ":\n\n") + } + } +} + +// event writes the request's state. end is true when the stream is done. +func (s *Server) event(w http.ResponseWriter, rid, browser string, c consent) (end bool) { + send := func(name, redirect string) { + data := "{}" + if redirect != "" && browser != "" { + data = fmt.Sprintf(`{"redirect":%q}`, redirect) + } + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, data) + } + expired := !s.now().Before(c.Expires) + switch { + case c.Status == statusApproved: + if browser == "" { + send("approved", "") + return true + } + // A new code for each delivery: a browser that reconnects gets one + // that works, and the earlier one stops working. + code := randomToken(32) + if err := s.st.issueCode(rid, code, s.now().Add(CodeTTL)); err != nil { + send("expired", "") + return true + } + p := url.Values{"code": {code}} + if c.State != "" { + p.Set("state", c.State) + } + send("approved", authorizationResponse(c.RedirectURI, p)) + return true + case c.Status == statusDenied: + send("denied", authorizationError(c.RedirectURI, "access_denied", "the owner did not approve", c.State)) + return true + case expired: + send("expired", authorizationError(c.RedirectURI, "access_denied", "the request ran out before the owner approved", c.State)) + return true + default: + _, _ = fmt.Fprintf(w, "event: pending\ndata: {\"expiresAtMs\":%d}\n\n", c.Expires.UnixMilli()) + return false + } +} diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go new file mode 100644 index 0000000..65a38c3 --- /dev/null +++ b/internal/oauth/cimd.go @@ -0,0 +1,375 @@ +package oauth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + "unicode" +) + +// Client ID metadata documents: a client's id is an https URL, and the +// document at that URL names the client and its redirect URIs. The service +// fetches a URL a stranger chose, so the fetch is fenced: https on port 443 +// to a DNS name, only to public addresses (checked on the address actually +// dialled, so a name that changes its answer between lookup and connect +// gains nothing), no proxy, no redirects, no cookies, a small body and a +// short time, a few at once, and the answers cached. + +const ( + cimdMaxBytes = 16 << 10 + cimdTimeout = 5 * time.Second + cimdMaxURL = 2048 + cimdCacheMin = time.Minute + cimdCacheMax = 24 * time.Hour + cimdCacheNone = 10 * time.Minute // with no Cache-Control + cimdCacheFailed = time.Minute + cimdCacheSize = 1024 + cimdConcurrent = 8 +) + +// clientMetadata is the part of a metadata document the service reads. +type clientMetadata struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +// Fetch refusals. Each is a reason to refuse the client, never to retry. +var ( + errCIMDURL = errors.New("client_id is not a URL this service fetches") + errCIMDAddress = errors.New("client_id resolves to an address this service does not fetch from") + errCIMDFetch = errors.New("client metadata could not be fetched") + errCIMDDocument = errors.New("client metadata document is not valid") +) + +// fetcher gets metadata documents. +type fetcher struct { + client *http.Client + // resolve looks a name up. Tests point it at their own server. + resolve func(ctx context.Context, host string) ([]netip.Addr, error) + // public decides which addresses may be dialled. + public func(netip.Addr) bool + // dial connects to an address that passed the checks. Tests point it + // at their own server. + dial func(ctx context.Context, network, addr string) (net.Conn, error) + timeout time.Duration + now func() time.Time + sem chan struct{} + + mu sync.Mutex + cache map[string]cimdEntry +} + +type cimdEntry struct { + md clientMetadata + err error + until time.Time +} + +// newFetcher builds the fenced fetcher. roots is nil for the system's. +func newFetcher(roots *x509.CertPool) *fetcher { + f := &fetcher{ + resolve: func(ctx context.Context, host string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip", host) + }, + public: publicAddr, + timeout: cimdTimeout, + now: time.Now, + sem: make(chan struct{}, cimdConcurrent), + cache: map[string]cimdEntry{}, + } + // The address is checked again as the socket connects, on the address + // itself. + dialer := &net.Dialer{Timeout: 3 * time.Second, Control: func(_, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil || !f.public(ap.Addr()) { + return errCIMDAddress + } + return nil + }} + f.dial = dialer.DialContext + tr := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := f.resolve(ctx, host) + if err != nil { + return nil, errCIMDFetch + } + for _, ip := range ips { + if !f.public(ip) { + // One private answer is enough to refuse: a name that + // mixes them is pointing somewhere it should not. + return nil, errCIMDAddress + } + } + if len(ips) == 0 { + return nil, errCIMDFetch + } + return f.dial(ctx, network, net.JoinHostPort(ips[0].Unmap().String(), port)) + }, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}, + TLSHandshakeTimeout: 3 * time.Second, + ResponseHeaderTimeout: 3 * time.Second, + MaxResponseHeaderBytes: 8 << 10, + DisableKeepAlives: true, + ForceAttemptHTTP2: true, + } + f.client = &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return f +} + +// isCIMD reports whether a client_id is a URL, and so names a metadata +// document rather than a registered client. +func isCIMD(clientID string) bool { return strings.HasPrefix(clientID, "https://") } + +// checkCIMDURL holds a client_id URL to the rules: https, a DNS name on the +// default port, a path that is not the root and has no dot segments, no +// credentials and no fragment. +func checkCIMDURL(raw string) (*url.URL, error) { + if len(raw) > cimdMaxURL { + return nil, errCIMDURL + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || u.RawFragment != "" || + strings.Contains(raw, "#") || u.Opaque != "" { + return nil, errCIMDURL + } + host := u.Hostname() + if host == "" || u.Port() != "" && u.Port() != "443" || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") { + return nil, errCIMDURL + } + if _, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { + return nil, errCIMDURL // an address, not a name + } + if u.Path == "" || u.Path == "/" { + return nil, errCIMDURL + } + for _, seg := range strings.Split(u.Path, "/") { + if seg == "." || seg == ".." { + return nil, errCIMDURL + } + } + return u, nil +} + +// get returns the validated metadata for a client_id URL, from the cache +// when it may. +func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, error) { + u, err := checkCIMDURL(clientID) + if err != nil { + return clientMetadata{}, err + } + now := f.now() + f.mu.Lock() + if e, ok := f.cache[clientID]; ok && now.Before(e.until) { + f.mu.Unlock() + return e.md, e.err + } + f.mu.Unlock() + + select { + case f.sem <- struct{}{}: + defer func() { <-f.sem }() + case <-ctx.Done(): + return clientMetadata{}, errCIMDFetch + } + md, ttl, err := f.fetch(ctx, u) + if err != nil { + ttl = cimdCacheFailed + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.cache) >= cimdCacheSize { + for k, e := range f.cache { + if !now.Before(e.until) || len(f.cache) >= cimdCacheSize { + delete(f.cache, k) + } + if len(f.cache) < cimdCacheSize/2 { + break + } + } + } + if ttl > 0 { + f.cache[clientID] = cimdEntry{md: md, err: err, until: now.Add(ttl)} + } + return md, err +} + +func (f *fetcher) fetch(ctx context.Context, u *url.URL) (clientMetadata, time.Duration, error) { + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return clientMetadata{}, 0, errCIMDURL + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "FTW-Cloud (+https://cloud.ftw.energy/source)") + resp, err := f.client.Do(req) + if err != nil { + if errors.Is(err, errCIMDAddress) { + return clientMetadata{}, 0, errCIMDAddress + } + return clientMetadata{}, 0, errCIMDFetch + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return clientMetadata{}, 0, fmt.Errorf("%w: status %d", errCIMDFetch, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, cimdMaxBytes+1)) + if err != nil { + return clientMetadata{}, 0, errCIMDFetch + } + if len(body) > cimdMaxBytes { + return clientMetadata{}, 0, fmt.Errorf("%w: larger than %d bytes", errCIMDDocument, cimdMaxBytes) + } + md, err := parseMetadata(body, u.String()) + if err != nil { + return clientMetadata{}, 0, err + } + return md, cacheFor(resp.Header.Get("Cache-Control")), nil +} + +// parseMetadata reads and checks one document fetched from clientID. +func parseMetadata(body []byte, clientID string) (clientMetadata, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return clientMetadata{}, fmt.Errorf("%w: not a JSON object", errCIMDDocument) + } + // A document is public: a client that puts a shared secret in it has + // none. + for _, k := range []string{"client_secret", "client_secret_expires_at"} { + if _, ok := fields[k]; ok { + return clientMetadata{}, fmt.Errorf("%w: %s in a public document", errCIMDDocument, k) + } + } + var md clientMetadata + if err := json.Unmarshal(body, &md); err != nil { + return clientMetadata{}, fmt.Errorf("%w: %v", errCIMDDocument, err) + } + if md.ClientID != clientID { + return clientMetadata{}, fmt.Errorf("%w: client_id does not match its URL", errCIMDDocument) + } + md.ClientName = cleanName(md.ClientName) + if md.ClientName == "" { + return clientMetadata{}, fmt.Errorf("%w: no client_name", errCIMDDocument) + } + if len(md.RedirectURIs) == 0 || len(md.RedirectURIs) > 16 { + return clientMetadata{}, fmt.Errorf("%w: redirect_uris", errCIMDDocument) + } + for _, r := range md.RedirectURIs { + if err := checkRedirect(r); err != nil { + return clientMetadata{}, fmt.Errorf("%w: %v", errCIMDDocument, err) + } + } + if m := md.TokenEndpointAuthMethod; m != "" && m != authNone { + return clientMetadata{}, fmt.Errorf("%w: token_endpoint_auth_method %q is not supported", errCIMDDocument, m) + } + if !subset(md.GrantTypes, []string{grantCode, grantRefresh}) || !subset(md.ResponseTypes, []string{"code"}) { + return clientMetadata{}, fmt.Errorf("%w: grant or response types", errCIMDDocument) + } + return md, nil +} + +// cacheFor reads how long a document may be kept, within bounds. +func cacheFor(cc string) time.Duration { + if cc == "" { + return cimdCacheNone + } + for _, d := range strings.Split(cc, ",") { + d = strings.ToLower(strings.TrimSpace(d)) + switch { + case d == "no-store" || d == "no-cache": + return 0 + case strings.HasPrefix(d, "max-age="): + s, err := strconv.Atoi(strings.TrimPrefix(d, "max-age=")) + if err != nil || s < 0 { + return cimdCacheNone + } + return min(max(time.Duration(s)*time.Second, cimdCacheMin), cimdCacheMax) + } + } + return cimdCacheNone +} + +// publicAddr is an address on the public internet: not loopback, private, +// link-local (where the cloud's metadata service answers), multicast, +// shared, reserved, documentation, or a translation prefix that could lead +// back inside. +func publicAddr(a netip.Addr) bool { + a = a.Unmap() + if !a.IsValid() || a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || + a.IsInterfaceLocalMulticast() || a.IsMulticast() || a.IsUnspecified() { + return false + } + for _, p := range blocked { + if p.Contains(a) { + return false + } + } + return true +} + +var blocked = func() []netip.Prefix { + var out []netip.Prefix + for _, s := range []string{ + "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", + "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "255.255.255.255/32", + "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", + } { + out = append(out, netip.MustParsePrefix(s)) + } + return out +}() + +// cleanName keeps a client's name to one short line of printable text. It +// is shown to an owner deciding whom to let in, so nothing in it may pass +// for something the service wrote. +func cleanName(s string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, s) + s = strings.Join(strings.Fields(s), " ") + if r := []rune(s); len(r) > 64 { + s = string(r[:64]) + "…" + } + return s +} + +func subset(have, allowed []string) bool { + for _, h := range have { + if !slices.Contains(allowed, h) { + return false + } + } + return true +} diff --git a/internal/oauth/cimd_test.go b/internal/oauth/cimd_test.go new file mode 100644 index 0000000..db50061 --- /dev/null +++ b/internal/oauth/cimd_test.go @@ -0,0 +1,215 @@ +package oauth + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" +) + +// The client_id URL itself: only https to a DNS name on port 443, with a +// real path. +func TestCIMDURLRules(t *testing.T) { + for _, bad := range []string{ + "http://example.com/client.json", + "https://example.com", + "https://example.com/", + "https://127.0.0.1/client.json", + "https://[::1]/client.json", + "https://169.254.169.254/latest/meta-data", + "https://example.com:8443/client.json", + "https://user:pass@example.com/client.json", + "https://example.com/client.json#frag", + "https://example.com/a/../client.json", + "https://example.com/./client.json", + "https://localhost/client.json", + "https://example.com./client.json", + "https://" + strings.Repeat("a", cimdMaxURL) + ".com/x", + } { + if _, err := checkCIMDURL(bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } + for _, good := range []string{"https://example.com/client.json", "https://claude.ai/oauth/mcp-client-metadata", "https://a.example:443/x"} { + if _, err := checkCIMDURL(good); err != nil { + t.Errorf("%s was refused: %v", good, err) + } + } +} + +func TestPublicAddresses(t *testing.T) { + for _, s := range []string{ + "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.40", "169.254.169.254", "100.64.0.1", "0.0.0.0", + "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "::1", "::", "fe80::1", "fd00::1", "fc00::1", + "::ffff:10.0.0.1", "::ffff:169.254.169.254", "64:ff9b::a00:1", "2002:a00:1::", "2001:db8::1", "ff02::1", + } { + if publicAddr(netip.MustParseAddr(s)) { + t.Errorf("%s counts as public", s) + } + } + for _, s := range []string{"1.1.1.1", "13.50.84.149", "2606:4700:4700::1111", "::ffff:1.1.1.1"} { + if !publicAddr(netip.MustParseAddr(s)) { + t.Errorf("%s does not count as public", s) + } + } +} + +// fenced is the production fence with a resolver the test chooses: every +// name below is refused before a socket opens, and dial counts any socket +// that would have. +func fenced(t *testing.T, answers map[string][]string) (*fetcher, *atomic.Int32) { + f := newFetcher(nil) + var dialled atomic.Int32 + f.resolve = func(_ context.Context, host string) ([]netip.Addr, error) { + var out []netip.Addr + for _, s := range answers[host] { + out = append(out, netip.MustParseAddr(s)) + } + if out == nil { + return nil, errors.New("no such host") + } + return out, nil + } + f.dial = func(context.Context, string, string) (net.Conn, error) { + dialled.Add(1) + return nil, errors.New("test: no network") + } + return f, &dialled +} + +// A name that resolves anywhere inside is refused before connecting: +// loopback, the private ranges, the cloud's metadata service, shared +// address space, IPv6 inside, IPv4 in IPv6, NAT64, and a name that mixes a +// public answer with a private one. +func TestCIMDRefusesInsideAddresses(t *testing.T) { + answers := map[string][]string{ + "loop.example": {"127.0.0.1"}, + "ten.example": {"10.0.0.7"}, + "meta.example": {"169.254.169.254"}, + "cgnat.example": {"100.64.1.1"}, + "v6loop.example": {"::1"}, + "ula.example": {"fd12:3456::1"}, + "mapped.example": {"::ffff:192.168.1.40"}, + "nat64.example": {"64:ff9b::a9fe:a9fe"}, + "mixed.example": {"1.1.1.1", "10.0.0.1"}, + "zero.example": {"0.0.0.0"}, + "linklocal.example": {"fe80::1"}, + } + f, dialled := fenced(t, answers) + for host := range answers { + _, err := f.get(context.Background(), "https://"+host+"/client.json") + if !errors.Is(err, errCIMDAddress) { + t.Errorf("%s: %v", host, err) + } + } + if n := dialled.Load(); n != 0 { + t.Fatalf("%d sockets opened toward inside addresses", n) + } + // The second check, as the socket connects, refuses too: a dialer + // handed an inside address by any route does not connect. + fresh := newFetcher(nil) + if _, err := fresh.dial(context.Background(), "tcp", "127.0.0.1:443"); err == nil || !strings.Contains(err.Error(), errCIMDAddress.Error()) { + t.Fatalf("connect-time check: %v", err) + } +} + +// What the document's server may do: redirect, send too much, be slow, +// answer an error, or serve a document that is not the client's. Each is a +// refusal, and a refusal is cached for a minute. +func TestCIMDServerMisbehaviour(t *testing.T) { + g := newRig(t) + var hits atomic.Int32 + slow := make(chan struct{}) + t.Cleanup(func() { close(slow) }) + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + switch r.URL.Path { + case "/redirect.json": + http.Redirect(w, r, "https://169.254.169.254/latest/meta-data", http.StatusFound) + case "/big.json": + _, _ = w.Write([]byte(`{"client_name":"` + strings.Repeat("a", cimdMaxBytes) + `"}`)) + case "/slow.json": + select { + case <-slow: + case <-r.Context().Done(): + } + case "/error.json": + http.Error(w, "nope", http.StatusInternalServerError) + case "/mismatch.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/other.json","client_name":"X","redirect_uris":["https://example.com/cb"]}`)) + case "/secret.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/secret.json","client_name":"X","redirect_uris":["https://example.com/cb"],"client_secret":"s"}`)) + case "/custom.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/custom.json","client_name":"X","redirect_uris":["myapp://cb"]}`)) + case "/jwt.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/jwt.json","client_name":"X","redirect_uris":["https://example.com/cb"],"token_endpoint_auth_method":"private_key_jwt"}`)) + case "/noname.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/noname.json","redirect_uris":["https://example.com/cb"]}`)) + case "/html.json": + _, _ = w.Write([]byte(``)) + case "/cached.json": + w.Header().Set("Cache-Control", "max-age=600") + _, _ = w.Write([]byte(`{"client_id":"https://example.com/cached.json","client_name":"Cached","redirect_uris":["https://example.com/cb"]}`)) + } + }) + g.s.fetch.timeout = 300 * time.Millisecond + for _, p := range []string{"redirect", "big", "slow", "error", "mismatch", "secret", "custom", "jwt", "noname", "html"} { + start := time.Now() + if _, err := g.s.fetch.get(context.Background(), "https://example.com/"+p+".json"); err == nil { + t.Errorf("%s was accepted", p) + } + if time.Since(start) > 2*time.Second { + t.Errorf("%s took %s", p, time.Since(start)) + } + } + // Failures are cached: asking again does not fetch again. + before := hits.Load() + _, _ = g.s.fetch.get(context.Background(), "https://example.com/error.json") + if hits.Load() != before { + t.Fatal("a failed fetch was not cached") + } + // And so is a good document, for as long as it says, within bounds. + for range 3 { + md, err := g.s.fetch.get(context.Background(), "https://example.com/cached.json") + if err != nil || md.ClientName != "Cached" { + t.Fatalf("cached: %v", err) + } + } + if hits.Load() != before+1 { + t.Fatalf("a cacheable document was fetched %d times", hits.Load()-before) + } + g.advance(11 * time.Minute) + _, _ = g.s.fetch.get(context.Background(), "https://example.com/cached.json") + if hits.Load() != before+2 { + t.Fatal("a document was kept past its max-age") + } +} + +func TestCacheFor(t *testing.T) { + for cc, want := range map[string]time.Duration{ + "": cimdCacheNone, + "no-store": 0, + "max-age=0": cimdCacheMin, + "public, max-age=3600": time.Hour, + "max-age=999999999": cimdCacheMax, + "max-age=x": cimdCacheNone, + } { + if got := cacheFor(cc); got != want { + t.Errorf("%q: %s, want %s", cc, got, want) + } + } +} + +// The fence uses no proxy from the environment. +func TestCIMDIgnoresProxies(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + f := newFetcher(nil) + if f.client.Transport.(*http.Transport).Proxy != nil { + t.Fatal("the fetcher reads a proxy") + } +} diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go new file mode 100644 index 0000000..92ca6f2 --- /dev/null +++ b/internal/oauth/clients.go @@ -0,0 +1,249 @@ +package oauth + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// client is a client as the authorize and token endpoints see it, from a +// metadata document or from a registration. +type client struct { + ID string + Name string + // Host is where the name came from: the host of the metadata + // document's URL, which vouches for it, or the client_uri a registration + // claimed, which does not. + Host string + Verified bool + RedirectURIs []string + AuthMethod string + secret []byte // hash, for a registered confidential client +} + +var errUnknownClient = errors.New("unknown client") + +// resolveClient finds the client a client_id names. +func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { + if isCIMD(id) { + md, err := s.fetch.get(ctx, id) + if err != nil { + return client{}, err + } + u, _ := url.Parse(id) + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: true, RedirectURIs: md.RedirectURIs, + AuthMethod: authNone}, nil + } + c, ok, err := s.st.client(id) + if err != nil { + return client{}, err + } + if !ok { + return client{}, errUnknownClient + } + host := "" + if u, err := url.Parse(c.URI); err == nil { + host = u.Hostname() + } + return client{ID: c.ID, Name: c.Name, Host: host, RedirectURIs: c.RedirectURIs, AuthMethod: c.AuthMethod, secret: c.Secret}, nil +} + +// checkRedirect holds a redirect URI to the rules: https, or http to this +// computer's loopback for a client that runs on it. No fragment and no +// credentials. Custom schemes are refused, as the MCP spec asks. +func checkRedirect(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.User != nil || u.Fragment != "" || strings.Contains(raw, "#") || u.Host == "" || len(raw) > 2048 { + return fmt.Errorf("redirect URI %q is not allowed", raw) + } + switch u.Scheme { + case "https": + return nil + case "http": + if isLoopback(u.Hostname()) { + return nil + } + } + return fmt.Errorf("redirect URI %q is neither https nor loopback http", raw) +} + +func isLoopback(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +// redirectMatches compares a redirect URI with the registered ones: exactly, +// or for a loopback URI with any port, since a client on the owner's +// computer takes whatever port is free (RFC 8252). +func redirectMatches(registered []string, given string) bool { + g, err := url.Parse(given) + if err != nil { + return false + } + for _, r := range registered { + if r == given { + return true + } + u, err := url.Parse(r) + if err != nil || u.Scheme != "http" || g.Scheme != "http" || !isLoopback(u.Hostname()) { + continue + } + if u.Hostname() == g.Hostname() && u.Path == g.Path && u.RawQuery == g.RawQuery && g.User == nil && g.Fragment == "" { + return true + } + } + return false +} + +// -------------------------------------------------------------------------- +// Dynamic registration (RFC 7591), kept for clients without metadata +// documents +// -------------------------------------------------------------------------- + +// maxClients bounds registrations kept. +const maxClients = 50_000 + +type registration struct { + RedirectURIs []string `json:"redirect_uris"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + Scope string `json:"scope"` +} + +func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { + if !s.registerLimit.allow(s.now()) { + w.Header().Set("Retry-After", "10") + registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") + return + } + var reg registration + body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10)) + if err != nil || json.Unmarshal(body, ®) != nil { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "the body is not a JSON object") + return + } + if len(reg.RedirectURIs) == 0 || len(reg.RedirectURIs) > 16 { + registerError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris must list between 1 and 16 URIs") + return + } + for _, u := range reg.RedirectURIs { + if err := checkRedirect(u); err != nil { + registerError(w, http.StatusBadRequest, "invalid_redirect_uri", err.Error()) + return + } + } + method := reg.TokenEndpointAuthMethod + if method == "" { + method = authNone + } + if method != authNone && method != authSecretPost && method != authSecretBasic { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method must be none, client_secret_post or client_secret_basic") + return + } + grants := reg.GrantTypes + if len(grants) == 0 { + grants = []string{grantCode} + } + if !subset(grants, []string{grantCode, grantRefresh}) || !subset(reg.ResponseTypes, []string{"code"}) { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "only authorization_code and refresh_token, with response type code") + return + } + for _, sc := range splitScope(reg.Scope) { + if !validScope(sc) { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "unknown scope "+sc) + return + } + } + name := cleanName(reg.ClientName) + if name == "" { + name = "An unnamed assistant" + } + if reg.ClientURI != "" { + if u, err := url.Parse(reg.ClientURI); err != nil || u.Scheme != "https" { + reg.ClientURI = "" + } + } + if s.st.countClients() >= maxClients { + registerError(w, http.StatusServiceUnavailable, "invalid_client_metadata", "registration is full") + return + } + c := dcrClient{ID: "ftwc_" + randomToken(16), AuthMethod: method, Name: name, URI: reg.ClientURI, RedirectURIs: reg.RedirectURIs} + var secret string + if method != authNone { + secret = "ftws_" + randomToken(32) + c.Secret = hash(secret) + } + if err := s.st.putClient(c, s.now()); err != nil { + registerError(w, http.StatusInternalServerError, "invalid_client_metadata", "could not register") + return + } + out := map[string]any{ + "client_id": c.ID, + "client_id_issued_at": s.now().Unix(), + "client_name": c.Name, + "redirect_uris": c.RedirectURIs, + "token_endpoint_auth_method": method, + "grant_types": grants, + "response_types": []string{"code"}, + } + if reg.ClientURI != "" { + out["client_uri"] = reg.ClientURI + } + if secret != "" { + out["client_secret"] = secret + out["client_secret_expires_at"] = 0 + } + writeJSON(w, http.StatusCreated, out, false) +} + +func registerError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}, false) +} + +// authenticateClient checks a token or revocation request's client: its id +// in the form or in Basic, and its secret if it has one. +func (s *Server) authenticateClient(r *http.Request) (client, bool) { + id, secret, basic := r.BasicAuth() + if basic { + id, _ = url.QueryUnescape(id) + secret, _ = url.QueryUnescape(secret) + } else { + id, secret = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret") + } + if id == "" { + return client{}, false + } + var c client + if isCIMD(id) { + // A metadata document's client is public; it was checked when the + // consent began, and its id is bound to the consent. + c = client{ID: id, AuthMethod: authNone} + } else { + dc, ok, err := s.st.client(id) + if err != nil || !ok { + return client{}, false + } + c = client{ID: dc.ID, AuthMethod: dc.AuthMethod, secret: dc.Secret} + } + switch c.AuthMethod { + case authNone: + return c, secret == "" + default: + if secret == "" || len(c.secret) == 0 { + return client{}, false + } + if subtle.ConstantTimeCompare(hash(secret), c.secret) != 1 { + return client{}, false + } + s.st.clientUsed(c.ID, s.now()) + return c, true + } +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 0000000..78a121f --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,453 @@ +// Package oauth is FTW Cloud's authorization server for assistants: ChatGPT, +// Claude, Grok and any MCP client. It follows the MCP authorization spec, +// revision 2026-07-28: OAuth 2.1 with PKCE S256 only, authorization server +// metadata (RFC 8414) with iss in every authorization response (RFC 9207), +// protected resource metadata for /mcp (RFC 9728), audience-bound tokens by +// the resource parameter (RFC 8707), client ID metadata documents, and +// dynamic registration (RFC 7591) kept for clients that need it. +// +// There is no account and no password. The authorize page shows a short +// code, a QR and a link to the FTW app. The owner approves in the app or on +// the box's own page, and the box lists the new agent's key in the roster it +// signs. When that roster arrives, the page, which waits on a server-sent +// event, sends the browser back to the client with a code. +// +// A token is bound to one agent of one home: (shard, home, agent id). It +// never leaves the service. The box sees the agent's own Noise session, +// opened by the keys part, and decides what each request may do. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Addresses. +const ( + // Issuer is the authorization server's identifier, and the origin of + // every endpoint. + Issuer = "https://cloud.ftw.energy" + // Resource is the MCP server's canonical URI: every token's audience. + Resource = Issuer + "/mcp" + // AppLink is where the QR and the button send the owner: a universal + // link that opens the FTW app, followed by the request id. + AppLink = "https://app.ftw.energy/a/" + // AppOrigin may read a request's details from a browser. + AppOrigin = "https://app.ftw.energy" +) + +// Lifetimes. +const ( + // RequestTTL is how long a consent waits for the owner. + RequestTTL = 10 * time.Minute + // CodeTTL is how long an authorization code lasts. + CodeTTL = time.Minute + // AccessTTL is an access token's life. Short: a token that leaks is + // worth little, and a revoked agent loses access at its box anyway. + AccessTTL = 10 * time.Minute + // RefreshTTL caps a refresh token, and an agent's own end caps it + // sooner. + RefreshTTL = 30 * 24 * time.Hour +) + +// Scopes. Each is a right the box grants an agent, by its registry name; a +// token carries those the client asked for and the box granted. +// +// The first five are in contract/registry.yaml today. The rest are the +// agent rights decided on 15 October 2026 and wait for their registry names +// (the core-agent-role contract); they move to protocol constants when it +// lands. +const ( + ScopeLiveRead = protocol.ScopeLiveRead + ScopePlanRead = protocol.ScopePlanRead + ScopeHistoryRead = protocol.ScopeHistoryRead + ScopeHealthRead = protocol.ScopeHealthRead + ScopeEventsRead = protocol.ScopeEventsRead + ScopeLogsRead = "ftw.logs.read" + ScopeConfigRead = "ftw.config.read" + ScopeDiagRun = "ftw.diag.run" + ScopeDispatchHold = "ftw.dispatch.hold" + ScopeGoalsRequest = "ftw.goals.request" +) + +// ReadScopes are what an agent gets by default. +var ReadScopes = []string{ScopeLiveRead, ScopePlanRead, ScopeHistoryRead, ScopeHealthRead, ScopeEventsRead, + ScopeLogsRead, ScopeConfigRead, ScopeDiagRun} + +// ControlScopes need the owner to tick control. +var ControlScopes = []string{ScopeDispatchHold, ScopeGoalsRequest} + +// AllScopes is every scope a client may ask for. +var AllScopes = append(slices.Clone(ReadScopes), ControlScopes...) + +// scopeWords says what each scope lets an assistant do, for the consent +// page. +var scopeWords = map[string]string{ + ScopeLiveRead: "See power, battery and the car right now", + ScopePlanRead: "See the plan and why it looks that way", + ScopeHistoryRead: "See past readings", + ScopeHealthRead: "See how the box and its devices are doing", + ScopeEventsRead: "See alerts and events", + ScopeLogsRead: "Read the box's logs, with secrets removed", + ScopeConfigRead: "Read the settings, with secrets removed", + ScopeDiagRun: "Run checks that change nothing", + ScopeDispatchHold: "Hold the battery or charge the car for a short, capped time", + ScopeGoalsRequest: "Ask for changes to goals and schedules, which wait for your yes", +} + +// Client authentication methods and grants. +const ( + authNone = "none" + authSecretPost = "client_secret_post" + authSecretBasic = "client_secret_basic" + grantCode = "authorization_code" + grantRefresh = "refresh_token" +) + +// AgentKeys is the keys part, as the authorization server uses it. +type AgentKeys interface { + New(ctx context.Context) (kid string, pub []byte, err error) + Keep(ctx context.Context, kid string) error + Forget(ctx context.Context, kid string) error +} + +// Server is the authorization server. +type Server struct { + st *store + keys AgentKeys + fetch *fetcher + log *slog.Logger + now func() time.Time + hub hub + + authorizeLimit, registerLimit, lookupLimit limiter + + roster chan rosterEvent + + // Shard names the shard a home lives on. Nil is one shard, 0. + Shard func(home string) int + // AgentExpiry reads when the box ends an agent, from its roster entry. + // Nil, or a zero time, is no end the service knows of; the box still + // enforces its own. + AgentExpiry func(d cloudwire.Device) time.Time + // 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) +} + +// New opens the authorization server's state at path. +func New(path string, keys AgentKeys, log *slog.Logger) (*Server, error) { + return newServer(path, keys, log, nil) +} + +func newServer(path string, keys AgentKeys, log *slog.Logger, roots *x509.CertPool) (*Server, error) { + if log == nil { + log = slog.Default() + } + st, err := openStore(path) + if err != nil { + return nil, err + } + s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, + authorizeLimit: newLimiter(30, 1), registerLimit: newLimiter(20, 0.2), lookupLimit: newLimiter(20, 5), + roster: make(chan rosterEvent, 1024)} + return s, nil +} + +// Close closes the state. +func (s *Server) Close() error { return s.st.close() } + +// Routes are the paths the authorization server answers, for the server's +// list. +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", +} + +// Mount adds the authorization server's routes to mux. +func (s *Server) Mount(mux *http.ServeMux) { + mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.serveMetadata) + mux.HandleFunc("GET /.well-known/oauth-protected-resource", s.serveResourceMetadata) + mux.HandleFunc("GET /.well-known/oauth-protected-resource/mcp", s.serveResourceMetadata) + mux.HandleFunc("GET /oauth/authorize", s.serveAuthorize) + mux.HandleFunc("POST /oauth/token", s.serveToken) + mux.HandleFunc("POST /oauth/register", s.serveRegister) + mux.HandleFunc("POST /oauth/revoke", s.serveRevoke) + mux.HandleFunc("GET /oauth/requests", s.serveRequestByCode) + 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) +} + +// -------------------------------------------------------------------------- +// Metadata +// -------------------------------------------------------------------------- + +// ResourceMetadataURL is where /mcp's protected resource metadata lives, +// named in every challenge. +const ResourceMetadataURL = Issuer + "/.well-known/oauth-protected-resource/mcp" + +func (s *Server) serveMetadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": Issuer, + "authorization_endpoint": Issuer + "/oauth/authorize", + "token_endpoint": Issuer + "/oauth/token", + "registration_endpoint": Issuer + "/oauth/register", + "revocation_endpoint": Issuer + "/oauth/revoke", + "response_types_supported": []string{"code"}, + "response_modes_supported": []string{"query"}, + "grant_types_supported": []string{grantCode, grantRefresh}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{authNone, authSecretPost, authSecretBasic}, + "revocation_endpoint_auth_methods_supported": []string{authNone, authSecretPost, authSecretBasic}, + "scopes_supported": AllScopes, + "client_id_metadata_document_supported": true, + "authorization_response_iss_parameter_supported": true, + "service_documentation": "https://github.com/srcfl/ftw-cloud/blob/main/README.md", + }, true) +} + +func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "resource": Resource, + "authorization_servers": []string{Issuer}, + "scopes_supported": ReadScopes, + "bearer_methods_supported": []string{"header"}, + "resource_name": "FTW Cloud", + "resource_documentation": "https://github.com/srcfl/ftw-cloud/blob/main/README.md", + }, true) +} + +// -------------------------------------------------------------------------- +// The box's roster +// -------------------------------------------------------------------------- + +type rosterEvent struct { + home string + devices []cloudwire.Device +} + +// Roster hears each roster the service keeps. A device whose key a pending +// consent made is that consent's approval; an agent the roster no longer +// lists is revoked. The work happens on Run's goroutine, in order, since +// this runs on the box's. +func (s *Server) Roster(home string, r *cloudwire.Roster) { + ev := rosterEvent{home: home, devices: slices.Clone(r.Devices)} + select { + case s.roster <- ev: + default: + // The next roster carries the same devices. + s.log.Warn("roster queue full; an agent change waits for the next roster") + } +} + +// Run applies rosters and sweeps until ctx ends. +func (s *Server) Run(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case ev := <-s.roster: + s.applyRoster(ctx, ev) + case <-t.C: + s.sweep(ctx) + } + } +} + +func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { + now := s.now() + listed := map[string]cloudwire.Device{} + for _, d := range ev.devices { + if d.Tun { + listed[d.ID] = d + } + } + // Approvals: a pending consent's key in the box's signed roster. + for _, d := range listed { + ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) + if err != nil || !ok { + continue + } + if cloudwire.DeviceID(d.Key) != d.ID { + continue + } + if err := s.keys.Keep(ctx, c.Kid); err != nil { + s.log.Warn("an approved agent's key could not be kept; the next roster tries again", "err", err) + continue + } + c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) + c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Scopes), " ") + if err := s.st.approve(ridHash, c, s.expiry(d), now); err != nil { + continue + } + s.hub.notify(string(ridHash)) + } + // Revocations: an agent the roster no longer lists, or lists with + // another key. + agents, err := s.st.agentsOf(ev.home) + if err != nil { + return + } + for _, a := range agents { + if d, ok := listed[a.Agent]; ok && string(d.Key) == string(a.Key) { + continue + } + s.revoke(ctx, a) + } +} + +func (s *Server) revoke(ctx context.Context, a agentRow) { + if err := s.st.dropAgent(a.Home, a.Agent); err != nil { + s.log.Warn("could not drop an agent", "err", err) + return + } + if err := s.keys.Forget(ctx, a.Kid); err != nil { + s.log.Warn("the keys part could not forget an agent's key", "err", err) + } + if s.OnRevoke != nil { + s.OnRevoke(a.Home, a.Agent) + } +} + +func (s *Server) sweep(ctx context.Context) { + now := s.now() + kids, err := s.st.sweep(now) + if err != nil { + s.log.Warn("oauth upkeep failed", "err", err) + } + for _, kid := range kids { + _ = s.keys.Forget(ctx, kid) + } + gone, err := s.st.expiredAgents(now) + if err == nil { + for _, a := range gone { + s.revoke(ctx, a) + } + } +} + +func (s *Server) shard(home string) int { + if s.Shard == nil { + return 0 + } + return s.Shard(home) +} + +func (s *Server) expiry(d cloudwire.Device) time.Time { + if s.AgentExpiry == nil { + return time.Time{} + } + return s.AgentExpiry(d) +} + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +// hub wakes the event streams waiting on a consent. +type hub struct { + mu sync.Mutex + subs map[string]map[chan struct{}]struct{} +} + +func (h *hub) subscribe(ridHash string) (chan struct{}, func()) { + ch := make(chan struct{}, 1) + h.mu.Lock() + if h.subs[ridHash] == nil { + h.subs[ridHash] = map[chan struct{}]struct{}{} + } + h.subs[ridHash][ch] = struct{}{} + h.mu.Unlock() + return ch, func() { + h.mu.Lock() + delete(h.subs[ridHash], ch) + if len(h.subs[ridHash]) == 0 { + delete(h.subs, ridHash) + } + h.mu.Unlock() + } +} + +func (h *hub) notify(ridHash string) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs[ridHash] { + select { + case ch <- struct{}{}: + default: + } + } +} + +// limiter is a token bucket shared by every caller of one endpoint. It +// bounds what strangers can make the service do; it names nobody. +type limiter struct { + mu sync.Mutex + tokens, burst float64 + rate float64 + at time.Time +} + +func newLimiter(burst, perSecond float64) limiter { + return limiter{tokens: burst, burst: burst, rate: perSecond} +} + +func (l *limiter) allow(now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + if !l.at.IsZero() { + l.tokens = min(l.burst, l.tokens+now.Sub(l.at).Seconds()*l.rate) + } + l.at = now + if l.tokens < 1 { + return false + } + l.tokens-- + return true +} + +func randomToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v any, cache bool) { + w.Header().Set("Content-Type", "application/json") + if cache { + w.Header().Set("Cache-Control", "public, max-age=3600") + } else { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func intersect(a, b []string) []string { + var out []string + for _, x := range a { + if slices.Contains(b, x) && !slices.Contains(out, x) { + out = append(out, x) + } + } + return out +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..f091e35 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,878 @@ +package oauth + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// rig is the authorization server with a real keys part behind it, a +// client's metadata document on its own https server, and a clock. +type rig struct { + t *testing.T + s *Server + keys *keys.Client + srv *httptest.Server + cimd *httptest.Server + docs map[string]string + docsMu sync.Mutex + clock struct { + sync.Mutex + now time.Time + } +} + +const ( + cimdID = "https://example.com/oauth/client.json" + cimdBack = "https://example.com/callback" + home = "homehomehomehomehomehomeho" +) + +func newRig(t *testing.T) *rig { + t.Helper() + g := &rig{t: t, docs: map[string]string{}} + g.clock.now = time.Now() + ks, err := keys.NewServer(t.TempDir(), mustFileSealer(t), nil) + if err != nil { + t.Fatal(err) + } + g.keys = ks.Local() + + g.docs["/oauth/client.json"] = `{"client_id":"` + cimdID + `","client_name":"Claude","redirect_uris":["` + cimdBack + `","http://127.0.0.1/cb"]}` + g.cimd = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.docsMu.Lock() + doc, ok := g.docs[r.URL.Path] + g.docsMu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, doc) + })) + t.Cleanup(g.cimd.Close) + pool := x509.NewCertPool() + pool.AddCert(g.cimd.Certificate()) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + g.s, err = newServer(filepath.Join(t.TempDir(), "oauth.db"), g.keys, log, pool) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = g.s.Close() }) + g.s.now = g.now + g.s.fetch.now = g.now + // example.com is the test server, on loopback, which the fence would + // refuse: only this rig lets it through. + g.s.fetch.resolve = func(_ context.Context, host string) ([]netip.Addr, error) { + if host == "example.com" { + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil + } + return nil, errors.New("no such host") + } + g.s.fetch.public = func(a netip.Addr) bool { return a == netip.MustParseAddr("127.0.0.1") || publicAddr(a) } + g.s.fetch.dial = func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, g.cimd.Listener.Addr().String()) + } + mux := http.NewServeMux() + g.s.Mount(mux) + g.srv = httptest.NewServer(mux) + t.Cleanup(g.srv.Close) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go g.s.Run(ctx) + return g +} + +func mustFileSealer(t *testing.T) keys.Sealer { + s, err := keys.FileSealerFromKey(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + return s +} + +func (g *rig) now() time.Time { + g.clock.Lock() + defer g.clock.Unlock() + return g.clock.now +} + +func (g *rig) advance(d time.Duration) { + g.clock.Lock() + g.clock.now = g.clock.now.Add(d) + g.clock.Unlock() +} + +var noFollow = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +func pkce() (verifier, challenge string) { + verifier = randomToken(32) + sum := sha256.Sum256([]byte(verifier)) + return verifier, base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// flow is one authorization in progress, as the client and its browser hold +// it. +type flow struct { + verifier, state string + rid string + cookie *http.Cookie + view requestView +} + +func (g *rig) authorizeParams(clientID, redirect, scope string) (url.Values, *flow) { + verifier, challenge := pkce() + f := &flow{verifier: verifier, state: randomToken(8)} + q := url.Values{"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirect}, + "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {f.state}, "resource": {Resource}} + if scope != "" { + q.Set("scope", scope) + } + return q, f +} + +var ridRE = regexp.MustCompile(`data-rid="([A-Za-z0-9_-]+)"`) + +// start opens the authorize page and reads what the app would. +func (g *rig) start(clientID, redirect, scope string) *flow { + g.t.Helper() + q, f := g.authorizeParams(clientID, redirect, scope) + resp, err := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + if err != nil { + g.t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + g.t.Fatalf("authorize: %d %s %s", resp.StatusCode, resp.Header.Get("Location"), body) + } + m := ridRE.FindSubmatch(body) + if m == nil { + g.t.Fatal("no request id on the page") + } + f.rid = string(m[1]) + for _, c := range resp.Cookies() { + if c.Name == cookieName(f.rid) { + f.cookie = c + } + } + if f.cookie == nil || !f.cookie.Secure || !f.cookie.HttpOnly { + g.t.Fatalf("the browser's cookie: %+v", f.cookie) + } + f.view = g.details(f.rid) + return f +} + +func (g *rig) details(rid string) requestView { + g.t.Helper() + resp, err := http.Get(g.srv.URL + "/oauth/requests/" + rid) + if err != nil { + g.t.Fatal(err) + } + defer resp.Body.Close() + var v requestView + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&v) != nil { + g.t.Fatalf("request details: %d", resp.StatusCode) + } + return v +} + +func (f *flow) agentKey(t *testing.T) []byte { + k, err := base64.RawURLEncoding.DecodeString(f.view.Agent.Key) + if err != nil || len(k) != 32 { + t.Fatalf("agent key %q", f.view.Agent.Key) + } + return k +} + +type event struct{ name, data string } + +// events opens the page's stream, as the browser does (with its cookie) or +// as anyone else could (without). +func (g *rig) events(f *flow, withCookie bool) (<-chan event, func()) { + g.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, g.srv.URL+"/oauth/requests/"+f.rid+"/events", nil) + if withCookie { + req.AddCookie(f.cookie) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + g.t.Fatal(err) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + g.t.Fatalf("event stream content type %q", ct) + } + ch := make(chan event, 16) + go func() { + defer close(ch) + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + var ev event + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event: "): + ev.name = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + ev.data = strings.TrimPrefix(line, "data: ") + case line == "" && ev.name != "": + ch <- ev + ev = event{} + } + } + }() + return ch, cancel +} + +func next(t *testing.T, ch <-chan event) event { + t.Helper() + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("the stream ended") + } + return ev + case <-time.After(3 * time.Second): + t.Fatal("no event") + } + return event{} +} + +// roster is the box's signed roster arriving, as the uplink hands it on +// after checking the box's signature. +func (g *rig) roster(h string, devs ...cloudwire.Device) { + g.s.Roster(h, &cloudwire.Roster{RosterSeq: 1, TTLS: 86400, Devices: devs}) + // Run applies it on its own goroutine; a request on the store after + // this waits for it by draining the queue. + deadline := time.Now().Add(2 * time.Second) + for len(g.s.roster) > 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + time.Sleep(20 * time.Millisecond) +} + +func agentDevice(key []byte, scopes ...string) cloudwire.Device { + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: "agent", Scopes: scopes, Epoch: 1, Tun: true} +} + +// redirectOf reads the client's redirect from an event. +func redirectOf(t *testing.T, ev event) *url.URL { + t.Helper() + var d struct{ Redirect string } + if json.Unmarshal([]byte(ev.data), &d) != nil || d.Redirect == "" { + t.Fatalf("event %s carries no redirect: %s", ev.name, ev.data) + } + u, err := url.Parse(d.Redirect) + if err != nil { + t.Fatal(err) + } + return u +} + +type tokens struct { + Access string `json:"access_token"` + Type string `json:"token_type"` + Expires int `json:"expires_in"` + Refresh string `json:"refresh_token"` + Scope string `json:"scope"` + Error string `json:"error"` +} + +func (g *rig) token(form url.Values, basic ...string) (int, tokens) { + g.t.Helper() + req, _ := http.NewRequest(http.MethodPost, g.srv.URL+"/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if len(basic) == 2 { + req.SetBasicAuth(basic[0], basic[1]) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + g.t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Cache-Control") != "no-store" { + g.t.Fatal("a token response may be cached") + } + var tk tokens + _ = json.NewDecoder(resp.Body).Decode(&tk) + return resp.StatusCode, tk +} + +// approve runs a whole consent: the box lists the agent, the page gets the +// code, the client swaps it. +func (g *rig) approve(f *flow, clientID string, scopes ...string) tokens { + g.t.Helper() + ch, stop := g.events(f, true) + defer stop() + if ev := next(g.t, ch); ev.name != "pending" { + g.t.Fatalf("first event %q", ev.name) + } + if len(scopes) == 0 { + scopes = []string{ScopeLiveRead, ScopePlanRead} + } + g.roster(home, agentDevice(f.agentKey(g.t), scopes...)) + ev := next(g.t, ch) + if ev.name != "approved" { + g.t.Fatalf("event %q after the roster", ev.name) + } + u := redirectOf(g.t, ev) + if u.Query().Get("iss") != Issuer || u.Query().Get("state") != f.state || u.Query().Get("code") == "" { + g.t.Fatalf("redirect %s", u) + } + status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {u.Query().Get("code")}, "client_id": {clientID}, + "code_verifier": {f.verifier}, "redirect_uri": {f.view.redirectURIForTest()}, "resource": {Resource}}) + if status != http.StatusOK || tk.Access == "" || tk.Refresh == "" || tk.Type != "Bearer" { + g.t.Fatalf("token: %d %+v", status, tk) + } + return tk +} + +// redirectURIForTest is the redirect the test flows use; requestView shows +// only its host. +func (v requestView) redirectURIForTest() string { + if v.Redirect.Local { + return "http://127.0.0.1/cb" + } + return cimdBack +} + +func (g *rig) bearer(tok string) (*Grant, *AuthError) { + r := httptest.NewRequest(http.MethodPost, "/mcp", nil) + r.Header.Set("Authorization", "Bearer "+tok) + return g.s.Authenticate(r) +} + +// --------------------------------------------------------------------------- + +func TestMetadataNamesTheIssuerAndTheResource(t *testing.T) { + g := newRig(t) + var as map[string]any + getJSON(t, g.srv.URL+"/.well-known/oauth-authorization-server", &as) + if as["issuer"] != Issuer || as["authorization_response_iss_parameter_supported"] != true || + as["client_id_metadata_document_supported"] != true || as["registration_endpoint"] != Issuer+"/oauth/register" { + t.Fatalf("authorization server metadata: %v", as) + } + if m := as["code_challenge_methods_supported"].([]any); len(m) != 1 || m[0] != "S256" { + t.Fatalf("PKCE methods %v", m) + } + for _, p := range []string{"/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"} { + var pr map[string]any + getJSON(t, g.srv.URL+p, &pr) + if pr["resource"] != Resource || pr["authorization_servers"].([]any)[0] != Issuer { + t.Fatalf("%s: %v", p, pr) + } + } +} + +func getJSON(t *testing.T, u string, v any) { + t.Helper() + resp, err := http.Get(u) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(v) != nil { + t.Fatalf("GET %s: %d", u, resp.StatusCode) + } +} + +// The whole flow with a client ID metadata document and PKCE, then refresh +// with rotation. +func TestTheFullFlowWithAMetadataDocument(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead+" "+ScopeHistoryRead) + if f.view.Client.Name != "Claude" || !f.view.Client.Verified || f.view.Client.Host != "example.com" || + f.view.Redirect.Host != "example.com" || f.view.Status != statusPending || len(f.view.Code) != codeLen { + t.Fatalf("what the app sees: %+v", f.view) + } + // The box grants less than asked: the token says so. + tk := g.approve(f, cimdID, ScopeLiveRead, ScopePlanRead) + if tk.Scope != ScopeLiveRead+" "+ScopePlanRead || tk.Expires <= 0 || tk.Expires > int(AccessTTL.Seconds()) { + t.Fatalf("token: %+v", tk) + } + grant, aerr := g.bearer(tk.Access) + if aerr != nil { + t.Fatalf("a fresh token was refused: %+v", aerr) + } + key := f.agentKey(t) + if grant.Subject.Home != home || grant.Subject.Agent != cloudwire.DeviceID(key) || !bytes.Equal(grant.Subject.Key, key) || + grant.Subject.Kind != SubjectAgent || grant.ClientID != cimdID || !grant.Has(ScopePlanRead) || grant.Has(ScopeHistoryRead) { + t.Fatalf("grant: %+v", grant) + } + + // Refresh rotates: the new pair works, the old refresh token is spent, + // and using it again revokes the whole family. + status, tk2 := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, "resource": {Resource}}) + if status != http.StatusOK || tk2.Refresh == tk.Refresh { + t.Fatalf("refresh: %d %+v", status, tk2) + } + if _, aerr := g.bearer(tk2.Access); aerr != nil { + t.Fatal("the refreshed token was refused") + } + status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}) + if status != http.StatusBadRequest { + t.Fatalf("a spent refresh token: %d", status) + } + if _, aerr := g.bearer(tk2.Access); aerr == nil { + t.Fatal("a reused refresh token left its family alive") + } + status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk2.Refresh}, "client_id": {cimdID}}) + if status != http.StatusBadRequest { + t.Fatalf("the family's newest refresh token: %d", status) + } +} + +// A code works once; presenting it again revokes what it got. +func TestACodeWorksOnce(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}} + status, tk := g.token(form) + if status != http.StatusOK { + t.Fatalf("first use: %d", status) + } + if status, _ := g.token(form); status != http.StatusBadRequest { + t.Fatalf("second use: %d", status) + } + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("tokens from a reused code still work") + } +} + +// PKCE: the verifier must match, and nothing but S256 starts a flow. +func TestPKCEIsRequired(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(cimdID, cimdBack, "") + for _, change := range []func(url.Values){ + func(q url.Values) { q.Del("code_challenge") }, + func(q url.Values) { q.Set("code_challenge_method", "plain") }, + func(q url.Values) { q.Del("code_challenge_method") }, + func(q url.Values) { q.Set("code_challenge", "short") }, + } { + qq := url.Values{} + for k, v := range q { + qq[k] = slices.Clone(v) + } + change(qq) + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + qq.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_request" || loc.Query().Get("iss") != Issuer { + t.Fatalf("without S256 PKCE: %d %s", resp.StatusCode, loc) + } + } + + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + wrong, _ := pkce() + if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}}); status != http.StatusBadRequest { + t.Fatalf("a wrong verifier: %d", status) + } +} + +// Before client and redirect URI check out, nothing redirects; after, every +// error goes back with iss. +func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { + g := newRig(t) + for _, q := range []url.Values{ + {"client_id": {"ftwc_nobody"}, "redirect_uri": {"https://evil.example/cb"}}, + {"client_id": {cimdID}, "redirect_uri": {"https://evil.example/cb"}}, + {"client_id": {cimdID}, "redirect_uri": {"https://example.com/callback/../x"}}, + {"client_id": {"https://example.com/nothing-here.json"}, "redirect_uri": {cimdBack}}, + } { + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" { + t.Fatalf("%v: %d %q", q, resp.StatusCode, resp.Header.Get("Location")) + } + } + q, _ := g.authorizeParams(cimdID, cimdBack, "") + q.Set("resource", "https://cloud.ftw.energy/other") + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if loc.Query().Get("error") != "invalid_target" || loc.Query().Get("iss") != Issuer || loc.Host != "example.com" { + t.Fatalf("another resource: %s", loc) + } + q.Set("resource", Resource) + q.Set("scope", "ftw.live.read admin") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ = url.Parse(resp.Header.Get("Location")) + if loc.Query().Get("error") != "invalid_scope" { + t.Fatalf("an unknown scope: %s", loc) + } + // A loopback redirect may take any port. + q2, _ := g.authorizeParams(cimdID, "http://127.0.0.1:53121/cb", "") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q2.Encode()) + if resp.StatusCode != http.StatusOK { + t.Fatalf("loopback with a port: %d", resp.StatusCode) + } +} + +// Dynamic registration, for clients without a metadata document: public +// and confidential. +func TestTheFullFlowWithDynamicRegistration(t *testing.T) { + g := newRig(t) + register := func(body string) (int, map[string]any) { + resp, err := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out + } + for _, bad := range []string{ + `{"redirect_uris":["myapp://cb"]}`, + `{"redirect_uris":["http://evil.example/cb"]}`, + `{"redirect_uris":[]}`, + `{"redirect_uris":["https://chatgpt.com/cb"],"token_endpoint_auth_method":"private_key_jwt"}`, + `{"redirect_uris":["https://chatgpt.com/cb"],"grant_types":["client_credentials"]}`, + `not json`, + } { + if status, out := register(bad); status != http.StatusBadRequest { + t.Fatalf("%s: %d %v", bad, status, out) + } + } + + status, pub := register(`{"client_name":"Grok‮","redirect_uris":["https://grok.example/cb"]}`) + if status != http.StatusCreated || pub["client_secret"] != nil || pub["client_name"] != "Grok" { + t.Fatalf("public registration: %d %v", status, pub) + } + id := pub["client_id"].(string) + f := g.start(id, "https://grok.example/cb", "") + if f.view.Client.Verified || f.view.Client.Name != "Grok" { + t.Fatalf("a registered client's name is not checked: %+v", f.view.Client) + } + ch, stop := g.events(f, true) + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + stop() + if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { + t.Fatalf("public client token: %d %+v", status, tk) + } + + status, conf := register(`{"client_name":"ChatGPT","redirect_uris":["https://chatgpt.com/connector_platform_oauth_redirect"],"token_endpoint_auth_method":"client_secret_post","grant_types":["authorization_code","refresh_token"]}`) + if status != http.StatusCreated || conf["client_secret"] == nil { + t.Fatalf("confidential registration: %d %v", status, conf) + } + cid, secret := conf["client_id"].(string), conf["client_secret"].(string) + f = g.start(cid, "https://chatgpt.com/connector_platform_oauth_redirect", "") + ch, stop = g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code = redirectOf(t, next(t, ch)).Query().Get("code") + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} + if status, _ := g.token(form); status != http.StatusUnauthorized { + t.Fatalf("no secret: %d", status) + } + form.Set("client_secret", "ftws_wrong") + if status, _ := g.token(form); status != http.StatusUnauthorized { + t.Fatalf("a wrong secret: %d", status) + } + form.Set("client_secret", secret) + if status, tk := g.token(form); status != http.StatusOK { + t.Fatalf("the right secret: %d %+v", status, tk) + } +} + +// The page finishes only when the box's roster lists the very key this +// request made, as a device that may open a session; and only the browser +// that started it gets the code. +func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + browser, stop := g.events(f, true) + defer stop() + stranger, stop2 := g.events(f, false) + defer stop2() + if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { + t.Fatal("not pending at first") + } + // Another key, the right key without a tunnel, the right key under + // another id: none of them approves. + other := bytes.Repeat([]byte{1}, 32) + key := f.agentKey(t) + noTun := agentDevice(key, ScopeLiveRead) + noTun.Tun = false + wrongID := agentDevice(key, ScopeLiveRead) + wrongID.ID = "someone" + g.roster(home, agentDevice(other, ScopeLiveRead), noTun, wrongID) + select { + case ev := <-browser: + t.Fatalf("event %q before the roster listed the agent", ev.name) + case <-time.After(100 * time.Millisecond): + } + if g.details(f.rid).Status != statusPending { + t.Fatal("approved without the agent in the roster") + } + + g.roster(home, agentDevice(key, ScopeLiveRead)) + if ev := next(t, stranger); ev.name != "approved" || strings.Contains(ev.data, "code") { + t.Fatalf("a stranger's stream got %q %s", ev.name, ev.data) + } + if ev := next(t, browser); ev.name != "approved" || redirectOf(t, ev).Query().Get("code") == "" { + t.Fatal("the browser got no code") + } +} + +// The owner says no, or the request runs out: the page goes back with +// access_denied, and the key is forgotten. +func TestDeniedAndExpiredRequestsEnd(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + resp, _ := http.Post(g.srv.URL+"/oauth/requests/"+f.rid+"/deny", "", nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("deny: %d", resp.StatusCode) + } + ev := next(t, ch) + if u := redirectOf(t, ev); ev.name != "denied" || u.Query().Get("error") != "access_denied" || u.Query().Get("iss") != Issuer { + t.Fatalf("after deny: %s %s", ev.name, ev.data) + } + // A roster that lists it now is too late. + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + if v := g.details(f.rid); v.Status != statusDenied { + t.Fatalf("status after deny and roster: %s", v.Status) + } + + f2 := g.start(cimdID, cimdBack, "") + g.advance(RequestTTL + time.Second) + ch2, stop2 := g.events(f2, true) + defer stop2() + if ev := next(t, ch2); ev.name != "expired" || redirectOf(t, ev).Query().Get("error") != "access_denied" { + t.Fatalf("expired: %s %s", ev.name, ev.data) + } + g.roster(home, agentDevice(f2.agentKey(t), ScopeLiveRead)) + if v := g.details(f2.rid); v.Status != "expired" { + t.Fatalf("an expired request was approved: %s", v.Status) + } +} + +// The owner revokes at the box: the next roster leaves the agent out, its +// tokens stop working at once, its refresh token too, and the keys part +// forgets its key. +func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { + g := newRig(t) + var revoked []string + var mu sync.Mutex + g.s.OnRevoke = func(h, agent string) { + mu.Lock() + revoked = append(revoked, agent) + mu.Unlock() + } + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + grant, aerr := g.bearer(tk.Access) + if aerr != nil { + t.Fatal(aerr) + } + // The keys part holds the key: a session opens. + if _, _, err := g.keys.Open(context.Background(), grant.Subject.Kid, bytes.Repeat([]byte{2}, 32)); err != nil { + t.Fatalf("an approved key does not open: %v", err) + } + + g.roster(home) // the agent is gone + if _, aerr := g.bearer(tk.Access); aerr == nil || aerr.Status != http.StatusUnauthorized || aerr.Code != "invalid_token" { + t.Fatalf("after revoke: %+v", aerr) + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("refresh after revoke: %d", status) + } + if _, _, err := g.keys.Open(context.Background(), grant.Subject.Kid, bytes.Repeat([]byte{2}, 32)); !errors.Is(err, keys.ErrUnknownKey) { + t.Fatalf("the key outlived its agent: %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(revoked) != 1 || revoked[0] != grant.Subject.Agent { + t.Fatalf("revoked %v", revoked) + } +} + +// An agent whose time the box set runs out fails, and is swept. +func TestAnExpiredAgentFails(t *testing.T) { + g := newRig(t) + end := g.now().Add(2 * time.Hour) + g.s.AgentExpiry = func(cloudwire.Device) time.Time { return end } + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + if _, aerr := g.bearer(tk.Access); aerr != nil { + t.Fatal(aerr) + } + g.advance(2*time.Hour + time.Second) + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("an expired agent's token works") + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("refresh after the agent ended: %d", status) + } + g.s.sweep(context.Background()) + if _, ok, _ := g.s.st.agent(home, cloudwire.DeviceID(f.agentKey(t))); ok { + t.Fatal("the sweep left an expired agent") + } +} + +// A token for another resource is refused at /mcp, whatever else is right +// about it; and so is a token that is not an access token. +func TestWrongAudienceIsRefused(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + agent := cloudwire.DeviceID(f.agentKey(t)) + other := "ftwat_" + randomToken(32) + if err := g.s.st.putToken(other, tokenRow{Kind: kindAccess, Family: []byte("f"), ClientID: cimdID, Home: home, + Agent: agent, Scope: ScopeLiveRead, Resource: "https://cloud.ftw.energy/other", Expires: g.now().Add(time.Minute)}); err != nil { + t.Fatal(err) + } + if _, aerr := g.bearer(other); aerr == nil || aerr.Status != http.StatusUnauthorized { + t.Fatal("a token for another resource was accepted") + } + if _, aerr := g.bearer(tk.Refresh); aerr == nil { + t.Fatal("a refresh token was accepted as an access token") + } + status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, + "resource": {"https://elsewhere.example/mcp"}}) + if status != http.StatusBadRequest { + t.Fatalf("refresh for another resource: %d", status) + } + // A token in the query string is not read. + r := httptest.NewRequest(http.MethodPost, "/mcp?access_token="+tk.Access, nil) + if _, aerr := g.s.Authenticate(r); aerr == nil { + t.Fatal("a token in the query string was read") + } +} + +// The challenges: 401 names the resource metadata and the scopes; 403 asks +// for the scope the operation needs. +func TestChallenges(t *testing.T) { + w := httptest.NewRecorder() + Challenge(w, &AuthError{Status: http.StatusUnauthorized, Scope: ScopeLiveRead}) + h := w.Header().Get("WWW-Authenticate") + if w.Code != 401 || !strings.HasPrefix(h, "Bearer ") || !strings.Contains(h, `resource_metadata="`+ResourceMetadataURL+`"`) || + !strings.Contains(h, `scope="ftw.live.read"`) { + t.Fatalf("401: %d %s", w.Code, h) + } + w = httptest.NewRecorder() + Challenge(w, InsufficientScope(ScopeDispatchHold)) + h = w.Header().Get("WWW-Authenticate") + if w.Code != 403 || !strings.Contains(h, `error="insufficient_scope"`) || !strings.Contains(h, `scope="ftw.dispatch.hold"`) || + !strings.Contains(h, "resource_metadata=") { + t.Fatalf("403: %d %s", w.Code, h) + } +} + +// The box's page finds a request by its code, as an owner types it. +func TestTheBoxPageFindsARequestByItsCode(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + typed := strings.ToLower(f.view.Code[:3]) + " - " + f.view.Code[3:] + var v requestView + getJSON(t, g.srv.URL+"/oauth/requests?code="+url.QueryEscape(typed), &v) + if v.Agent.Key != f.view.Agent.Key || v.Request != "" { + t.Fatalf("by code: %+v", v) + } + resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("an unknown code: %d", resp.StatusCode) + } + // Lookups are rate limited for everyone together. + limited := false + for range 60 { + resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") + if resp.StatusCode == http.StatusTooManyRequests { + limited = true + break + } + } + if !limited { + t.Fatal("code lookups are not limited") + } +} + +// The page says who is asking, where the browser goes back, and that a +// loopback redirect is a program on this computer; and it carries a QR and +// the app link, under a strict content security policy. +func TestTheConsentPage(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(cimdID, "http://127.0.0.1:8123/cb", ScopeLiveRead+" "+ScopeDispatchHold) + 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?", "Identified by example.com", "127.0.0.1:8123", + "a program on this computer", "alert(1)","redirect_uris":["https://example.com/cb"]}` + g.docsMu.Unlock() + q, _ = g.authorizeParams("https://example.com/oauth/evil.json", "https://example.com/cb", "") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + body, _ = io.ReadAll(resp.Body) + if bytes.Contains(body, []byte(" +`)) + +var errorTmpl = template.Must(template.New("error").Parse(` + +Connect an assistant · FTW Cloud +

{{.Title}}

{{.Text}}

`)) diff --git a/internal/oauth/store.go b/internal/oauth/store.go new file mode 100644 index 0000000..92790b5 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,512 @@ +package oauth + +import ( + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// The authorization server's disk state, in a SQLite file of its own: +// clients that registered dynamically, consents in progress, the agents +// owners approved, and the hashes of tokens. No token, code or request id is +// stored as itself: each is a random secret held by its bearer, and the file +// keeps its SHA-256. Deleted rows are overwritten (secure_delete). + +type store struct { + db *sql.DB +} + +func openStore(path string) (*store, error) { + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=secure_delete(ON)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + _, err = db.Exec(` +CREATE TABLE IF NOT EXISTS clients ( + client_id TEXT PRIMARY KEY, + secret BLOB, + auth_method TEXT NOT NULL, + name TEXT NOT NULL, + uri TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + created_ms INTEGER NOT NULL, + used_ms INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS requests ( + rid BLOB PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + browser BLOB NOT NULL, + client_id TEXT NOT NULL, + client_name TEXT NOT NULL, + client_host TEXT NOT NULL, + verified INTEGER NOT NULL, + redirect_uri TEXT NOT NULL, + state TEXT NOT NULL, + challenge TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + kid TEXT NOT NULL, + agent_key BLOB NOT NULL UNIQUE, + created_ms INTEGER NOT NULL, + expires_ms INTEGER NOT NULL, + status TEXT NOT NULL, + home TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT '', + granted TEXT NOT NULL DEFAULT '', + shard INTEGER NOT NULL DEFAULT 0, + authz BLOB UNIQUE, + authz_ms INTEGER NOT NULL DEFAULT 0, + authz_used INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS agents ( + home TEXT NOT NULL, + agent TEXT NOT NULL, + agent_key BLOB NOT NULL, + kid TEXT NOT NULL, + client_id TEXT NOT NULL, + scope TEXT NOT NULL, + shard INTEGER NOT NULL, + expires_ms INTEGER NOT NULL, + created_ms INTEGER NOT NULL, + PRIMARY KEY (home, agent) +); +CREATE TABLE IF NOT EXISTS tokens ( + hash BLOB PRIMARY KEY, + kind TEXT NOT NULL, + family BLOB NOT NULL, + client_id TEXT NOT NULL, + home TEXT NOT NULL, + agent TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + expires_ms INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (home, agent) REFERENCES agents(home, agent) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS tokens_family ON tokens(family); +CREATE INDEX IF NOT EXISTS tokens_agent ON tokens(home, agent);`) + if err != nil { + _ = db.Close() + return nil, err + } + return &store{db: db}, nil +} + +func (s *store) close() error { return s.db.Close() } + +func hash(secret string) []byte { + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +// -------------------------------------------------------------------------- +// Clients registered dynamically +// -------------------------------------------------------------------------- + +type dcrClient struct { + ID string + Secret []byte // hash, nil for a public client + AuthMethod string + Name string + URI string + RedirectURIs []string +} + +func (s *store) putClient(c dcrClient, now time.Time) error { + uris, _ := json.Marshal(c.RedirectURIs) + _, err := s.db.Exec(`INSERT INTO clients (client_id, secret, auth_method, name, uri, redirect_uris, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, c.Secret, c.AuthMethod, c.Name, c.URI, string(uris), now.UnixMilli()) + return err +} + +func (s *store) client(id string) (dcrClient, bool, error) { + var c dcrClient + var uris string + err := s.db.QueryRow(`SELECT client_id, secret, auth_method, name, uri, redirect_uris FROM clients WHERE client_id = ?`, id). + Scan(&c.ID, &c.Secret, &c.AuthMethod, &c.Name, &c.URI, &uris) + if errors.Is(err, sql.ErrNoRows) { + return c, false, nil + } + if err != nil { + return c, false, err + } + err = json.Unmarshal([]byte(uris), &c.RedirectURIs) + return c, err == nil, err +} + +func (s *store) clientUsed(id string, now time.Time) { + _, _ = s.db.Exec(`UPDATE clients SET used_ms = ? WHERE client_id = ?`, now.UnixMilli(), id) +} + +func (s *store) countClients() (n int) { + _ = s.db.QueryRow(`SELECT count(*) FROM clients`).Scan(&n) + return n +} + +// -------------------------------------------------------------------------- +// Consents in progress +// -------------------------------------------------------------------------- + +// Request statuses. +const ( + statusPending = "pending" + statusApproved = "approved" + statusDenied = "denied" +) + +// consent is one authorization request, from the authorize page to the +// token it turns into. +type consent struct { + // Code is the short code the page shows, for an owner to type on the + // box's page. It finds the request and proves nothing. + Code string + ClientID string + ClientName string + ClientHost string + Verified bool // the client's name came from a document at its own https URL + RedirectURI string + State string + Challenge string + Scope string + Resource string + Kid string + AgentKey []byte + Created time.Time + Expires time.Time + Status string + Home string + Agent string + Granted string + Shard int +} + +const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, + kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard` + +func scanConsent(row interface{ Scan(...any) error }) (consent, error) { + var c consent + var verified int + var created, expires int64 + err := row.Scan(&c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + return c, err +} + +func (s *store) putConsent(rid, browser string, c consent) error { + verified := 0 + if c.Verified { + verified = 1 + } + _, err := s.db.Exec(`INSERT INTO requests (rid, browser, `+consentCols+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + hash(rid), hash(browser), c.Code, c.ClientID, c.ClientName, c.ClientHost, verified, c.RedirectURI, c.State, + c.Challenge, c.Scope, c.Resource, c.Kid, c.AgentKey, c.Created.UnixMilli(), c.Expires.UnixMilli(), c.Status, + c.Home, c.Agent, c.Granted, c.Shard) + return err +} + +func (s *store) consentByRID(rid string) (consent, bool, error) { + return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE rid = ?`, hash(rid)) +} + +func (s *store) consentByCode(code string) (consent, bool, error) { + return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE code = ?`, code) +} + +func (s *store) oneConsent(q string, arg any) (consent, bool, error) { + c, err := scanConsent(s.db.QueryRow(q, arg)) + if errors.Is(err, sql.ErrNoRows) { + return c, false, nil + } + return c, err == nil, err +} + +// browserMatches reports whether a browser's cookie secret is the one the +// request was started with. +func (s *store) browserMatches(rid, browser string) bool { + var n int + _ = s.db.QueryRow(`SELECT count(*) FROM requests WHERE rid = ? AND browser = ?`, hash(rid), hash(browser)).Scan(&n) + return n == 1 +} + +// pendingByKey finds the pending consent for an agent key. +func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c consent, ok bool, err error) { + row := s.db.QueryRow(`SELECT rid, `+consentCols+` FROM requests WHERE agent_key = ? AND status = ? AND expires_ms > ?`, + key, statusPending, now.UnixMilli()) + var verified int + var created, expires int64 + err = row.Scan(&ridHash, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + if errors.Is(err, sql.ErrNoRows) { + return nil, c, false, nil + } + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + return ridHash, c, err == nil, err +} + +// approve marks a consent approved for an agent of a home, and records the +// agent, in one transaction. +func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // The page has as long again to hand its browser the code. + res, err := tx.Exec(`UPDATE requests SET status = ?, home = ?, agent = ?, granted = ?, shard = ?, expires_ms = ? + WHERE rid = ? AND status = ?`, + statusApproved, c.Home, c.Agent, c.Granted, c.Shard, now.Add(RequestTTL).UnixMilli(), ridHash, statusPending) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return errGone + } + if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, shard, expires_ms, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (home, agent) DO UPDATE SET agent_key = excluded.agent_key, kid = excluded.kid, + client_id = excluded.client_id, scope = excluded.scope, shard = excluded.shard, expires_ms = excluded.expires_ms`, + c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, c.Granted, c.Shard, agentExpires.UnixMilli(), now.UnixMilli()); err != nil { + return err + } + return tx.Commit() +} + +func (s *store) deny(rid string) (consent, bool, error) { + c, ok, err := s.consentByRID(rid) + if err != nil || !ok || c.Status != statusPending { + return c, false, err + } + res, err := s.db.Exec(`UPDATE requests SET status = ? WHERE rid = ? AND status = ?`, statusDenied, hash(rid), statusPending) + if err != nil { + return c, false, err + } + n, _ := res.RowsAffected() + return c, n == 1, nil +} + +// issueCode gives an approved consent a new authorization code, in place of +// any earlier one that was not used. +func (s *store) issueCode(rid, code string, expires time.Time) error { + res, err := s.db.Exec(`UPDATE requests SET authz = ?, authz_ms = ? WHERE rid = ? AND status = ? AND authz_used = 0`, + hash(code), expires.UnixMilli(), hash(rid), statusApproved) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return errGone + } + return nil +} + +// redeem spends an authorization code once. A code presented a second time +// is reported as reused, and the caller revokes what the first use got. +func (s *store) redeem(code string, now time.Time) (c consent, ridHash []byte, reused bool, err error) { + tx, err := s.db.Begin() + if err != nil { + return c, nil, false, err + } + defer tx.Rollback() + var used int + var ms int64 + row := tx.QueryRow(`SELECT rid, authz_used, authz_ms, `+consentCols+` FROM requests WHERE authz = ?`, hash(code)) + var verified int + var created, expires int64 + err = row.Scan(&ridHash, &used, &ms, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, + &c.State, &c.Challenge, &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, + &c.Agent, &c.Granted, &c.Shard) + if errors.Is(err, sql.ErrNoRows) { + return c, nil, false, errGone + } + if err != nil { + return c, nil, false, err + } + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + if used != 0 { + return c, ridHash, true, errGone + } + if now.UnixMilli() >= ms { + return c, ridHash, false, errGone + } + if _, err := tx.Exec(`UPDATE requests SET authz_used = 1 WHERE authz = ?`, hash(code)); err != nil { + return c, nil, false, err + } + return c, ridHash, false, tx.Commit() +} + +var errGone = errors.New("oauth: gone") + +// -------------------------------------------------------------------------- +// Agents and tokens +// -------------------------------------------------------------------------- + +// agentRow is an agent an owner approved, as the authorization server knows +// it. +type agentRow struct { + Home string + Agent string + Key []byte + Kid string + ClientID string + Scope string + Shard int + Expires time.Time // zero when the box set no end +} + +func (s *store) agent(home, agent string) (agentRow, bool, error) { + var a agentRow + var ms int64 + err := s.db.QueryRow(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ? AND agent = ?`, + home, agent).Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms) + if errors.Is(err, sql.ErrNoRows) { + return a, false, nil + } + if ms > 0 { + a.Expires = time.UnixMilli(ms) + } + return a, err == nil, err +} + +func (s *store) agentsOf(home string) ([]agentRow, error) { + rows, err := s.db.Query(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ?`, home) + if err != nil { + return nil, err + } + defer rows.Close() + var out []agentRow + for rows.Next() { + var a agentRow + var ms int64 + if err := rows.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms); err != nil { + return nil, err + } + if ms > 0 { + a.Expires = time.UnixMilli(ms) + } + out = append(out, a) + } + return out, rows.Err() +} + +// dropAgent deletes an agent and, by the foreign key, every token it holds. +func (s *store) dropAgent(home, agent string) error { + _, err := s.db.Exec(`DELETE FROM agents WHERE home = ? AND agent = ?`, home, agent) + return err +} + +func (s *store) expiredAgents(now time.Time) ([]agentRow, error) { + rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms > 0 AND expires_ms <= ?`, now.UnixMilli()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []agentRow + for rows.Next() { + var a agentRow + if err := rows.Scan(&a.Home, &a.Agent, &a.Kid); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// Token kinds. +const ( + kindAccess = "access" + kindRefresh = "refresh" +) + +type tokenRow struct { + Kind string + Family []byte + ClientID string + Home string + Agent string + Scope string + Resource string + Expires time.Time + Used bool +} + +func (s *store) putToken(tok string, t tokenRow) error { + _, err := s.db.Exec(`INSERT INTO tokens (hash, kind, family, client_id, home, agent, scope, resource, expires_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash(tok), t.Kind, t.Family, t.ClientID, t.Home, t.Agent, t.Scope, + t.Resource, t.Expires.UnixMilli()) + return err +} + +func (s *store) token(tok string) (tokenRow, bool, error) { + var t tokenRow + var ms int64 + var used int + err := s.db.QueryRow(`SELECT kind, family, client_id, home, agent, scope, resource, expires_ms, used FROM tokens WHERE hash = ?`, + hash(tok)).Scan(&t.Kind, &t.Family, &t.ClientID, &t.Home, &t.Agent, &t.Scope, &t.Resource, &ms, &used) + if errors.Is(err, sql.ErrNoRows) { + return t, false, nil + } + t.Expires, t.Used = time.UnixMilli(ms), used == 1 + return t, err == nil, err +} + +// rotate spends a refresh token. False means it was spent before: a second +// use of one refresh token is theft or a bug, and the caller revokes its +// family. +func (s *store) rotate(tok string) (bool, error) { + res, err := s.db.Exec(`UPDATE tokens SET used = 1 WHERE hash = ? AND kind = ? AND used = 0`, hash(tok), kindRefresh) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (s *store) revokeFamily(family []byte) error { + _, err := s.db.Exec(`DELETE FROM tokens WHERE family = ?`, family) + return err +} + +func (s *store) revokeToken(tok string) error { + t, ok, err := s.token(tok) + if err != nil || !ok { + return err + } + if t.Kind == kindRefresh { + return s.revokeFamily(t.Family) + } + _, err = s.db.Exec(`DELETE FROM tokens WHERE hash = ?`, hash(tok)) + return err +} + +// sweep deletes what has run out: tokens, and consents a while after they +// ended. It returns the key ids of consents that ended without approval, +// for the keys part to forget. +func (s *store) sweep(now time.Time) ([]string, error) { + if _, err := s.db.Exec(`DELETE FROM tokens WHERE expires_ms <= ?`, now.UnixMilli()); err != nil { + return nil, err + } + rows, err := s.db.Query(`SELECT kid FROM requests WHERE status != ? AND expires_ms <= ?`, statusApproved, now.UnixMilli()) + if err != nil { + return nil, err + } + var kids []string + for rows.Next() { + var kid string + if rows.Scan(&kid) == nil { + kids = append(kids, kid) + } + } + _ = rows.Close() + _, err = s.db.Exec(`DELETE FROM requests WHERE expires_ms <= ?`, now.UnixMilli()) + return kids, err +} + +// splitScope reads a space-separated scope parameter. +func splitScope(s string) []string { return strings.Fields(s) } diff --git a/internal/oauth/token.go b/internal/oauth/token.go new file mode 100644 index 0000000..582d8b8 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,283 @@ +package oauth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + "time" +) + +var verifierRE = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) + +// Token prefixes, so a leaked token is recognisable for what it is. +const ( + accessPrefix = "ftwat_" + refreshPrefix = "ftwrt_" +) + +func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := r.ParseForm(); err != nil { + tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") + return + } + for k, v := range r.PostForm { + if len(v) > 1 { + tokenError(w, http.StatusBadRequest, "invalid_request", k+" appears more than once") + return + } + } + c, ok := s.authenticateClient(r) + if !ok { + if _, _, basic := r.BasicAuth(); basic { + w.Header().Set("WWW-Authenticate", `Basic realm="FTW Cloud"`) + } + tokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + if res := r.PostForm.Get("resource"); res != "" && !sameResource(res) { + tokenError(w, http.StatusBadRequest, "invalid_target", "this server issues tokens only for "+Resource) + return + } + switch r.PostForm.Get("grant_type") { + case grantCode: + s.exchangeCode(w, r, c) + case grantRefresh: + s.refresh(w, r, c) + default: + tokenError(w, http.StatusBadRequest, "unsupported_grant_type", "authorization_code or refresh_token") + } +} + +func (s *Server) exchangeCode(w http.ResponseWriter, r *http.Request, cl client) { + f := r.PostForm + verifier := f.Get("code_verifier") + if !verifierRE.MatchString(verifier) { + tokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier is required") + return + } + c, ridHash, reused, err := s.st.redeem(f.Get("code"), s.now()) + if reused { + // The code was used before: whoever holds the first tokens may not + // be the client. They go. + _ = s.st.revokeFamily(ridHash) + } + if err != nil { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the code is not valid") + return + } + sum := sha256.Sum256([]byte(verifier)) + if c.ClientID != cl.ID || subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(c.Challenge)) != 1 || + (f.Get("redirect_uri") != "" && f.Get("redirect_uri") != c.RedirectURI) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the code is not valid") + return + } + a, ok, err := s.st.agent(c.Home, c.Agent) + if err != nil || !ok || s.agentGone(a) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the owner removed this assistant") + return + } + s.issue(w, ridHash, cl.ID, a, c.Granted, s.now().Add(RefreshTTL)) +} + +func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { + tok := r.PostForm.Get("refresh_token") + t, ok, err := s.st.token(tok) + if err != nil || !ok || t.Kind != kindRefresh || t.ClientID != cl.ID || !s.now().Before(t.Expires) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is not valid") + return + } + fresh, err := s.st.rotate(tok) + if err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not refresh") + return + } + if !fresh { + // A refresh token used twice: one of the two holders stole it. + _ = s.st.revokeFamily(t.Family) + tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token was used before") + return + } + scope := t.Scope + if asked := splitScope(r.PostForm.Get("scope")); len(asked) > 0 { + have := splitScope(t.Scope) + for _, sc := range asked { + if !slices.Contains(have, sc) { + tokenError(w, http.StatusBadRequest, "invalid_scope", "a refresh cannot widen the scope") + return + } + } + scope = strings.Join(asked, " ") + } + a, ok, err := s.st.agent(t.Home, t.Agent) + if err != nil || !ok || s.agentGone(a) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the owner removed this assistant") + return + } + s.issue(w, t.Family, cl.ID, a, scope, t.Expires) +} + +// issue writes a new access token and a new refresh token of one family. +// The refresh token ends no later than refreshEnd or the agent. +func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a agentRow, scope string, refreshEnd time.Time) { + now := s.now() + if !a.Expires.IsZero() && a.Expires.Before(refreshEnd) { + refreshEnd = a.Expires + } + accessEnd := now.Add(AccessTTL) + if accessEnd.After(refreshEnd) { + accessEnd = refreshEnd + } + access, refresh := accessPrefix+randomToken(32), refreshPrefix+randomToken(32) + base := tokenRow{Family: family, ClientID: clientID, Home: a.Home, Agent: a.Agent, Scope: scope, Resource: Resource} + at, rt := base, base + at.Kind, at.Expires = kindAccess, accessEnd + rt.Kind, rt.Expires = kindRefresh, refreshEnd + if err := s.st.putToken(access, at); err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not issue a token") + return + } + if err := s.st.putToken(refresh, rt); err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not issue a token") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": int(accessEnd.Sub(now).Seconds()), + "refresh_token": refresh, + "scope": scope, + }, false) +} + +func (s *Server) agentGone(a agentRow) bool { + return !a.Expires.IsZero() && !s.now().Before(a.Expires) +} + +func tokenError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}, false) +} + +// serveRevoke is RFC 7009: revoking a refresh token revokes its family. It +// answers 200 whether or not the token was known. +func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := r.ParseForm(); err != nil { + tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") + return + } + c, ok := s.authenticateClient(r) + if !ok { + tokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + tok := r.PostForm.Get("token") + if t, ok, err := s.st.token(tok); err == nil && ok && t.ClientID == c.ID { + _ = s.st.revokeToken(tok) + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) +} + +// -------------------------------------------------------------------------- +// The resource server's side: checking a bearer token at /mcp +// -------------------------------------------------------------------------- + +// Subject is who a token acts as. Today that is always an agent of one home. +// A later Sourceful staff principal fits here with Kind "staff", a staff id +// and no home of its own: it reaches only homes with an active support +// grant, each through that grant's agent. +type Subject struct { + Kind string // SubjectAgent + Shard int + Home string + // Agent is the roster device id; Key the agent's X25519 public key, + // as the roster lists it; Kid its key in the keys part. + Agent string + Key []byte + Kid string +} + +// SubjectAgent is a personal agent an owner approved. +const SubjectAgent = "agent" + +// Grant is what one valid access token allows. +type Grant struct { + Subject Subject + ClientID string + Scopes []string + Expires time.Time +} + +// Has reports whether the grant carries a scope. +func (g *Grant) Has(scope string) bool { return slices.Contains(g.Scopes, scope) } + +// AuthError is why a request carries no usable token. +type AuthError struct { + Status int // 401 or 403 + Code string // "", invalid_token, insufficient_scope + Desc string + Scope string // the scopes the operation needs +} + +// Authenticate checks a request's bearer token for /mcp: an access token +// this server issued, not expired, for this resource, whose agent the owner +// has not removed. Tokens in the query string are not read. +func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { + h := r.Header.Get("Authorization") + scheme, tok, ok := strings.Cut(h, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || tok == "" { + return nil, &AuthError{Status: http.StatusUnauthorized, Scope: strings.Join(ReadScopes, " ")} + } + invalid := &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is not valid"} + t, ok, err := s.st.token(strings.TrimSpace(tok)) + if err != nil || !ok || t.Kind != kindAccess || !s.now().Before(t.Expires) { + return nil, invalid + } + if t.Resource != Resource { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is for another resource"} + } + a, ok, err := s.st.agent(t.Home, t.Agent) + if err != nil || !ok { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the owner removed this assistant"} + } + if s.agentGone(a) { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "this assistant's access ran out"} + } + return &Grant{ + Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, + ClientID: t.ClientID, Scopes: splitScope(t.Scope), Expires: t.Expires, + }, nil +} + +// Challenge writes a 401 or 403 with its WWW-Authenticate header, and a +// JSON body for clients that show one. +func Challenge(w http.ResponseWriter, e *AuthError) { + parts := []string{fmt.Sprintf("resource_metadata=%q", ResourceMetadataURL)} + if e.Code != "" { + parts = append(parts, fmt.Sprintf("error=%q", e.Code)) + } + if e.Desc != "" { + parts = append(parts, fmt.Sprintf("error_description=%q", e.Desc)) + } + if e.Scope != "" { + parts = append(parts, fmt.Sprintf("scope=%q", e.Scope)) + } + w.Header().Set("WWW-Authenticate", "Bearer "+strings.Join(parts, ", ")) + body := map[string]string{"error": e.Code, "error_description": e.Desc} + if e.Code == "" { + body = map[string]string{"error": "unauthorized", "error_description": "a bearer token from " + Issuer + " is required"} + } + writeJSON(w, e.Status, body, false) +} + +// InsufficientScope is the 403 that asks the client to step up. +func InsufficientScope(scopes ...string) *AuthError { + return &AuthError{Status: http.StatusForbidden, Code: "insufficient_scope", + Desc: "this needs more rights; ask the owner to approve them", Scope: strings.Join(scopes, " ")} +} diff --git a/internal/server/oauth_routes_test.go b/internal/server/oauth_routes_test.go new file mode 100644 index 0000000..fcf5d4b --- /dev/null +++ b/internal/server/oauth_routes_test.go @@ -0,0 +1,53 @@ +package server + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw-cloud/internal/oauth" +) + +// The authorization server's routes read no home either, whatever they are +// asked: they know agents and tokens, never readings. +func TestOAuthRoutesReadNoHome(t *testing.T) { + s, home := newServer(t) + sealer, _ := keys.FileSealerFromKey(bytes.Repeat([]byte{3}, 32)) + ks, err := keys.NewServer(t.TempDir(), sealer, nil) + if err != nil { + t.Fatal(err) + } + auth, err := oauth.New(filepath.Join(t.TempDir(), "oauth.db"), ks.Local(), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = auth.Close() }) + s.OAuth = auth + h := s.Handler() + paths := append(append([]string{}, oauth.Routes...), "/oauth/requests/"+home, "/oauth/requests?code="+home[:6], + "/oauth/authorize?client_id="+home, "/.well-known/oauth-protected-resource/"+home) + for _, p := range paths { + for _, m := range []string{http.MethodGet, http.MethodPost} { + w := httptest.NewRecorder() + r := httptest.NewRequest(m, p, nil) + h.ServeHTTP(w, r) + if bytes.Contains(w.Body.Bytes(), []byte("CANARY")) { + t.Fatalf("%s %s returned a home's readings", m, p) + } + if w.Header().Get("Link") == "" { + t.Fatalf("%s %s has no source link", m, p) + } + } + } + // The metadata is there. + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) + if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte(oauth.Issuer)) { + t.Fatalf("metadata: %d", w.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 6f78aad..4b15e08 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ // /v1/now a widget's token read (package token) // /source the source of the running service, as AGPL-3.0 asks // /healthz liveness, and nothing about any home +// /oauth/*, /.well-known/oauth-* the authorization server for assistants (package oauth) // // There is no route for staff. Reading a home needs its box's key, a phone in // its roster, or a token bound to one. @@ -21,6 +22,7 @@ import ( "time" "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/oauth" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/store" "github.com/srcfl/ftw-cloud/internal/token" @@ -31,7 +33,7 @@ import ( const SourceArchive = "/usr/share/ftw-cloud/source.tar.gz" // Routes are every path the service answers. -var Routes = []string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/source", "/healthz"} +var Routes = append([]string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/source", "/healthz"}, oauth.Routes...) // devRoutes adds a local inspection route in binaries built with // -tags ftwclouddev. It is nil in every other build. @@ -48,7 +50,9 @@ type Server struct { // Tokens answers /v1/now. Tokens come only over the app session, so // without a Noise key every token read is refused. Tokens *token.Service - Log *slog.Logger + // OAuth is the authorization server for assistants. Nil serves none. + OAuth *oauth.Server + Log *slog.Logger } // Handler is the whole HTTP surface. @@ -67,6 +71,9 @@ func (s *Server) Handler() http.Handler { } else { mux.HandleFunc("/v1/now", notYet) } + if s.OAuth != nil { + s.OAuth.Mount(mux) + } mux.HandleFunc("/source", source) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") From 8d50688830268fbcd83e320dc49cde652e05d141 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:25:34 +0200 Subject: [PATCH 07/28] fix(oauth): a token names its shard as the shard directory does cloud-scale's shard seam (srcfl/ftw-cloud#17) names shards by string, so the subject carries the shard's name, not a number. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/oauth.go | 9 +++++---- internal/oauth/store.go | 8 ++++---- internal/oauth/token.go | 6 ++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 78a121f..5a2c6d0 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -136,8 +136,9 @@ type Server struct { roster chan rosterEvent - // Shard names the shard a home lives on. Nil is one shard, 0. - Shard func(home string) int + // Shard names the shard a home lives on (shard.Directory). Nil is one + // shard, "". + Shard func(home string) string // AgentExpiry reads when the box ends an agent, from its roster entry. // Nil, or a zero time, is no end the service knows of; the box still // enforces its own. @@ -342,9 +343,9 @@ func (s *Server) sweep(ctx context.Context) { } } -func (s *Server) shard(home string) int { +func (s *Server) shard(home string) string { if s.Shard == nil { - return 0 + return "" } return s.Shard(home) } diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 92790b5..980dcc9 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS requests ( home TEXT NOT NULL DEFAULT '', agent TEXT NOT NULL DEFAULT '', granted TEXT NOT NULL DEFAULT '', - shard INTEGER NOT NULL DEFAULT 0, + shard TEXT NOT NULL DEFAULT '', authz BLOB UNIQUE, authz_ms INTEGER NOT NULL DEFAULT 0, authz_used INTEGER NOT NULL DEFAULT 0 @@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS agents ( kid TEXT NOT NULL, client_id TEXT NOT NULL, scope TEXT NOT NULL, - shard INTEGER NOT NULL, + shard TEXT NOT NULL, expires_ms INTEGER NOT NULL, created_ms INTEGER NOT NULL, PRIMARY KEY (home, agent) @@ -183,7 +183,7 @@ type consent struct { Home string Agent string Granted string - Shard int + Shard string } const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, @@ -356,7 +356,7 @@ type agentRow struct { Kid string ClientID string Scope string - Shard int + Shard string Expires time.Time // zero when the box set no end } diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 582d8b8..26255a0 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -193,8 +193,10 @@ func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { // and no home of its own: it reaches only homes with an active support // grant, each through that grant's agent. type Subject struct { - Kind string // SubjectAgent - Shard int + Kind string // SubjectAgent + // Shard names the shard that held the home when the owner approved, + // so a router can send the token on; "" with one shard. + Shard string Home string // Agent is the roster device id; Key the agent's X25519 public key, // as the roster lists it; Kid its key in the keys part. From 078329033453ed360392738d21ab499209c11ce5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:32:14 +0200 Subject: [PATCH 08/28] fix(oauth): an approval outlasts a keys part that is briefly away A box sends its roster when it changes, so an approval whose key the keys part could not keep at once is tried again every 5 s for two minutes, rather than waiting for a roster that may not come. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/oauth.go | 24 ++++++++++++++++++++++- internal/oauth/oauth_test.go | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 5a2c6d0..82424f1 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -239,8 +239,19 @@ func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { type rosterEvent struct { home string devices []cloudwire.Device + // tries counts how often the event came back because the keys part + // could not keep an approved key. + tries int } +// keepRetry is how long an approval waits before it tries the keys part +// again, and keepTries how often. A box sends its roster when it changes, +// so the approval cannot wait for the next one. +var ( + keepRetry = 5 * time.Second + keepTries = 24 +) + // Roster hears each roster the service keeps. A device whose key a pending // consent made is that consent's approval; an agent the roster no longer // lists is revoked. The work happens on Run's goroutine, in order, since @@ -273,6 +284,7 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() + retry := false listed := map[string]cloudwire.Device{} for _, d := range ev.devices { if d.Tun { @@ -289,7 +301,8 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { continue } if err := s.keys.Keep(ctx, c.Kid); err != nil { - s.log.Warn("an approved agent's key could not be kept; the next roster tries again", "err", err) + s.log.Warn("an approved agent's key could not be kept; trying again", "err", err) + retry = true continue } c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) @@ -299,6 +312,15 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { } s.hub.notify(string(ridHash)) } + if retry && ev.tries < keepTries { + ev.tries++ + time.AfterFunc(keepRetry, func() { + select { + case s.roster <- ev: + default: + } + }) + } // Revocations: an agent the roster no longer lists, or lists with // another key. agents, err := s.st.agentsOf(ev.home) diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index f091e35..c2b5e8b 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -876,3 +876,40 @@ func TestAuthorizeIsRateLimited(t *testing.T) { t.Fatal("authorize is not limited") } } + +// flakyKeys is a keys part that is away for a while. +type flakyKeys struct { + AgentKeys + mu sync.Mutex + down int +} + +func (f *flakyKeys) Keep(ctx context.Context, kid string) error { + f.mu.Lock() + if f.down > 0 { + f.down-- + f.mu.Unlock() + return keys.ErrUnavailable + } + f.mu.Unlock() + return f.AgentKeys.Keep(ctx, kid) +} + +// A box sends its roster when it changes, not again: an approval the keys +// part could not take at once is tried again without a new roster. +func TestApprovalOutlastsAKeysPartThatIsAway(t *testing.T) { + old := keepRetry + keepRetry = 30 * time.Millisecond + t.Cleanup(func() { keepRetry = old }) + g := newRig(t) + flaky := &flakyKeys{AgentKeys: g.keys, down: 3} + g.s.keys = flaky + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + if ev := next(t, ch); ev.name != "approved" { + t.Fatalf("event %q", ev.name) + } +} From d892bf3f35a1133922657b81a46f9cae8fb93e55 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:46:37 +0200 Subject: [PATCH 09/28] feat(oauth): build to the agent contract, with a signed grant offer srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This follows it: - Scopes are the registry's constants. An agent row is approved only as the roster lists it: role agent, a tunnel, its `agent` grant for the client the offer named, and an end still ahead. Support and release rows are not this door's. - The grant is the roster's: scopes, ops, caps and end, refreshed with every roster, so an owner who narrows, widens or renews a grant changes the token at once. The service stops admitting an agent at its end by its own clock. - The consent page builds the grant offer (key, role, client, clientId, scopes, the ops those scopes open, proposed caps, 30 days, the code; no home, since the owner picks it), has the keys part sign it, and puts it in the link's fragment and the QR. /oauth/requests?code= returns it once. /.well-known/ftw-offer-key serves the key the app pins. - verified means a client FTW Cloud knows, by its document's host; any other document client is shown by its host. - Tokens name their home, ftwat_., for shard routing. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 +++--- internal/oauth/authorize.go | 20 +++-- internal/oauth/clients.go | 6 +- internal/oauth/oauth.go | 75 ++++++++-------- internal/oauth/oauth_test.go | 160 ++++++++++++++++++++++++++++++++--- internal/oauth/offer.go | 123 +++++++++++++++++++++++++++ internal/oauth/page.go | 12 +-- internal/oauth/store.go | 102 +++++++++++++++------- internal/oauth/token.go | 40 +++++++-- 9 files changed, 455 insertions(+), 111 deletions(-) create mode 100644 internal/oauth/offer.go diff --git a/README.md b/README.md index e16bb69..c448b19 100644 --- a/README.md +++ b/README.md @@ -135,16 +135,24 @@ to it yet. as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older clients. There is no account and no password: `/oauth/authorize` asks - `ftwkeysd` for a new agent key, then shows the client's name, where the - browser goes back, the rights asked for, a short code, a QR and the link - `https://app.ftw.energy/a/`. The owner approves in the app or on - the box's page; the box lists the agent's key in the roster it signs, and - when that roster arrives the page, waiting on a server-sent event, returns - to the client with a code. Only the browser that started the request gets - it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one - revokes its family. The service keeps hashes, never tokens. A token is - bound to one agent of one home; a roster without the agent, or the end the - box set for it, ends its tokens and its key. + `ftwkeysd` for a new agent key and has it sign the grant offer + (srcfl/ftw `docs/agents.md`): the key, the client, the scopes, ops and + caps asked for, and the page's code, with no home, since the owner picks + it. The page shows the client's name, where the browser goes back, the + rights, the code, a QR and `https://app.ftw.energy/a/#`; + the app checks the offer against the offer key it pins, which + `/.well-known/ftw-offer-key` serves. The box's page or an owner who types + the code finds the offer once at `/oauth/requests?code=`. The owner + approves in the app or on the box's page; the box lists the agent's key, + for that client, in the roster it signs, and when that roster arrives the + page, waiting on a server-sent event, returns to the client with a code. + Only the browser that started the request gets it. Access tokens last 10 + minutes; refresh tokens rotate, and a reused one revokes its family. A + token names its home (`ftwat_.`) so any instance can route + it; the service keeps hashes, never tokens. A token carries the grant the + newest roster lists, narrowed or widened at once, and stops at the + agent's end by the service's own clock. A roster without the agent ends + its tokens and its key. Not built yet: diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 4d906f7..280a341 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -119,6 +119,10 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { con.Code = shortCode() + if con.Offer, err = s.signOffer(ctx, buildOffer(con)); err != nil { + s.log.Warn("the keys part could not sign an offer", "err", err) + break + } err = s.st.putConsent(rid, browser, con) if err == nil || try == 3 { break @@ -196,7 +200,9 @@ func authorizationError(redirect, code, desc, state string) string { // -------------------------------------------------------------------------- type requestView struct { - Request string `json:"request,omitempty"` + Request string `json:"request,omitempty"` + // Offer is the signed grant offer, base64url, as the link carries it. + Offer string `json:"offer"` Status string `json:"status"` Code string `json:"code"` Client clientView `json:"client"` @@ -231,7 +237,7 @@ func (s *Server) view(c consent) requestView { if status == statusPending && !s.now().Before(c.Expires) { status = "expired" } - return requestView{Status: status, Code: c.Code, + return requestView{Offer: c.Offer, Status: status, Code: c.Code, Client: clientView{ID: c.ClientID, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified}, Redirect: redirectView{Host: u.Hostname(), Local: u.Scheme == "http" && isLoopback(u.Hostname())}, Scopes: splitScope(c.Scope), @@ -253,8 +259,10 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { } // serveRequestByCode finds a request by the code an owner typed on the box's -// page. Lookups are rate limited for everyone together: a code lasts ten -// minutes and has about 29 bits, and finding one only shows a client's name. +// page or in the app, once: a short code cannot carry the agent's key, so +// it fetches the signed offer. Lookups are rate limited for everyone +// together, and a code works once: it lasts ten minutes and has about 29 +// bits, and finding one shows only an offer the owner must still approve. func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { if !s.lookupLimit.allow(s.now()) { w.Header().Set("Retry-After", "1") @@ -266,8 +274,8 @@ func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - c, ok, err := s.st.consentByCode(code) - if err != nil || !ok || c.Status != statusPending || !s.now().Before(c.Expires) { + c, ok, err := s.st.spendCode(code, s.now()) + if err != nil || !ok { http.NotFound(w, r) return } diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 92ca6f2..0cf55c7 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -20,7 +20,9 @@ type client struct { // Host is where the name came from: the host of the metadata // document's URL, which vouches for it, or the client_uri a registration // claimed, which does not. - Host string + Host string + // Verified is a client FTW Cloud knows: a metadata document on one of + // verifiedHosts. Verified bool RedirectURIs []string AuthMethod string @@ -37,7 +39,7 @@ func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { return client{}, err } u, _ := url.Parse(id) - return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: true, RedirectURIs: md.RedirectURIs, + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedHost(u.Hostname()), RedirectURIs: md.RedirectURIs, AuthMethod: authNone}, nil } c, ok, err := s.st.client(id) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 82424f1..a8445d3 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -62,24 +62,20 @@ const ( RefreshTTL = 30 * 24 * time.Hour ) -// Scopes. Each is a right the box grants an agent, by its registry name; a -// token carries those the client asked for and the box granted. -// -// The first five are in contract/registry.yaml today. The rest are the -// agent rights decided on 15 October 2026 and wait for their registry names -// (the core-agent-role contract); they move to protocol constants when it -// lands. +// Scopes. Each is a right the box grants an agent, by its registry name +// (docs/agents.md in srcfl/ftw); a token carries those the client asked for +// and the box granted. const ( ScopeLiveRead = protocol.ScopeLiveRead ScopePlanRead = protocol.ScopePlanRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopeHealthRead = protocol.ScopeHealthRead ScopeEventsRead = protocol.ScopeEventsRead - ScopeLogsRead = "ftw.logs.read" - ScopeConfigRead = "ftw.config.read" - ScopeDiagRun = "ftw.diag.run" - ScopeDispatchHold = "ftw.dispatch.hold" - ScopeGoalsRequest = "ftw.goals.request" + ScopeLogsRead = protocol.ScopeLogsRead + ScopeConfigRead = protocol.ScopeConfigRead + ScopeDiagRun = protocol.ScopeDiagRun + ScopeDispatchHold = protocol.ScopeDispatchHold + ScopeGoalsRequest = protocol.ScopeGoalsRequest ) // ReadScopes are what an agent gets by default. @@ -121,6 +117,8 @@ type AgentKeys interface { New(ctx context.Context) (kid string, pub []byte, err error) Keep(ctx context.Context, kid string) error Forget(ctx context.Context, kid string) error + SignOffer(ctx context.Context, offer []byte) ([]byte, error) + OfferKey(ctx context.Context) ([]byte, error) } // Server is the authorization server. @@ -136,13 +134,11 @@ type Server struct { roster chan rosterEvent + offerKey offerKey + // Shard names the shard a home lives on (shard.Directory). Nil is one // shard, "". Shard func(home string) string - // AgentExpiry reads when the box ends an agent, from its roster entry. - // Nil, or a zero time, is no end the service knows of; the box still - // enforces its own. - AgentExpiry func(d cloudwire.Device) time.Time // 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) @@ -175,7 +171,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", + "/oauth/revoke", "/oauth/requests", "/.well-known/ftw-offer-key", } // Mount adds the authorization server's routes to mux. @@ -187,6 +183,7 @@ func (s *Server) Mount(mux *http.ServeMux) { mux.HandleFunc("POST /oauth/token", s.serveToken) mux.HandleFunc("POST /oauth/register", s.serveRegister) mux.HandleFunc("POST /oauth/revoke", s.serveRevoke) + mux.HandleFunc("GET /.well-known/ftw-offer-key", s.serveOfferKey) mux.HandleFunc("GET /oauth/requests", s.serveRequestByCode) mux.HandleFunc("GET /oauth/requests/{rid}", s.serveRequest) mux.HandleFunc("GET /oauth/requests/{rid}/events", s.serveEvents) @@ -285,19 +282,21 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() retry := false + // The rows this service serves: a personal agent's, with its grant, a + // tunnel and an end still ahead. Support and release rows reach their + // homes another way. listed := map[string]cloudwire.Device{} for _, d := range ev.devices { - if d.Tun { + if d.Tun && d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { listed[d.ID] = d } } - // Approvals: a pending consent's key in the box's signed roster. + // Approvals: a pending consent's key in the box's signed roster, for + // the client the offer named. for _, d := range listed { ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) - if err != nil || !ok { - continue - } - if cloudwire.DeviceID(d.Key) != d.ID { + if err != nil || !ok || d.Agent.Principal.ClientID != c.ClientID { continue } if err := s.keys.Keep(ctx, c.Kid); err != nil { @@ -306,8 +305,8 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { continue } c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) - c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Scopes), " ") - if err := s.st.approve(ridHash, c, s.expiry(d), now); err != nil { + c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Agent.Scopes), " ") + if err := s.st.approve(ridHash, c, grantOf(d), now); err != nil { continue } s.hub.notify(string(ridHash)) @@ -321,20 +320,31 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { } }) } - // Revocations: an agent the roster no longer lists, or lists with - // another key. + // Every agent the roster lists keeps the grant the box gives it now: + // an owner may widen, narrow or renew it. One it no longer lists, or + // lists with another key, is revoked. agents, err := s.st.agentsOf(ev.home) if err != nil { return } for _, a := range agents { - if d, ok := listed[a.Agent]; ok && string(d.Key) == string(a.Key) { + d, ok := listed[a.Agent] + if !ok || string(d.Key) != string(a.Key) { + s.revoke(ctx, a) continue } - s.revoke(ctx, a) + if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { + s.log.Warn("could not update an agent's grant", "err", err) + } } } +// grantOf is an agent row's grant, as the box signed it in the roster. +func grantOf(d cloudwire.Device) agentGrant { + return agentGrant{Scopes: slices.Clone(d.Agent.Scopes), Ops: slices.Clone(d.Agent.Ops), Caps: d.Agent.Caps, + Expires: time.UnixMilli(d.Agent.ExpiresAtMs)} +} + func (s *Server) revoke(ctx context.Context, a agentRow) { if err := s.st.dropAgent(a.Home, a.Agent); err != nil { s.log.Warn("could not drop an agent", "err", err) @@ -372,13 +382,6 @@ func (s *Server) shard(home string) string { return s.Shard(home) } -func (s *Server) expiry(d cloudwire.Device) time.Time { - if s.AgentExpiry == nil { - return time.Time{} - } - return s.AgentExpiry(d) -} - // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index c2b5e8b..0524b6d 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "github.com/fxamacker/cbor/v2" "io" "log/slog" "net" @@ -25,6 +26,7 @@ import ( "time" "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -276,8 +278,25 @@ func (g *rig) roster(h string, devs ...cloudwire.Device) { time.Sleep(20 * time.Millisecond) } +// agentDevice is an agent row as the box lists it in its roster, for the +// metadata-document client, ending in 30 days. func agentDevice(key []byte, scopes ...string) cloudwire.Device { - return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: "agent", Scopes: scopes, Epoch: 1, Tun: true} + return agentFor(key, cimdID, time.Now().Add(30*24*time.Hour), scopes...) +} + +// agentFor is an agent row for a client, with the end the box set. The +// readable copy's scopes are empty, as the contract has it for agents. +func agentFor(key []byte, clientID string, end time.Time, scopes ...string) cloudwire.Device { + var ops []string + for op, sc := range protocol.RegistryAgentOps { + if slices.Contains(scopes, sc) { + ops = append(ops, op) + } + } + slices.Sort(ops) + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Tun: true, + Agent: &cloudwire.Agent{Scopes: scopes, Ops: ops, Caps: protocol.Caps{HoldS: 600, MaxW: 5000, PerMin: 6}, + ExpiresAtMs: end.UnixMilli(), Principal: protocol.Principal{Client: "Claude", ClientID: clientID}}} } // redirectOf reads the client's redirect from an event. @@ -335,7 +354,7 @@ func (g *rig) approve(f *flow, clientID string, scopes ...string) tokens { if len(scopes) == 0 { scopes = []string{ScopeLiveRead, ScopePlanRead} } - g.roster(home, agentDevice(f.agentKey(g.t), scopes...)) + g.roster(home, agentFor(f.agentKey(g.t), clientID, g.now().Add(30*24*time.Hour), scopes...)) ev := next(g.t, ch) if ev.name != "approved" { g.t.Fatalf("event %q after the roster", ev.name) @@ -406,7 +425,7 @@ func getJSON(t *testing.T, u string, v any) { func TestTheFullFlowWithAMetadataDocument(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead+" "+ScopeHistoryRead) - if f.view.Client.Name != "Claude" || !f.view.Client.Verified || f.view.Client.Host != "example.com" || + if f.view.Client.Name != "Claude" || f.view.Client.Verified || f.view.Client.Host != "example.com" || f.view.Redirect.Host != "example.com" || f.view.Status != statusPending || len(f.view.Code) != codeLen { t.Fatalf("what the app sees: %+v", f.view) } @@ -578,7 +597,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { } ch, stop := g.events(f, true) next(t, ch) - g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + g.roster(home, agentFor(f.agentKey(t), id, time.Now().Add(time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") stop() if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { @@ -594,7 +613,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { ch, stop = g.events(f, true) defer stop() next(t, ch) - g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + g.roster(home, agentFor(f.agentKey(t), cid, time.Now().Add(time.Hour), ScopeLiveRead)) code = redirectOf(t, next(t, ch)).Query().Get("code") form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} if status, _ := g.token(form); status != http.StatusUnauthorized { @@ -623,15 +642,24 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } - // Another key, the right key without a tunnel, the right key under - // another id: none of them approves. + // Another key; the right key without a tunnel, under another id, for + // another client, past its end, as support, or as a phone: none of them + // approves. other := bytes.Repeat([]byte{1}, 32) key := f.agentKey(t) noTun := agentDevice(key, ScopeLiveRead) noTun.Tun = false wrongID := agentDevice(key, ScopeLiveRead) wrongID.ID = "someone" - g.roster(home, agentDevice(other, ScopeLiveRead), noTun, wrongID) + otherClient := agentFor(key, "https://evil.example/client.json", time.Now().Add(time.Hour), ScopeLiveRead) + ended := agentFor(key, cimdID, g.now().Add(-time.Second), ScopeLiveRead) + support := agentDevice(key, ScopeLiveRead) + support.Role = protocol.RoleSupport + phone := agentDevice(key, ScopeLiveRead) + phone.Agent = nil + for _, d := range []cloudwire.Device{noTun, wrongID, otherClient, ended, support, phone} { + g.roster(home, agentDevice(other, ScopeLiveRead), d) + } select { case ev := <-browser: t.Fatalf("event %q before the roster listed the agent", ev.name) @@ -728,10 +756,13 @@ func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { // An agent whose time the box set runs out fails, and is swept. func TestAnExpiredAgentFails(t *testing.T) { g := newRig(t) - end := g.now().Add(2 * time.Hour) - g.s.AgentExpiry = func(cloudwire.Device) time.Time { return end } f := g.start(cimdID, cimdBack, "") - tk := g.approve(f, cimdID) + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentFor(f.agentKey(t), cimdID, g.now().Add(2*time.Hour), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}}) if _, aerr := g.bearer(tk.Access); aerr != nil { t.Fatal(aerr) } @@ -755,7 +786,7 @@ func TestWrongAudienceIsRefused(t *testing.T) { f := g.start(cimdID, cimdBack, "") tk := g.approve(f, cimdID) agent := cloudwire.DeviceID(f.agentKey(t)) - other := "ftwat_" + randomToken(32) + other := "ftwat_" + home + "." + randomToken(32) if err := g.s.st.putToken(other, tokenRow{Kind: kindAccess, Family: []byte("f"), ClientID: cimdID, Home: home, Agent: agent, Scope: ScopeLiveRead, Resource: "https://cloud.ftw.energy/other", Expires: g.now().Add(time.Minute)}); err != nil { t.Fatal(err) @@ -804,9 +835,13 @@ func TestTheBoxPageFindsARequestByItsCode(t *testing.T) { typed := strings.ToLower(f.view.Code[:3]) + " - " + f.view.Code[3:] var v requestView getJSON(t, g.srv.URL+"/oauth/requests?code="+url.QueryEscape(typed), &v) - if v.Agent.Key != f.view.Agent.Key || v.Request != "" { + if v.Agent.Key != f.view.Agent.Key || v.Request != "" || v.Offer == "" || v.Offer != f.view.Offer { t.Fatalf("by code: %+v", v) } + // Once. + if resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=" + f.view.Code); resp.StatusCode != http.StatusNotFound { + t.Fatalf("a code used twice: %d", resp.StatusCode) + } resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") if resp.StatusCode != http.StatusNotFound { t.Fatalf("an unknown code: %d", resp.StatusCode) @@ -834,7 +869,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?", "Identified by example.com", "127.0.0.1:8123", + for _, want := range []string{"Let Claude into your home?", "From example.com. FTW Cloud does not know this client", "127.0.0.1:8123", "a program on this computer", " 0 { + caps.PerMin = protocol.PerMinDefault + } + return protocol.GrantOffer{V: protocol.GrantOfferVersion, Key: c.AgentKey, Role: protocol.RoleAgent, + Client: c.ClientName, ClientID: c.ClientID, Scopes: scopes, Ops: ops, Caps: caps, + TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code} +} + +// signOffer encodes and signs an offer, for the link's fragment. +func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { + raw, err := detEnc.Marshal(o) + if err != nil { + return "", err + } + sig, err := s.keys.SignOffer(ctx, raw) + if err != nil { + return "", err + } + out, err := detEnc.Marshal(signedOffer{Offer: raw, Sig: sig}) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(out), nil +} + +// appLink is the universal link the QR and the button carry: the request +// id in the path, for the app's answer, and the signed offer in the +// fragment, which no server sees. +func appLink(rid, offer string) string { return AppLink + rid + "#" + offer } + +// verifiedHosts are clients FTW Cloud knows by the host of their metadata +// document. A client from one of them is shown as verified; any other is +// shown by its host and the name it gives itself. +var verifiedHosts = []string{"claude.ai", "claude.com", "anthropic.com", "chatgpt.com", "openai.com", "grok.com", "x.ai"} + +func verifiedHost(host string) bool { + host = strings.ToLower(host) + for _, h := range verifiedHosts { + if host == h || strings.HasSuffix(host, "."+h) { + return true + } + } + return false +} + +// offerKey serves the offer key's public half, so anyone can check what the +// app pins. +type offerKey struct { + mu sync.Mutex + pub []byte +} + +func (s *Server) serveOfferKey(w http.ResponseWriter, r *http.Request) { + s.offerKey.mu.Lock() + pub := s.offerKey.pub + s.offerKey.mu.Unlock() + if pub == nil { + p, err := s.keys.OfferKey(r.Context()) + if err != nil { + http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) + return + } + s.offerKey.mu.Lock() + s.offerKey.pub, pub = p, p + s.offerKey.mu.Unlock() + } + writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": "ftw.offer.v1", + "pub": base64.RawURLEncoding.EncodeToString(pub)}, true) +} diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 6fabee0..5996afc 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -21,6 +21,7 @@ type pageData struct { Name string Host string Verified bool + FromDocument bool RedirectHost string Local bool Scopes []string @@ -38,9 +39,9 @@ type pageError struct { func (s *Server) consentPage(w http.ResponseWriter, rid string, c consent) { u, _ := url.Parse(c.RedirectURI) - d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, + d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID), RedirectHost: u.Host, Local: u.Scheme == "http" && isLoopback(u.Hostname()), - Code: c.Code[:3] + "-" + c.Code[3:], Link: AppLink + rid} + Code: c.Code[:3] + "-" + c.Code[3:], Link: appLink(rid, c.Offer)} for _, sc := range splitScope(c.Scope) { d.Scopes = append(d.Scopes, scopeWords[sc]) if !slices.Contains(ReadScopes, sc) { @@ -74,7 +75,8 @@ func pageHeaders(w http.ResponseWriter, nonce string) { // qrSVG draws text as a QR code, dark on light whatever the page's theme, // with the quiet zone the standard asks for. func qrSVG(text string) template.HTML { - code, err := qr.Encode(text, qr.M) + // Level L: the link carries the signed offer, and a screen is clean. + code, err := qr.Encode(text, qr.L) if err != nil { return "" } @@ -100,7 +102,7 @@ const pageStyle = ` 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} -.qr{width:168px;height:168px;flex:none;background:#fff;border-radius:8px;padding:4px}.qr svg{width:100%;height:100%} +.qr{width:232px;height:232px;flex:none;background:#fff;border-radius:8px;padding:4px}.qr svg{width:100%;height:100%} .code{font:600 1.75rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;margin:4px 0 0} a.button{display:inline-block;background:var(--accent);color:#fff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:600} @media (prefers-color-scheme:dark){a.button{color:#0b1a12}} @@ -113,7 +115,7 @@ var consentTmpl = template.Must(template.New("consent").Parse(` Connect an assistant · FTW Cloud

Let {{.Name}} into your home?

-

{{if .Verified}}Identified by {{.Host}}.{{else}}This name is what the assistant registered with. FTW Cloud has not checked it.{{end}}

+

{{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}}

It asks to

diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 980dcc9..c8f1ac5 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/srcfl/ftw/go/protocol" _ "modernc.org/sqlite" ) @@ -62,7 +63,9 @@ CREATE TABLE IF NOT EXISTS requests ( shard TEXT NOT NULL DEFAULT '', authz BLOB UNIQUE, authz_ms INTEGER NOT NULL DEFAULT 0, - authz_used INTEGER NOT NULL DEFAULT 0 + authz_used INTEGER NOT NULL DEFAULT 0, + offer TEXT NOT NULL DEFAULT '', + code_used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS agents ( home TEXT NOT NULL, @@ -71,6 +74,8 @@ CREATE TABLE IF NOT EXISTS agents ( kid TEXT NOT NULL, client_id TEXT NOT NULL, scope TEXT NOT NULL, + ops TEXT NOT NULL, + caps TEXT NOT NULL, shard TEXT NOT NULL, expires_ms INTEGER NOT NULL, created_ms INTEGER NOT NULL, @@ -169,7 +174,7 @@ type consent struct { ClientID string ClientName string ClientHost string - Verified bool // the client's name came from a document at its own https URL + Verified bool // a client FTW Cloud knows (verifiedHosts) RedirectURI string State string Challenge string @@ -184,17 +189,19 @@ type consent struct { Agent string Granted string Shard string + // Offer is the signed grant offer, base64url, as the link carries it. + Offer string } const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, - kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard` + kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard, offer` func scanConsent(row interface{ Scan(...any) error }) (consent, error) { var c consent var verified int var created, expires int64 err := row.Scan(&c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, - &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard, &c.Offer) c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) return c, err } @@ -205,10 +212,10 @@ func (s *store) putConsent(rid, browser string, c consent) error { verified = 1 } _, err := s.db.Exec(`INSERT INTO requests (rid, browser, `+consentCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash(rid), hash(browser), c.Code, c.ClientID, c.ClientName, c.ClientHost, verified, c.RedirectURI, c.State, c.Challenge, c.Scope, c.Resource, c.Kid, c.AgentKey, c.Created.UnixMilli(), c.Expires.UnixMilli(), c.Status, - c.Home, c.Agent, c.Granted, c.Shard) + c.Home, c.Agent, c.Granted, c.Shard, c.Offer) return err } @@ -216,7 +223,17 @@ func (s *store) consentByRID(rid string) (consent, bool, error) { return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE rid = ?`, hash(rid)) } -func (s *store) consentByCode(code string) (consent, bool, error) { +// spendCode finds a pending request by its short code, once: a code that +// found its request finds nothing again. +func (s *store) spendCode(code string, now time.Time) (consent, bool, error) { + res, err := s.db.Exec(`UPDATE requests SET code_used = 1 WHERE code = ? AND code_used = 0 AND status = ? AND expires_ms > ?`, + code, statusPending, now.UnixMilli()) + if err != nil { + return consent{}, false, err + } + if n, _ := res.RowsAffected(); n != 1 { + return consent{}, false, nil + } return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE code = ?`, code) } @@ -243,7 +260,7 @@ func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c conse var verified int var created, expires int64 err = row.Scan(&ridHash, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, - &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard, &c.Offer) if errors.Is(err, sql.ErrNoRows) { return nil, c, false, nil } @@ -251,9 +268,18 @@ func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c conse return ridHash, c, err == nil, err } +// agentGrant is what the box's roster grants an agent now. +type agentGrant struct { + Scopes []string + Ops []string + Caps protocol.Caps + Expires time.Time +} + // approve marks a consent approved for an agent of a home, and records the // agent, in one transaction. -func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) error { +func (s *store) approve(ridHash []byte, c consent, g agentGrant, now time.Time) error { + caps, _ := json.Marshal(g.Caps) tx, err := s.db.Begin() if err != nil { return err @@ -269,11 +295,13 @@ func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) if n, _ := res.RowsAffected(); n != 1 { return errGone } - if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, shard, expires_ms, created_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, ops, caps, shard, expires_ms, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (home, agent) DO UPDATE SET agent_key = excluded.agent_key, kid = excluded.kid, - client_id = excluded.client_id, scope = excluded.scope, shard = excluded.shard, expires_ms = excluded.expires_ms`, - c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, c.Granted, c.Shard, agentExpires.UnixMilli(), now.UnixMilli()); err != nil { + client_id = excluded.client_id, scope = excluded.scope, ops = excluded.ops, caps = excluded.caps, + shard = excluded.shard, expires_ms = excluded.expires_ms`, + c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, strings.Join(g.Scopes, " "), strings.Join(g.Ops, " "), string(caps), + c.Shard, g.Expires.UnixMilli(), now.UnixMilli()); err != nil { return err } return tx.Commit() @@ -321,7 +349,7 @@ func (s *store) redeem(code string, now time.Time) (c consent, ridHash []byte, r var created, expires int64 err = row.Scan(&ridHash, &used, &ms, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, - &c.Agent, &c.Granted, &c.Shard) + &c.Agent, &c.Granted, &c.Shard, &c.Offer) if errors.Is(err, sql.ErrNoRows) { return c, nil, false, errGone } @@ -348,53 +376,65 @@ var errGone = errors.New("oauth: gone") // -------------------------------------------------------------------------- // agentRow is an agent an owner approved, as the authorization server knows -// it. +// it: the grant is the one the box's newest roster lists. type agentRow struct { Home string Agent string Key []byte Kid string ClientID string - Scope string Shard string - Expires time.Time // zero when the box set no end + agentGrant } -func (s *store) agent(home, agent string) (agentRow, bool, error) { +const agentCols = `home, agent, agent_key, kid, client_id, scope, ops, caps, shard, expires_ms` + +func scanAgent(row interface{ Scan(...any) error }) (agentRow, error) { var a agentRow + var scope, ops, caps string var ms int64 - err := s.db.QueryRow(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ? AND agent = ?`, - home, agent).Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms) + err := row.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &scope, &ops, &caps, &a.Shard, &ms) + if err != nil { + return a, err + } + a.Scopes, a.Ops, a.Expires = splitScope(scope), splitScope(ops), time.UnixMilli(ms) + _ = json.Unmarshal([]byte(caps), &a.Caps) + return a, nil +} + +func (s *store) agent(home, agent string) (agentRow, bool, error) { + a, err := scanAgent(s.db.QueryRow(`SELECT `+agentCols+` FROM agents WHERE home = ? AND agent = ?`, home, agent)) if errors.Is(err, sql.ErrNoRows) { return a, false, nil } - if ms > 0 { - a.Expires = time.UnixMilli(ms) - } return a, err == nil, err } func (s *store) agentsOf(home string) ([]agentRow, error) { - rows, err := s.db.Query(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ?`, home) + rows, err := s.db.Query(`SELECT `+agentCols+` FROM agents WHERE home = ?`, home) if err != nil { return nil, err } defer rows.Close() var out []agentRow for rows.Next() { - var a agentRow - var ms int64 - if err := rows.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms); err != nil { + a, err := scanAgent(rows) + if err != nil { return nil, err } - if ms > 0 { - a.Expires = time.UnixMilli(ms) - } out = append(out, a) } return out, rows.Err() } +// setGrant records the grant the newest roster lists for an agent. +func (s *store) setGrant(home, agent string, g agentGrant) error { + caps, _ := json.Marshal(g.Caps) + _, err := s.db.Exec(`UPDATE agents SET scope = ?, ops = ?, caps = ?, expires_ms = ? WHERE home = ? AND agent = ?`, + strings.Join(g.Scopes, " "), strings.Join(g.Ops, " "), string(caps), g.Expires.UnixMilli(), home, agent) + return err +} + // dropAgent deletes an agent and, by the foreign key, every token it holds. func (s *store) dropAgent(home, agent string) error { _, err := s.db.Exec(`DELETE FROM agents WHERE home = ? AND agent = ?`, home, agent) @@ -402,7 +442,7 @@ func (s *store) dropAgent(home, agent string) error { } func (s *store) expiredAgents(now time.Time) ([]agentRow, error) { - rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms > 0 AND expires_ms <= ?`, now.UnixMilli()) + rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms <= ?`, now.UnixMilli()) if err != nil { return nil, err } diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 26255a0..7ebfabb 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "github.com/srcfl/ftw/go/protocol" "net/http" "regexp" "slices" @@ -14,12 +15,27 @@ import ( var verifierRE = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) -// Token prefixes, so a leaked token is recognisable for what it is. +// Token prefixes, so a leaked token is recognisable for what it is. After +// the prefix comes the home, then a dot and the secret, so any instance can +// tell which shard holds a token's home without a lookup. const ( accessPrefix = "ftwat_" refreshPrefix = "ftwrt_" ) +// TokenHome is the home a token names, or "" for something else. It +// proves nothing: only the stored hash does. +func TokenHome(tok string) string { + for _, p := range []string{accessPrefix, refreshPrefix} { + if rest, ok := strings.CutPrefix(tok, p); ok { + if home, _, ok := strings.Cut(rest, "."); ok { + return home + } + } + } + return "" +} + func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { @@ -126,14 +142,14 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { // The refresh token ends no later than refreshEnd or the agent. func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a agentRow, scope string, refreshEnd time.Time) { now := s.now() - if !a.Expires.IsZero() && a.Expires.Before(refreshEnd) { + if a.Expires.Before(refreshEnd) { refreshEnd = a.Expires } accessEnd := now.Add(AccessTTL) if accessEnd.After(refreshEnd) { accessEnd = refreshEnd } - access, refresh := accessPrefix+randomToken(32), refreshPrefix+randomToken(32) + access, refresh := accessPrefix+a.Home+"."+randomToken(32), refreshPrefix+a.Home+"."+randomToken(32) base := tokenRow{Family: family, ClientID: clientID, Home: a.Home, Agent: a.Agent, Scope: scope, Resource: Resource} at, rt := base, base at.Kind, at.Expires = kindAccess, accessEnd @@ -155,8 +171,10 @@ func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a }, false) } +// agentGone is an agent past the end its box set. The service stops +// admitting it by its own clock, without waiting for a roster. func (s *Server) agentGone(a agentRow) bool { - return !a.Expires.IsZero() && !s.now().Before(a.Expires) + return !s.now().Before(a.Expires) } func tokenError(w http.ResponseWriter, status int, code, desc string) { @@ -208,12 +226,20 @@ type Subject struct { // SubjectAgent is a personal agent an owner approved. const SubjectAgent = "agent" -// Grant is what one valid access token allows. +// Grant is what one valid access token allows: the rights the box grants +// its agent now, as the newest roster lists them. An owner who narrows the +// grant narrows the token at once; one who widens it widens the token too. type Grant struct { Subject Subject ClientID string Scopes []string - Expires time.Time + // Ops and Caps are the grant's operations and caps. The service may + // hold a call to them before the box does; it never loosens them. + Ops []string + Caps protocol.Caps + Expires time.Time + // AgentExpires is when the box ends the agent. + AgentExpires time.Time } // Has reports whether the grant carries a scope. @@ -253,7 +279,7 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { } return &Grant{ Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, - ClientID: t.ClientID, Scopes: splitScope(t.Scope), Expires: t.Expires, + ClientID: t.ClientID, Scopes: a.Scopes, Ops: a.Ops, Caps: a.Caps, Expires: t.Expires, AgentExpires: a.Expires, }, nil } From 0224dd8ae52dff9e15c74baa3911389b0fdc9146 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:13:27 +0200 Subject: [PATCH 10/28] feat(oauth): the final agent contract: SignedOffer {o, kid, sig}, rows without tun srcfl/ftw#1642 at b7d1cc80 (pinned until it merges): - The offer is protocol.GrantOffer with clientHost (the metadata document's host, or a registered client's redirect host), verified and exp (the request's end, at most ten minutes), sent as protocol.SignedOffer {o, kid, sig}; protocol.OpenOffer opens it with the key pinned by keys.OfferKid. Only loadpoint.hold is a hold op now. - Agent rows carry tun false: approval needs the agent grant, the role, the client and an end ahead, not a tunnel. - The code lookup answers any origin, as the box's page fetches it from the owner's browser; the offer key's endpoint names its kid. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++--- internal/oauth/authorize.go | 15 +++++++-- internal/oauth/oauth.go | 10 +++--- internal/oauth/oauth_test.go | 64 +++++++++++++++++++++--------------- internal/oauth/offer.go | 59 +++++++++++++++++++-------------- 5 files changed, 98 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c448b19..4e45f41 100644 --- a/README.md +++ b/README.md @@ -136,15 +136,17 @@ to it yet. registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older clients. There is no account and no password: `/oauth/authorize` asks `ftwkeysd` for a new agent key and has it sign the grant offer - (srcfl/ftw `docs/agents.md`): the key, the client, the scopes, ops and - caps asked for, and the page's code, with no home, since the owner picks - it. The page shows the client's name, where the browser goes back, the + (srcfl/ftw `docs/agents.md`), sent as `{o, kid, sig}`: the key, the + client, the host it is known by and whether FTW Cloud knows it, the + scopes, ops and caps asked for, the page's code, and an end ten minutes + out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the rights, the code, a QR and `https://app.ftw.energy/a/#`; - the app checks the offer against the offer key it pins, which + the app checks the offer against the offer key it pins by kid, which `/.well-known/ftw-offer-key` serves. The box's page or an owner who types the code finds the offer once at `/oauth/requests?code=`. The owner approves in the app or on the box's page; the box lists the agent's key, - for that client, in the roster it signs, and when that roster arrives the + for that client, in the roster it signs (an agent row, with no tunnel of + its own), and when that roster arrives the page, waiting on a server-sent event, returns to the client with a code. Only the browser that started the request gets it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one revokes its family. A diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 280a341..932b78d 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -114,7 +114,16 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } now := s.now() rid, browser := randomToken(16), randomToken(32) - con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: c.Host, Verified: c.Verified, RedirectURI: redirect, + // The host the owner is shown: the metadata document's, or for a + // registered client the redirect URI's, since the name it registered + // proves nothing. + host := c.Host + if !isCIMD(c.ID) { + if u, err := url.Parse(redirect); err == nil { + host = u.Hostname() + } + } + con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: host, Verified: c.Verified, RedirectURI: redirect, State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { @@ -279,7 +288,9 @@ func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - allowApp(w, r) + // The box's page fetches it from the owner's browser, on whatever + // address the box has at home. + w.Header().Set("Access-Control-Allow-Origin", "*") writeJSON(w, http.StatusOK, s.view(c), false) } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index a8445d3..2d118e0 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -282,12 +282,14 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() retry := false - // The rows this service serves: a personal agent's, with its grant, a - // tunnel and an end still ahead. Support and release rows reach their - // homes another way. + // The rows this service serves: a personal agent's, with its grant and + // an end still ahead. Support and release rows reach their homes + // another way. listed := map[string]cloudwire.Device{} for _, d := range ev.devices { - if d.Tun && d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + // An agent row has no tunnel of its own (tun is false): only the + // keys part opens its session. + if d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { listed[d.ID] = d } diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 0524b6d..cbed988 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -284,8 +284,9 @@ func agentDevice(key []byte, scopes ...string) cloudwire.Device { return agentFor(key, cimdID, time.Now().Add(30*24*time.Hour), scopes...) } -// agentFor is an agent row for a client, with the end the box set. The -// readable copy's scopes are empty, as the contract has it for agents. +// agentFor is an agent row for a client, with the end the box set. Its +// copy scopes are empty and it has no tunnel of its own, as the contract +// has it for agents. func agentFor(key []byte, clientID string, end time.Time, scopes ...string) cloudwire.Device { var ops []string for op, sc := range protocol.RegistryAgentOps { @@ -294,7 +295,7 @@ func agentFor(key []byte, clientID string, end time.Time, scopes ...string) clou } } slices.Sort(ops) - return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Tun: true, + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Agent: &cloudwire.Agent{Scopes: scopes, Ops: ops, Caps: protocol.Caps{HoldS: 600, MaxW: 5000, PerMin: 6}, ExpiresAtMs: end.UnixMilli(), Principal: protocol.Principal{Client: "Claude", ClientID: clientID}}} } @@ -642,13 +643,10 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } - // Another key; the right key without a tunnel, under another id, for - // another client, past its end, as support, or as a phone: none of them - // approves. + // Another key; the right key under another id, for another client, + // past its end, as support, or as a phone: none of them approves. other := bytes.Repeat([]byte{1}, 32) key := f.agentKey(t) - noTun := agentDevice(key, ScopeLiveRead) - noTun.Tun = false wrongID := agentDevice(key, ScopeLiveRead) wrongID.ID = "someone" otherClient := agentFor(key, "https://evil.example/client.json", time.Now().Add(time.Hour), ScopeLiveRead) @@ -657,7 +655,7 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { support.Role = protocol.RoleSupport phone := agentDevice(key, ScopeLiveRead) phone.Agent = nil - for _, d := range []cloudwire.Device{noTun, wrongID, otherClient, ended, support, phone} { + for _, d := range []cloudwire.Device{wrongID, otherClient, ended, support, phone} { g.roster(home, agentDevice(other, ScopeLiveRead), d) } select { @@ -949,9 +947,10 @@ func TestApprovalOutlastsAKeysPartThatIsAway(t *testing.T) { } } -// The offer: signed by the keys part's offer key, carrying the agent's key, -// the client, what it asks for and the code, and no home; in the QR, the -// link and the request's details alike. +// The offer: the contract's SignedOffer, which protocol.OpenOffer opens +// with the offer key pinned by its kid. It carries the agent's key, the +// client and its host, what it asks for, the code and an end within ten +// minutes, and no home; in the QR, the link and the request's details alike. func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopeDispatchHold) @@ -959,25 +958,28 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { if err != nil { t.Fatal(err) } - var so signedOffer + var so protocol.SignedOffer if err := cbor.Unmarshal(raw, &so); err != nil { t.Fatal(err) } pub, _ := g.keys.OfferKey(context.Background()) - if err := keys.VerifyOffer(pub, so.Offer, so.Sig); err != nil { - t.Fatalf("the offer does not verify: %v", err) - } - var o protocol.GrantOffer - if err := cbor.Unmarshal(so.Offer, &o); err != nil { - t.Fatal(err) + o, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, so, g.now().UnixMilli()) + if err != nil { + t.Fatalf("the offer does not open: %v", err) } - if o.V != protocol.GrantOfferVersion || o.Home != "" || !bytes.Equal(o.Key, f.agentKey(t)) || o.Role != protocol.RoleAgent || - o.Client != "Claude" || o.ClientID != cimdID || o.Code != f.view.Code || - !slices.Equal(o.Scopes, []string{ScopeDispatchHold, ScopeLiveRead}) || - !slices.Equal(o.Ops, []string{"battery.hold", "loadpoint.boost", "loadpoint.hold"}) || o.Caps.HoldS == 0 || - o.TTLS != uint64(protocol.AgentMaxMs/1000) { + if o.Home != "" || !bytes.Equal(o.Key, f.agentKey(t)) || o.Role != protocol.RoleAgent || o.Client != "Claude" || + o.ClientID != cimdID || o.ClientHost != "example.com" || o.Verified || o.Code != f.view.Code || + !slices.Equal(o.Scopes, []string{ScopeDispatchHold, ScopeLiveRead}) || !slices.Equal(o.Ops, []string{"loadpoint.hold"}) || + o.Caps.HoldS == 0 || o.TTLS != uint64(protocol.AgentMaxMs/1000) || o.ExpMs > g.now().UnixMilli()+protocol.OfferTTLMs { t.Fatalf("offer %+v", o) } + // Another key does not open it, and it ends. + if _, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): bytes.Repeat([]byte{4}, 65)}, so, g.now().UnixMilli()); err == nil { + t.Fatal("an offer opened with another key") + } + if _, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, so, o.ExpMs); err == nil { + t.Fatal("an offer opened at its end") + } // The page's link and QR carry the same offer, in the fragment. q, _ := g.authorizeParams(cimdID, cimdBack, "") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) @@ -985,14 +987,22 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { if !regexp.MustCompile(`href="https://app\.ftw\.energy/a/[A-Za-z0-9_-]{22}#[A-Za-z0-9_-]{200,}"`).Match(page) { t.Fatal("the app link does not carry the offer in its fragment") } + // A registered client is shown by its redirect URI's host. + resp2, _ := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(`{"client_name":"X","redirect_uris":["https://x.example/cb"]}`)) + var reg map[string]any + _ = json.NewDecoder(resp2.Body).Decode(®) + f2 := g.start(reg["client_id"].(string), "https://x.example/cb", "") + if f2.view.Client.Host != "x.example" { + t.Fatalf("a registered client's host: %q", f2.view.Client.Host) + } } func TestTheOfferKeyIsPublished(t *testing.T) { g := newRig(t) - var v struct{ Alg, Tag, Pub string } + var v struct{ Alg, Tag, Kid, Pub string } getJSON(t, g.srv.URL+"/.well-known/ftw-offer-key", &v) pub, _ := g.keys.OfferKey(context.Background()) - if v.Alg != "ES256" || v.Tag != keys.OfferTag || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { + if v.Alg != "ES256" || v.Tag != protocol.OfferTag || v.Kid != keys.OfferKid(pub) || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { t.Fatalf("offer key %+v", v) } } @@ -1027,7 +1037,7 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } g.roster(home, agentFor(key, cimdID, g.now().Add(3*time.Hour), ScopeLiveRead, ScopePlanRead, ScopeDispatchHold)) grant, _ = g.bearer(tk.Access) - if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "battery.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { + if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "loadpoint.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { t.Fatalf("after widening and renewing: %+v", grant) } g.advance(3*time.Hour + time.Second) diff --git a/internal/oauth/offer.go b/internal/oauth/offer.go index eedb7a4..d2437dd 100644 --- a/internal/oauth/offer.go +++ b/internal/oauth/offer.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw-cloud/internal/keys" "github.com/srcfl/ftw/go/protocol" ) @@ -18,13 +19,6 @@ import ( // fragment; the short code finds it once, for the box's page or an owner // who types it. -// signedOffer travels base64url without padding: the offer's deterministic -// CBOR and the offer key's signature over keys.OfferBytes of it. -type signedOffer struct { - Offer []byte `cbor:"offer"` - Sig []byte `cbor:"sig"` -} - var detEnc = func() cbor.EncMode { m, err := cbor.CoreDetEncOptions().EncMode() if err != nil { @@ -56,12 +50,18 @@ func buildOffer(c consent) protocol.GrantOffer { caps.PerMin = protocol.PerMinDefault } return protocol.GrantOffer{V: protocol.GrantOfferVersion, Key: c.AgentKey, Role: protocol.RoleAgent, - Client: c.ClientName, ClientID: c.ClientID, Scopes: scopes, Ops: ops, Caps: caps, - TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code} + Client: c.ClientName, ClientID: c.ClientID, ClientHost: c.ClientHost, Verified: c.Verified, + Scopes: scopes, Ops: ops, Caps: caps, TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code, + ExpMs: min(c.Expires.UnixMilli(), c.Created.UnixMilli()+protocol.OfferTTLMs)} } -// signOffer encodes and signs an offer, for the link's fragment. +// signOffer encodes and signs an offer as the contract's SignedOffer +// {o, kid, sig}, base64url, for the link's fragment and the code lookup. func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { + pub, err := s.offerPub(ctx) + if err != nil { + return "", err + } raw, err := detEnc.Marshal(o) if err != nil { return "", err @@ -70,13 +70,31 @@ func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, if err != nil { return "", err } - out, err := detEnc.Marshal(signedOffer{Offer: raw, Sig: sig}) + out, err := detEnc.Marshal(protocol.SignedOffer{O: raw, Kid: keys.OfferKid(pub), Sig: sig}) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(out), nil } +// offerPub is the offer key's public half, asked of the keys part once. +func (s *Server) offerPub(ctx context.Context) ([]byte, error) { + s.offerKey.mu.Lock() + pub := s.offerKey.pub + s.offerKey.mu.Unlock() + if pub != nil { + return pub, nil + } + pub, err := s.keys.OfferKey(ctx) + if err != nil { + return nil, err + } + s.offerKey.mu.Lock() + s.offerKey.pub = pub + s.offerKey.mu.Unlock() + return pub, nil +} + // appLink is the universal link the QR and the button carry: the request // id in the path, for the app's answer, and the signed offer in the // fragment, which no server sees. @@ -105,19 +123,12 @@ type offerKey struct { } func (s *Server) serveOfferKey(w http.ResponseWriter, r *http.Request) { - s.offerKey.mu.Lock() - pub := s.offerKey.pub - s.offerKey.mu.Unlock() - if pub == nil { - p, err := s.keys.OfferKey(r.Context()) - if err != nil { - http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) - return - } - s.offerKey.mu.Lock() - s.offerKey.pub, pub = p, p - s.offerKey.mu.Unlock() + pub, err := s.offerPub(r.Context()) + if err != nil { + http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) + return } - writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": "ftw.offer.v1", + w.Header().Set("Access-Control-Allow-Origin", "*") + writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": protocol.OfferTag, "kid": keys.OfferKid(pub), "pub": base64.RawURLEncoding.EncodeToString(pub)}, true) } From 41d6aaecb106e4576c3cbe23844a993f6b20ad0f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:44:22 +0200 Subject: [PATCH 11/28] chore(oauth): pin the agent contract at e2559632, where protocol.OfferKid lands srcfl/ftw#1642 now derives an offer key's kid the same way keys.OfferKid does, and Core pins offer keys as a list of kid:pub. A test holds the two derivations equal. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/oauth_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index cbed988..eb04cb7 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -1002,6 +1002,10 @@ func TestTheOfferKeyIsPublished(t *testing.T) { var v struct{ Alg, Tag, Kid, Pub string } getJSON(t, g.srv.URL+"/.well-known/ftw-offer-key", &v) pub, _ := g.keys.OfferKey(context.Background()) + // The kid is the contract's, so Core's pin list takes it as it comes. + if keys.OfferKid(pub) != protocol.OfferKid(pub) { + t.Fatal("the keys part's kid is not the contract's") + } if v.Alg != "ES256" || v.Tag != protocol.OfferTag || v.Kid != keys.OfferKid(pub) || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { t.Fatalf("offer key %+v", v) } From 79e7c1c3ee5cd6d6caf01ad9ece105fade05c4ae Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:25:51 +0200 Subject: [PATCH 12/28] fix(oauth): a token allows only what it carries and the box still grants Review of srcfl/ftw-cloud#19 (N3, N4): - Authenticate grants the intersection of the token's own scopes and the agent's in the newest roster, so a narrowed refresh stays narrow and an owner's widening needs a new consent. - A bearer token must have this server's shape (prefix, home, dot, 43 characters of base64url) before the store is asked. - The token, revocation and registration endpoints give a client 10 s to send its body. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++--- internal/oauth/clients.go | 1 + internal/oauth/oauth_test.go | 38 ++++++++++++++++++++++++--- internal/oauth/token.go | 51 ++++++++++++++++++++++++++++++++---- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4e45f41..947de8b 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,10 @@ to it yet. Only the browser that started the request gets it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one revokes its family. A token names its home (`ftwat_.`) so any instance can route - it; the service keeps hashes, never tokens. A token carries the grant the - newest roster lists, narrowed or widened at once, and stops at the - agent's end by the service's own clock. A roster without the agent ends + it; the service keeps hashes, never tokens. A token allows the scopes it + carries that the newest roster still grants, so an owner who narrows a + grant narrows it at once, and it stops at the agent's end by the + service's own clock. A roster without the agent ends its tokens and its key. Not built yet: diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 0cf55c7..1b8a82b 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -126,6 +126,7 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") return } + readDeadline(w) var reg registration body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10)) if err != nil || json.Unmarshal(body, ®) != nil { diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index eb04cb7..7d13546 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -1026,9 +1026,10 @@ func TestTokensNameTheirHome(t *testing.T) { } } -// The grant is what the newest roster lists: an owner who narrows it -// narrows the token at once, one who renews it extends it, and an end -// reached by the service's own clock stops the agent without a roster. +// The grant is what the token carries and the newest roster still lists: +// an owner who narrows it narrows the token at once, wider rights need a new +// consent, a renewal extends the agent, and an end reached by the service's +// own clock stops it without a roster. func TestTheGrantFollowsTheRoster(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead) @@ -1041,7 +1042,8 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } g.roster(home, agentFor(key, cimdID, g.now().Add(3*time.Hour), ScopeLiveRead, ScopePlanRead, ScopeDispatchHold)) grant, _ = g.bearer(tk.Access) - if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "loadpoint.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { + if grant.Has(ScopeDispatchHold) || !grant.Has(ScopePlanRead) || !slices.Contains(grant.Ops, "loadpoint.hold") || + grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { t.Fatalf("after widening and renewing: %+v", grant) } g.advance(3*time.Hour + time.Second) @@ -1050,6 +1052,34 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } } +// A refresh that narrows the scope narrows what the new token allows. +func TestARefreshCanNarrow(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead) + tk := g.approve(f, cimdID, ScopeLiveRead, ScopePlanRead) + status, tk2 := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, "scope": {ScopeLiveRead}}) + if status != http.StatusOK { + t.Fatalf("refresh: %d", status) + } + grant, aerr := g.bearer(tk2.Access) + if aerr != nil || grant.Has(ScopePlanRead) || !grant.Has(ScopeLiveRead) { + t.Fatalf("a narrowed token: %+v %v", grant, aerr) + } +} + +func TestTokenShape(t *testing.T) { + good := accessPrefix + home + "." + randomToken(32) + if !tokenShape(good, accessPrefix) { + t.Fatal("a token this server makes") + } + for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), refreshPrefix + home + "." + randomToken(32), + accessPrefix + "HOME." + randomToken(32), accessPrefix + home + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { + if tokenShape(bad, accessPrefix) { + t.Errorf("%q passed", bad) + } + } +} + // Only clients FTW Cloud knows, by their document's host, are verified. func TestVerifiedHosts(t *testing.T) { for host, want := range map[string]bool{"claude.ai": true, "www.claude.ai": true, "chatgpt.com": true, "grok.com": true, diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 7ebfabb..e47a6fa 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -23,6 +23,31 @@ const ( refreshPrefix = "ftwrt_" ) +// tokenShape is a token as this server makes one: the prefix, a home id, a +// dot and 43 characters of base64url. Anything else is refused before the +// store is asked. +func tokenShape(tok, prefix string) bool { + rest, ok := strings.CutPrefix(tok, prefix) + if !ok { + return false + } + home, secret, ok := strings.Cut(rest, ".") + if !ok || len(home) == 0 || len(home) > 64 || len(secret) != 43 { + return false + } + for _, c := range home { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { + return false + } + } + for _, c := range secret { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { + return false + } + } + return true +} + // TokenHome is the home a token names, or "" for something else. It // proves nothing: only the stored hash does. func TokenHome(tok string) string { @@ -37,6 +62,7 @@ func TokenHome(tok string) string { } func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { + readDeadline(w) r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") @@ -184,6 +210,7 @@ func tokenError(w http.ResponseWriter, status int, code, desc string) { // serveRevoke is RFC 7009: revoking a refresh token revokes its family. It // answers 200 whether or not the token was known. func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { + readDeadline(w) r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") @@ -226,9 +253,11 @@ type Subject struct { // SubjectAgent is a personal agent an owner approved. const SubjectAgent = "agent" -// Grant is what one valid access token allows: the rights the box grants -// its agent now, as the newest roster lists them. An owner who narrows the -// grant narrows the token at once; one who widens it widens the token too. +// Grant is what one valid access token allows: the scopes the token +// carries that the box still grants its agent, as the newest roster lists +// them. An owner who narrows the grant narrows the token at once; wider +// rights need a new consent, so a token never holds more than its client +// was given. type Grant struct { Subject Subject ClientID string @@ -263,7 +292,11 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { return nil, &AuthError{Status: http.StatusUnauthorized, Scope: strings.Join(ReadScopes, " ")} } invalid := &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is not valid"} - t, ok, err := s.st.token(strings.TrimSpace(tok)) + tok = strings.TrimSpace(tok) + if !tokenShape(tok, accessPrefix) { + return nil, invalid + } + t, ok, err := s.st.token(tok) if err != nil || !ok || t.Kind != kindAccess || !s.now().Before(t.Expires) { return nil, invalid } @@ -279,7 +312,8 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { } return &Grant{ Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, - ClientID: t.ClientID, Scopes: a.Scopes, Ops: a.Ops, Caps: a.Caps, Expires: t.Expires, AgentExpires: a.Expires, + ClientID: t.ClientID, Scopes: intersect(splitScope(t.Scope), a.Scopes), Ops: a.Ops, Caps: a.Caps, + Expires: t.Expires, AgentExpires: a.Expires, }, nil } @@ -309,3 +343,10 @@ func InsufficientScope(scopes ...string) *AuthError { return &AuthError{Status: http.StatusForbidden, Code: "insufficient_scope", Desc: "this needs more rights; ask the owner to approve them", Scope: strings.Join(scopes, " ")} } + +// readDeadline bounds how long a client may take to send its request's +// body. The server sets no ReadTimeout of its own, since its WebSockets set +// their own deadlines. +func readDeadline(w http.ResponseWriter) { + _ = http.NewResponseController(w).SetReadDeadline(time.Now().Add(10 * time.Second)) +} From fccb46d0ca068352974fbec1ab6c4de8cc6469e1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 23:04:44 +0200 Subject: [PATCH 13/28] fix(oauth): review of #19: the newest roster only, purges, no open redirect, exact verified clients 1. Only the newest roster per home waits to be applied, so nothing drops and nothing old replays. A retry after the keys part was away runs the approval pass alone, on the newest roster. 2. A purge reaches the authorization server (uplink.OnPurge -> DropHome): the home's agents, their tokens and keys, and its consents go. A roster from before the purge is not applied after it. 3. A client FTW Cloud does not know gets an error page with a link back to its host, never a 302 before the owner acts (RFC 9700, 4.11.2). 4. Verified is an exact list of client_id URLs. Claude's is on it, read at its URL on 15 Oct 2026; ChatGPT's and Grok's documents were not found. 5. A key listed at a second home is revoked everywhere and forgotten. Codes are eight characters, drawn evenly. Lookups have a bucket per address. The request id moves into the link's fragment. 6. Per-address buckets beside the global ones, taken before the client's document is fetched. CIMD: a document is kept at least a minute, failures have a small cache of their own, and fetches are capped per host. Only the cookie holder keeps an event stream, two at most, under a global cap. Registrations with no agent go after a day. 7. The store admits an agent's, support's or the release check's row neither as a phone nor by a token. And: a key to forget stays queued, across restarts, until the keys part has forgotten it; Keep and Forget get 5 s. redirect_uri is required at the token endpoint when authorize named one. A code used again after the sweep still revokes. A refresh retried within 5 s works once more. Tokens carry their home sealed with a fresh nonce, so no client sees an id that links its tokens. Agents are unique by their full key. Support's client id is not a metadata client. HSTS on the pages. ::/96 is blocked. Claude's real document lists the JWT bearer grant, which the old check refused: a client may list grants this server does not offer, as long as it uses the code grant. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +- cmd/ftwcloudd/main.go | 2 + internal/oauth/authorize.go | 113 ++++++--- internal/oauth/cimd.go | 115 +++++---- internal/oauth/cimd_test.go | 77 +++++- internal/oauth/clients.go | 26 ++- internal/oauth/oauth.go | 411 +++++++++++++++++++++++++++------ internal/oauth/oauth_test.go | 232 +++++++++++++++---- internal/oauth/offer.go | 34 ++- internal/oauth/page.go | 27 ++- internal/oauth/review_test.go | 401 ++++++++++++++++++++++++++++++++ internal/oauth/store.go | 280 ++++++++++++++++------ internal/oauth/token.go | 133 ++++++++--- internal/store/store.go | 10 + internal/store/store_test.go | 32 +++ internal/uplink/uplink.go | 7 + internal/uplink/uplink_test.go | 10 + 17 files changed, 1591 insertions(+), 340 deletions(-) create mode 100644 internal/oauth/review_test.go diff --git a/README.md b/README.md index 947de8b..7b249ac 100644 --- a/README.md +++ b/README.md @@ -140,22 +140,31 @@ to it yet. client, the host it is known by and whether FTW Cloud knows it, the scopes, ops and caps asked for, the page's code, and an end ten minutes out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the - rights, the code, a QR and `https://app.ftw.energy/a/#`; + rights, the code, a QR and `https://app.ftw.energy/a#.`; the app checks the offer against the offer key it pins by kid, which `/.well-known/ftw-offer-key` serves. The box's page or an owner who types - the code finds the offer once at `/oauth/requests?code=`. The owner + the eight-character code finds the offer once at `/oauth/requests?code=`. + A client FTW Cloud knows by the exact URL of its document is shown as + verified, and an error goes back to it at once; any other gets a page + with a link back. Each endpoint a stranger can call is limited per + address and for everyone, before anything is fetched. The owner approves in the app or on the box's page; the box lists the agent's key, for that client, in the roster it signs (an agent row, with no tunnel of its own), and when that roster arrives the page, waiting on a server-sent event, returns to the client with a code. Only the browser that started the request gets it. Access tokens last 10 - minutes; refresh tokens rotate, and a reused one revokes its family. A - token names its home (`ftwat_.`) so any instance can route - it; the service keeps hashes, never tokens. A token allows the scopes it + minutes; refresh tokens rotate, a retry within 5 s works once more, and a + later reuse revokes the family, as does a code used twice, even after its + consent is gone. A token carries its home sealed with a fresh nonce under + the route key (`oauth-route.key`, shared by every instance that routes), + so any instance can route it and no client sees an id that links its + tokens; the service keeps hashes, never tokens. A token allows the scopes it carries that the newest roster still grants, so an owner who narrows a grant narrows it at once, and it stops at the agent's end by the service's own clock. A roster without the agent ends - its tokens and its key. + its tokens and its key, and so does a second home listing the same key, + 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. Not built yet: diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 9dde1cd..5e24efe 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -149,6 +149,8 @@ func serve(args []string) error { auth.Roster(home, r) } up.OnGone = pushSvc.BoxGone + // A purged home's agents, their tokens and keys, and its consents go. + up.OnPurge = auth.DropHome // Phones register their tokens over their app session, so without a // Noise key only the development route registers one. diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 932b78d..ff2a6f0 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -18,9 +18,9 @@ import ( // codeAlphabet makes the short code: no 0/O, 1/I or L to misread. const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -// codeLen gives about 29 bits. A code finds a request for the box's page and -// proves nothing: the owner still approves on their own box. -const codeLen = 6 +// codeLen gives about 39 bits. A code finds a request for the box's page +// once, and proves nothing: the owner still approves on their own box. +const codeLen = 8 var challengeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) @@ -42,6 +42,14 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } } + // The limits come before anything is fetched or made: each request may + // fetch a client's document and makes a key. + if s.limited(&s.authorizeLimit, s.authorizeByIP, r) { + w.Header().Set("Retry-After", "10") + s.authorizePage(w, http.StatusTooManyRequests, pageError{Title: "Too many requests", + Text: "FTW Cloud is busy connecting assistants. Try again in a little while."}) + return + } ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) defer cancel() @@ -54,6 +62,7 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } redirect := q.Get("redirect_uri") + given := redirect != "" if redirect == "" && len(c.RedirectURIs) == 1 { redirect = c.RedirectURIs[0] } @@ -63,8 +72,19 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } req := authRequest{client: c, redirectURI: redirect, state: q.Get("state")} + // An error goes back to a client FTW Cloud knows at once. Any other + // client gets a page with a link back to its host, and the person + // decides: no stranger's URL can bounce a browser through here before + // the owner acts (RFC 9700, 4.11.2). fail := func(code, desc string) { - http.Redirect(w, r, authorizationError(redirect, code, desc, req.state), http.StatusFound) + back := authorizationError(redirect, code, desc, req.state) + if c.Verified { + http.Redirect(w, r, back, http.StatusFound) + return + } + u, _ := url.Parse(redirect) + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This request cannot go on", + Text: "FTW Cloud cannot connect this assistant: " + desc + ".", Back: back, BackHost: u.Host}) } if len(req.state) > 1024 { fail("invalid_request", "state is too long") @@ -99,10 +119,6 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { if len(req.scope) == 0 { req.scope = slices.Clone(ReadScopes) } - if !s.authorizeLimit.allow(s.now()) { - fail("temporarily_unavailable", "too many requests; try again shortly") - return - } // The agent's key pair comes first; only its public half leaves the // keys part, to be shown to the owner and listed by the box. @@ -124,11 +140,11 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } } con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: host, Verified: c.Verified, RedirectURI: redirect, - State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, - Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} + RedirectGiven: given, State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), + Resource: Resource, Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { con.Code = shortCode() - if con.Offer, err = s.signOffer(ctx, buildOffer(con)); err != nil { + if con.Offer, err = s.signOffer(ctx, kid, buildOffer(con)); err != nil { s.log.Warn("the keys part could not sign an offer", "err", err) break } @@ -138,7 +154,8 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } } if err != nil { - _ = s.keys.Forget(ctx, kid) + _ = s.st.queueForget(kid, now) + s.forget(ctx, kid) fail("server_error", "could not start the request") return } @@ -163,17 +180,28 @@ func validScope(sc string) bool { return slices.Contains(AllScopes, sc) } func cookieName(rid string) string { return "__Host-ftw-consent-" + rid[:8] } +// shortCode draws codeLen characters evenly from codeAlphabet: a byte at or +// above the last whole multiple of the alphabet's size is drawn again. func shortCode() string { - b := make([]byte, codeLen) - if _, err := rand.Read(b); err != nil { - panic(err) - } - for i := range b { - b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] + const limit = 256 - 256%len(codeAlphabet) + out := make([]byte, 0, codeLen) + b := make([]byte, 2*codeLen) + for len(out) < codeLen { + if _, err := rand.Read(b); err != nil { + panic(err) + } + for _, x := range b { + if int(x) < limit && len(out) < codeLen { + out = append(out, codeAlphabet[int(x)%len(codeAlphabet)]) + } + } } - return string(b) + return string(out) } +// showCode is the code as the page shows it, in two halves. +func showCode(code string) string { return code[:codeLen/2] + "-" + code[codeLen/2:] } + // normalizeCode reads a code as an owner may type it. func normalizeCode(s string) string { s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(s)) @@ -269,11 +297,12 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { // serveRequestByCode finds a request by the code an owner typed on the box's // page or in the app, once: a short code cannot carry the agent's key, so -// it fetches the signed offer. Lookups are rate limited for everyone -// together, and a code works once: it lasts ten minutes and has about 29 -// bits, and finding one shows only an offer the owner must still approve. +// it fetches the signed offer. Lookups are rate limited per address and for +// everyone together, and a code works once: it lasts ten minutes and has +// about 39 bits, and finding one shows only an offer the owner must still +// approve. func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { - if !s.lookupLimit.allow(s.now()) { + if s.limited(&s.lookupLimit, s.lookupByIP, r) { w.Header().Set("Retry-After", "1") http.Error(w, "too many lookups", http.StatusTooManyRequests) return @@ -305,13 +334,13 @@ func allowApp(w http.ResponseWriter, r *http.Request) { // the person at the computer cancelled. func (s *Server) serveDeny(w http.ResponseWriter, r *http.Request) { rid := r.PathValue("rid") - c, ok, err := s.st.deny(rid) + c, ok, err := s.st.deny(rid, s.now()) if err != nil { http.Error(w, "could not deny", http.StatusInternalServerError) return } if ok { - _ = s.keys.Forget(r.Context(), c.Kid) + s.forget(r.Context(), c.Kid) s.hub.notify(string(hash(rid))) } allowApp(w, r) @@ -328,7 +357,8 @@ const keepAlive = 20 * time.Second func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { rid := r.PathValue("rid") - if _, ok, err := s.st.consentByRID(rid); err != nil || !ok { + c, ok, err := s.st.consentByRID(rid) + if err != nil || !ok { http.NotFound(w, r) return } @@ -337,18 +367,35 @@ func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } - // Only the browser that started the request gets the code. + // Only the browser that started the request gets the code, and keeps a + // stream open: anyone else gets the state once. var browser string if ck, err := r.Cookie(cookieName(rid)); err == nil && s.st.browserMatches(rid, ck.Value) { browser = ck.Value } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Accel-Buffering", "no") - w.WriteHeader(http.StatusOK) - - wake, done := s.hub.subscribe(string(hash(rid))) + head := func() { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + } + if browser == "" { + head() + s.event(w, rid, "", c) + flusher.Flush() + return + } + done, ok := s.streams.open(rid) + if !ok { + w.Header().Set("Retry-After", "5") + http.Error(w, "too many streams", http.StatusTooManyRequests) + return + } defer done() + head() + + wake, unsubscribe := s.hub.subscribe(string(hash(rid))) + defer unsubscribe() tick := time.NewTicker(keepAlive) defer tick.Stop() // A pending request's end does not move; once it changes state the diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go index 65a38c3..75e27f4 100644 --- a/internal/oauth/cimd.go +++ b/internal/oauth/cimd.go @@ -27,7 +27,8 @@ import ( // to a DNS name, only to public addresses (checked on the address actually // dialled, so a name that changes its answer between lookup and connect // gains nothing), no proxy, no redirects, no cookies, a small body and a -// short time, a few at once, and the answers cached. +// short time, a few at once and a few per host, and the answers cached: +// documents for at least a minute, failures apart for a minute. const ( cimdMaxBytes = 16 << 10 @@ -38,7 +39,12 @@ const ( cimdCacheNone = 10 * time.Minute // with no Cache-Control cimdCacheFailed = time.Minute cimdCacheSize = 1024 + cimdFailedSize = 256 cimdConcurrent = 8 + // Per host: a few fetches at once, then one every ten seconds. + cimdHostBurst = 4 + cimdHostRate = 0.1 + cimdHosts = 4096 ) // clientMetadata is the part of a metadata document the service reads. @@ -73,9 +79,13 @@ type fetcher struct { timeout time.Duration now func() time.Time sem chan struct{} + // hostBurst and hostRate bound fetches per host. + hostBurst, hostRate float64 - mu sync.Mutex - cache map[string]cimdEntry + mu sync.Mutex + cache map[string]cimdEntry + failed map[string]cimdEntry + hosts map[string]*limiter } type cimdEntry struct { @@ -84,17 +94,39 @@ type cimdEntry struct { until time.Time } +// put keeps an entry in a bounded cache: what has run out goes first, then +// whatever the map yields. +func put(cache map[string]cimdEntry, size int, key string, e cimdEntry, now time.Time) { + if len(cache) >= size { + for k, old := range cache { + if !now.Before(old.until) { + delete(cache, k) + } + } + for k := range cache { + if len(cache) < size { + break + } + delete(cache, k) + } + } + cache[key] = e +} + // newFetcher builds the fenced fetcher. roots is nil for the system's. func newFetcher(roots *x509.CertPool) *fetcher { f := &fetcher{ resolve: func(ctx context.Context, host string) ([]netip.Addr, error) { return net.DefaultResolver.LookupNetIP(ctx, "ip", host) }, - public: publicAddr, - timeout: cimdTimeout, - now: time.Now, - sem: make(chan struct{}, cimdConcurrent), - cache: map[string]cimdEntry{}, + public: publicAddr, + timeout: cimdTimeout, + now: time.Now, + sem: make(chan struct{}, cimdConcurrent), + hostBurst: cimdHostBurst, hostRate: cimdHostRate, + cache: map[string]cimdEntry{}, + failed: map[string]cimdEntry{}, + hosts: map[string]*limiter{}, } // The address is checked again as the socket connects, on the address // itself. @@ -188,11 +220,26 @@ func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, err } now := f.now() f.mu.Lock() - if e, ok := f.cache[clientID]; ok && now.Before(e.until) { - f.mu.Unlock() - return e.md, e.err + for _, c := range []map[string]cimdEntry{f.cache, f.failed} { + if e, ok := c[clientID]; ok && now.Before(e.until) { + f.mu.Unlock() + return e.md, e.err + } + } + host := strings.ToLower(u.Hostname()) + hl := f.hosts[host] + if hl == nil { + if len(f.hosts) >= cimdHosts { + clear(f.hosts) + } + l := newLimiter(f.hostBurst, f.hostRate) + hl = &l + f.hosts[host] = hl } f.mu.Unlock() + if !hl.allow(now) { + return clientMetadata{}, fmt.Errorf("%w: too many fetches from %s", errCIMDFetch, host) + } select { case f.sem <- struct{}{}: @@ -201,23 +248,12 @@ func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, err return clientMetadata{}, errCIMDFetch } md, ttl, err := f.fetch(ctx, u) - if err != nil { - ttl = cimdCacheFailed - } f.mu.Lock() defer f.mu.Unlock() - if len(f.cache) >= cimdCacheSize { - for k, e := range f.cache { - if !now.Before(e.until) || len(f.cache) >= cimdCacheSize { - delete(f.cache, k) - } - if len(f.cache) < cimdCacheSize/2 { - break - } - } - } - if ttl > 0 { - f.cache[clientID] = cimdEntry{md: md, err: err, until: now.Add(ttl)} + if err != nil { + put(f.failed, cimdFailedSize, clientID, cimdEntry{err: err, until: now.Add(cimdCacheFailed)}, now) + } else { + put(f.cache, cimdCacheSize, clientID, cimdEntry{md: md, until: now.Add(ttl)}, now) } return md, err } @@ -291,13 +327,19 @@ func parseMetadata(body []byte, clientID string) (clientMetadata, error) { if m := md.TokenEndpointAuthMethod; m != "" && m != authNone { return clientMetadata{}, fmt.Errorf("%w: token_endpoint_auth_method %q is not supported", errCIMDDocument, m) } - if !subset(md.GrantTypes, []string{grantCode, grantRefresh}) || !subset(md.ResponseTypes, []string{"code"}) { + // A client may list grants this server does not offer (Claude lists + // the JWT bearer grant); it gets the code and refresh grants only. It + // must use the code grant with a code response. + if len(md.GrantTypes) > 0 && !slices.Contains(md.GrantTypes, grantCode) || + len(md.ResponseTypes) > 0 && !slices.Contains(md.ResponseTypes, "code") { return clientMetadata{}, fmt.Errorf("%w: grant or response types", errCIMDDocument) } return md, nil } -// cacheFor reads how long a document may be kept, within bounds. +// cacheFor reads how long a document may be kept, within bounds. Even a +// document that asks not to be kept is kept a minute, so a page that loads +// it again and again cannot make the service fetch it each time. func cacheFor(cc string) time.Duration { if cc == "" { return cimdCacheNone @@ -306,7 +348,7 @@ func cacheFor(cc string) time.Duration { d = strings.ToLower(strings.TrimSpace(d)) switch { case d == "no-store" || d == "no-cache": - return 0 + return cimdCacheMin case strings.HasPrefix(d, "max-age="): s, err := strconv.Atoi(strings.TrimPrefix(d, "max-age=")) if err != nil || s < 0 { @@ -320,8 +362,8 @@ func cacheFor(cc string) time.Duration { // publicAddr is an address on the public internet: not loopback, private, // link-local (where the cloud's metadata service answers), multicast, -// shared, reserved, documentation, or a translation prefix that could lead -// back inside. +// shared, reserved, documentation, IPv4-compatible IPv6, or a translation +// prefix that could lead back inside. func publicAddr(a netip.Addr) bool { a = a.Unmap() if !a.IsValid() || a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || @@ -341,7 +383,7 @@ var blocked = func() []netip.Prefix { for _, s := range []string{ "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "255.255.255.255/32", - "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", + "::/96", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", } { out = append(out, netip.MustParsePrefix(s)) } @@ -364,12 +406,3 @@ func cleanName(s string) string { } return s } - -func subset(have, allowed []string) bool { - for _, h := range have { - if !slices.Contains(allowed, h) { - return false - } - } - return true -} diff --git a/internal/oauth/cimd_test.go b/internal/oauth/cimd_test.go index db50061..1f92cc4 100644 --- a/internal/oauth/cimd_test.go +++ b/internal/oauth/cimd_test.go @@ -3,6 +3,7 @@ package oauth import ( "context" "errors" + "fmt" "net" "net/http" "net/netip" @@ -47,6 +48,7 @@ func TestPublicAddresses(t *testing.T) { "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.40", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "::1", "::", "fe80::1", "fd00::1", "fc00::1", "::ffff:10.0.0.1", "::ffff:169.254.169.254", "64:ff9b::a00:1", "2002:a00:1::", "2001:db8::1", "ff02::1", + "::a00:1", "::a9fe:a9fe", "::1.1.1.1", } { if publicAddr(netip.MustParseAddr(s)) { t.Errorf("%s counts as public", s) @@ -193,7 +195,7 @@ func TestCIMDServerMisbehaviour(t *testing.T) { func TestCacheFor(t *testing.T) { for cc, want := range map[string]time.Duration{ "": cimdCacheNone, - "no-store": 0, + "no-store": cimdCacheMin, "max-age=0": cimdCacheMin, "public, max-age=3600": time.Hour, "max-age=999999999": cimdCacheMax, @@ -213,3 +215,76 @@ func TestCIMDIgnoresProxies(t *testing.T) { t.Fatal("the fetcher reads a proxy") } } + +// Fetches are capped per host, so one host cannot make the service fetch +// for it again and again; a refusal of that kind is not cached. +func TestCIMDFetchesPerHostAreCapped(t *testing.T) { + g := newRig(t) + g.s.fetch.hostBurst, g.s.fetch.hostRate = 2, 0.0001 + var errs []error + for i := range 3 { + _, err := g.s.fetch.get(context.Background(), fmt.Sprintf("https://example.com/c%d.json", i)) + errs = append(errs, err) + } + if errs[2] == nil || !strings.Contains(errs[2].Error(), "too many fetches") { + t.Fatalf("the third fetch from one host: %v", errs) + } + // A cached document needs no fetch. + if _, err := g.s.fetch.get(context.Background(), "https://example.com/c0.json"); err == nil || + strings.Contains(err.Error(), "too many fetches") { + t.Fatalf("a cached failure was fetched again: %v", err) + } +} + +// Failures have a small cache of their own: many of them do not push a +// good document out, and the good one is not fetched again. +func TestFailuresDoNotPushOutDocuments(t *testing.T) { + g := newRig(t) + var hits atomic.Int32 + inner := g.cimd.Config.Handler + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + inner.ServeHTTP(w, r) + }) + if _, err := g.s.fetch.get(context.Background(), cimdID); err != nil { + t.Fatal(err) + } + for i := range cimdFailedSize * 2 { + _, _ = g.s.fetch.get(context.Background(), fmt.Sprintf("https://example.com/missing%d.json", i)) + } + before := hits.Load() + if _, err := g.s.fetch.get(context.Background(), cimdID); err != nil || hits.Load() != before { + t.Fatalf("the good document was pushed out: %v, %d fetches", err, hits.Load()-before) + } + g.s.fetch.mu.Lock() + n := len(g.s.fetch.failed) + g.s.fetch.mu.Unlock() + if n > cimdFailedSize { + t.Fatalf("%d failures kept", n) + } +} + +// Claude's own document, as it was served on 15 Oct 2026, lists a grant +// this server does not offer. It is accepted; the client gets the code and +// refresh grants. +func TestClaudesDocumentIsAccepted(t *testing.T) { + const id = "https://claude.ai/oauth/mcp-oauth-client-metadata" + doc := `{"client_id":"https://claude.ai/oauth/mcp-oauth-client-metadata","client_name":"Claude","client_uri":"https://claude.ai",` + + `"redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"grant_types":["authorization_code","refresh_token",` + + `"urn:ietf:params:oauth:grant-type:jwt-bearer"],"response_types":["code"],"token_endpoint_auth_method":"none"}` + md, err := parseMetadata([]byte(doc), id) + if err != nil || md.ClientName != "Claude" || md.RedirectURIs[0] != "https://claude.ai/api/mcp/auth_callback" { + t.Fatalf("%+v %v", md, err) + } + if !verifiedClient(id) { + t.Fatal("Claude is not on the list") + } + for _, bad := range []string{ + `{"client_id":"` + id + `","client_name":"X","redirect_uris":["https://x.example/cb"],"grant_types":["client_credentials"]}`, + `{"client_id":"` + id + `","client_name":"X","redirect_uris":["https://x.example/cb"],"response_types":["token"]}`, + } { + if _, err := parseMetadata([]byte(bad), id); err == nil { + t.Errorf("accepted %s", bad) + } + } +} diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 1b8a82b..f30fa2d 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -9,7 +9,10 @@ import ( "io" "net/http" "net/url" + "slices" "strings" + + "github.com/srcfl/ftw/go/protocol" ) // client is a client as the authorize and token endpoints see it, from a @@ -21,8 +24,8 @@ type client struct { // document's URL, which vouches for it, or the client_uri a registration // claimed, which does not. Host string - // Verified is a client FTW Cloud knows: a metadata document on one of - // verifiedHosts. + // Verified is a client FTW Cloud knows: a metadata document at one of + // verifiedClients. Verified bool RedirectURIs []string AuthMethod string @@ -33,13 +36,18 @@ var errUnknownClient = errors.New("unknown client") // resolveClient finds the client a client_id names. func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { + if id == protocol.SupportClientID { + // Support's offers come from its own tooling, never through this + // page: the box and the app trust that client id for support. + return client{}, errUnknownClient + } if isCIMD(id) { md, err := s.fetch.get(ctx, id) if err != nil { return client{}, err } u, _ := url.Parse(id) - return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedHost(u.Hostname()), RedirectURIs: md.RedirectURIs, + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedClient(id), RedirectURIs: md.RedirectURIs, AuthMethod: authNone}, nil } c, ok, err := s.st.client(id) @@ -121,7 +129,7 @@ type registration struct { } func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { - if !s.registerLimit.allow(s.now()) { + if s.limited(&s.registerLimit, s.registerByIP, r) { w.Header().Set("Retry-After", "10") registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") return @@ -151,12 +159,14 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { registerError(w, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method must be none, client_secret_post or client_secret_basic") return } - grants := reg.GrantTypes - if len(grants) == 0 { + // Grants this server does not offer are left out of what it registers + // (RFC 7591 lets it replace them), as for a metadata document. + grants := intersect(reg.GrantTypes, []string{grantCode, grantRefresh}) + if len(reg.GrantTypes) == 0 { grants = []string{grantCode} } - if !subset(grants, []string{grantCode, grantRefresh}) || !subset(reg.ResponseTypes, []string{"code"}) { - registerError(w, http.StatusBadRequest, "invalid_client_metadata", "only authorization_code and refresh_token, with response type code") + if !slices.Contains(grants, grantCode) || len(reg.ResponseTypes) > 0 && !slices.Contains(reg.ResponseTypes, "code") { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "the authorization_code grant, with response type code, is required") return } for _, sc := range splitScope(reg.Scope) { diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 2d118e0..ded9a6b 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -23,13 +23,19 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "errors" "log/slog" + "net" "net/http" + "net/netip" + "path/filepath" "slices" "strings" "sync" + "sync/atomic" "time" + "github.com/srcfl/ftw-cloud/internal/keys" "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -42,8 +48,9 @@ const ( // Resource is the MCP server's canonical URI: every token's audience. Resource = Issuer + "/mcp" // AppLink is where the QR and the button send the owner: a universal - // link that opens the FTW app, followed by the request id. - AppLink = "https://app.ftw.energy/a/" + // link that opens the FTW app. Its fragment carries the request id and + // the signed offer, so neither reaches a server's log. + AppLink = "https://app.ftw.energy/a" // AppOrigin may read a request's details from a browser. AppOrigin = "https://app.ftw.energy" ) @@ -117,7 +124,8 @@ type AgentKeys interface { New(ctx context.Context) (kid string, pub []byte, err error) Keep(ctx context.Context, kid string) error Forget(ctx context.Context, kid string) error - SignOffer(ctx context.Context, offer []byte) ([]byte, error) + // SignOffer signs an offer for key kid, which must still be pending. + SignOffer(ctx context.Context, kid string, offer []byte) ([]byte, error) OfferKey(ctx context.Context) ([]byte, error) } @@ -129,10 +137,21 @@ type Server struct { log *slog.Logger now func() time.Time hub hub + route routeKey + // Each endpoint a stranger can call has a bucket for everyone and one + // per address. authorizeLimit, registerLimit, lookupLimit limiter + authorizeByIP, registerByIP, lookupByIP *ipLimiter + streams streams - roster chan rosterEvent + // The newest roster, or purge, per home, not applied yet; and homes + // whose approval waits for the keys part. + pmu sync.Mutex + pending map[string]*homeEvent + retries map[string]*approvalRetry + wake chan struct{} + busy atomic.Int32 offerKey offerKey @@ -153,13 +172,20 @@ func newServer(path string, keys AgentKeys, log *slog.Logger, roots *x509.CertPo if log == nil { log = slog.Default() } + route, err := loadRouteKey(filepath.Join(filepath.Dir(path), "oauth-route.key")) + if err != nil { + return nil, err + } st, err := openStore(path) if err != nil { return nil, err } - s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, + s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, route: route, + hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, authorizeLimit: newLimiter(30, 1), registerLimit: newLimiter(20, 0.2), lookupLimit: newLimiter(20, 5), - roster: make(chan rosterEvent, 1024)} + 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)} return s, nil } @@ -233,111 +259,223 @@ func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { // The box's roster // -------------------------------------------------------------------------- -type rosterEvent struct { - home string +// homeEvent is what waits to be applied for one home: the newest roster +// only, and whether the home was purged first. A newer roster replaces an +// older one, so nothing old is ever applied and nothing is dropped. +type homeEvent struct { + purge bool devices []cloudwire.Device - // tries counts how often the event came back because the keys part - // could not keep an approved key. - tries int + roster bool +} + +// approvalRetry is a home whose approval the keys part could not take yet. +// A retry runs the approval pass alone, on the newest roster. +type approvalRetry struct { + devices []cloudwire.Device + tries int + at time.Time } // keepRetry is how long an approval waits before it tries the keys part // again, and keepTries how often. A box sends its roster when it changes, -// so the approval cannot wait for the next one. +// so the approval cannot wait for the next one. keysTimeout bounds one call +// to the keys part. var ( - keepRetry = 5 * time.Second - keepTries = 24 + keepRetry = 5 * time.Second + keepTries = 24 + keysTimeout = 5 * time.Second ) // Roster hears each roster the service keeps. A device whose key a pending // consent made is that consent's approval; an agent the roster no longer -// lists is revoked. The work happens on Run's goroutine, in order, since -// this runs on the box's. +// lists is revoked. The work happens on Run's goroutine, since this runs on +// the box's. func (s *Server) Roster(home string, r *cloudwire.Roster) { - ev := rosterEvent{home: home, devices: slices.Clone(r.Devices)} + s.pmu.Lock() + ev := s.pending[home] + if ev == nil { + ev = &homeEvent{} + s.pending[home] = ev + } + ev.devices, ev.roster = slices.Clone(r.Devices), true + s.pmu.Unlock() + s.poke() +} + +// DropHome forgets a home its box purged: its agents, their tokens and keys, +// and its consents. A roster that came before it is not applied. +func (s *Server) DropHome(home string) { + s.pmu.Lock() + s.pending[home] = &homeEvent{purge: true} + delete(s.retries, home) + s.pmu.Unlock() + s.poke() +} + +func (s *Server) poke() { select { - case s.roster <- ev: + case s.wake <- struct{}{}: default: - // The next roster carries the same devices. - s.log.Warn("roster queue full; an agent change waits for the next roster") } } -// Run applies rosters and sweeps until ctx ends. +// Run applies rosters, retries approvals and forgets, and sweeps, until +// ctx ends. It first forgets the keys whose agents went while the keys part +// was away. func (s *Server) Run(ctx context.Context) { - t := time.NewTicker(time.Minute) - defer t.Stop() + s.forgetPending(ctx) + sweep := time.NewTicker(time.Minute) + defer sweep.Stop() + retry := time.NewTicker(min(keepRetry, time.Second)) + defer retry.Stop() for { select { case <-ctx.Done(): return - case ev := <-s.roster: - s.applyRoster(ctx, ev) - case <-t.C: + case <-s.wake: + s.applyPending(ctx) + case <-retry.C: + s.retryApprovals(ctx) + case <-sweep.C: s.sweep(ctx) + s.forgetPending(ctx) + } + } +} + +func (s *Server) applyPending(ctx context.Context) { + s.busy.Add(1) + defer s.busy.Add(-1) + s.pmu.Lock() + evs := s.pending + s.pending = map[string]*homeEvent{} + s.pmu.Unlock() + for home, ev := range evs { + if ev.purge { + s.dropHome(ctx, home) + } + if ev.roster { + s.applyRoster(ctx, home, ev.devices) } } } -func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { +// listedAgents are the rows this service serves: a personal agent's, with +// its grant and an end still ahead, by its full key. Support and release +// rows reach their homes another way. +func (s *Server) listedAgents(devices []cloudwire.Device) map[string]cloudwire.Device { now := s.now() - retry := false - // The rows this service serves: a personal agent's, with its grant and - // an end still ahead. Support and release rows reach their homes - // another way. listed := map[string]cloudwire.Device{} - for _, d := range ev.devices { + for _, d := range devices { // An agent row has no tunnel of its own (tun is false): only the // keys part opens its session. - if d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + if d.Agent != nil && d.Role == protocol.RoleAgent && len(d.Key) == 32 && cloudwire.DeviceID(d.Key) == d.ID && now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { - listed[d.ID] = d + listed[string(d.Key)] = d + } + } + return listed +} + +func (s *Server) applyRoster(ctx context.Context, home string, devices []cloudwire.Device) { + listed := s.listedAgents(devices) + // A key belongs to one home. A roster that lists, as its agent, a key + // approved for another home has a copy it should not: the agent goes + // everywhere, and its key with it. + for key := range listed { + if a, ok, err := s.st.agentByKey([]byte(key)); err == nil && ok && a.Home != home { + s.log.Warn("a roster listed an agent approved for another home; revoking it") + s.revoke(ctx, a) + delete(listed, key) + } + } + if s.approve(ctx, home, listed) { + s.pmu.Lock() + r := s.retries[home] + if r == nil { + r = &approvalRetry{} + s.retries[home] = r + } + r.devices, r.at = devices, time.Now().Add(keepRetry) + s.pmu.Unlock() + } else { + s.pmu.Lock() + delete(s.retries, home) + s.pmu.Unlock() + } + // Every agent the roster lists keeps the grant the box gives it now: + // an owner may widen, narrow or renew it. One it no longer lists, or + // lists under another id, is revoked. + agents, err := s.st.agentsOf(home) + if err != nil { + return + } + for _, a := range agents { + d, ok := listed[string(a.Key)] + if !ok || d.ID != a.Agent { + s.revoke(ctx, a) + continue + } + if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { + s.log.Warn("could not update an agent's grant", "err", err) } } - // Approvals: a pending consent's key in the box's signed roster, for - // the client the offer named. +} + +// approve is the approval pass: a pending consent's key in the box's signed +// roster, for the client the offer named. It reports whether an approval +// waits for the keys part. +func (s *Server) approve(ctx context.Context, home string, listed map[string]cloudwire.Device) (retry bool) { + now := s.now() for _, d := range listed { ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) if err != nil || !ok || d.Agent.Principal.ClientID != c.ClientID { continue } - if err := s.keys.Keep(ctx, c.Kid); err != nil { + kctx, cancel := context.WithTimeout(ctx, keysTimeout) + err = s.keys.Keep(kctx, c.Kid) + cancel() + if err != nil { s.log.Warn("an approved agent's key could not be kept; trying again", "err", err) retry = true continue } - c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) + c.Home, c.Agent, c.Shard = home, d.ID, s.shard(home) c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Agent.Scopes), " ") if err := s.st.approve(ridHash, c, grantOf(d), now); err != nil { + s.log.Warn("could not record an approval", "err", err) continue } s.hub.notify(string(ridHash)) } - if retry && ev.tries < keepTries { - ev.tries++ - time.AfterFunc(keepRetry, func() { - select { - case s.roster <- ev: - default: - } - }) - } - // Every agent the roster lists keeps the grant the box gives it now: - // an owner may widen, narrow or renew it. One it no longer lists, or - // lists with another key, is revoked. - agents, err := s.st.agentsOf(ev.home) - if err != nil { - return - } - for _, a := range agents { - d, ok := listed[a.Agent] - if !ok || string(d.Key) != string(a.Key) { - s.revoke(ctx, a) - continue + return retry +} + +// retryApprovals runs the approval pass again for homes that wait on the +// keys part, on each home's newest roster. It revokes nothing: that is the +// roster's own pass, which already ran. +func (s *Server) retryApprovals(ctx context.Context) { + now := time.Now() + s.pmu.Lock() + due := map[string][]cloudwire.Device{} + for home, r := range s.retries { + if !now.Before(r.at) { + due[home] = r.devices } - if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { - s.log.Warn("could not update an agent's grant", "err", err) + } + s.pmu.Unlock() + for home, devices := range due { + retry := s.approve(ctx, home, s.listedAgents(devices)) + s.pmu.Lock() + if r := s.retries[home]; r != nil { + r.tries++ + if !retry || r.tries >= keepTries { + delete(s.retries, home) + } else { + r.at = now.Add(keepRetry) + } } + s.pmu.Unlock() } } @@ -347,27 +485,60 @@ func grantOf(d cloudwire.Device) agentGrant { Expires: time.UnixMilli(d.Agent.ExpiresAtMs)} } +// revoke drops an agent and its tokens, and has its key forgotten. func (s *Server) revoke(ctx context.Context, a agentRow) { - if err := s.st.dropAgent(a.Home, a.Agent); err != nil { + if err := s.st.dropAgent(a.Home, a.Agent, a.Kid); err != nil { s.log.Warn("could not drop an agent", "err", err) return } - if err := s.keys.Forget(ctx, a.Kid); err != nil { - s.log.Warn("the keys part could not forget an agent's key", "err", err) - } + s.forget(ctx, a.Kid) if s.OnRevoke != nil { s.OnRevoke(a.Home, a.Agent) } } -func (s *Server) sweep(ctx context.Context) { - now := s.now() - kids, err := s.st.sweep(now) +// dropHome revokes every agent of a purged home and deletes its consents. +func (s *Server) dropHome(ctx context.Context, home string) { + agents, err := s.st.agentsOf(home) if err != nil { - s.log.Warn("oauth upkeep failed", "err", err) + s.log.Warn("could not read a purged home's agents", "err", err) + return + } + for _, a := range agents { + s.revoke(ctx, a) + } + if err := s.st.dropRequests(home); err != nil { + s.log.Warn("could not delete a purged home's consents", "err", err) + } +} + +// forget asks the keys part to delete a key the store already queued for +// forgetting, and dequeues it once done. A key the keys part could not +// forget now is tried again every minute and at the next start. +func (s *Server) forget(ctx context.Context, kid string) { + kctx, cancel := context.WithTimeout(ctx, keysTimeout) + defer cancel() + if err := s.keys.Forget(kctx, kid); err != nil && !errors.Is(err, keys.ErrUnknownKey) { + s.log.Warn("the keys part could not forget a key; trying again later", "err", err) + return + } + _ = s.st.forgotten(kid) +} + +func (s *Server) forgetPending(ctx context.Context) { + kids, err := s.st.toForget() + if err != nil { + return } for _, kid := range kids { - _ = s.keys.Forget(ctx, kid) + s.forget(ctx, kid) + } +} + +func (s *Server) sweep(ctx context.Context) { + now := s.now() + if err := s.st.sweep(now); err != nil { + s.log.Warn("oauth upkeep failed", "err", err) } gone, err := s.st.expiredAgents(now) if err == nil { @@ -450,6 +621,106 @@ func (l *limiter) allow(now time.Time) bool { return true } +// ipLimiter is a token bucket per address, an IPv6 address by its /64, so +// one caller cannot spend everyone's bucket. A full bucket carries nothing, +// so it is the first to go when the table fills. +type ipLimiter struct { + mu sync.Mutex + burst, rate float64 + m map[netip.Prefix]*limiter +} + +// maxAddresses bounds the per-address table. +const maxAddresses = 50_000 + +func newIPLimiter(burst, perSecond float64) *ipLimiter { + return &ipLimiter{burst: burst, rate: perSecond, m: map[netip.Prefix]*limiter{}} +} + +func (l *ipLimiter) allow(r *http.Request, now time.Time) bool { + key, ok := clientPrefix(r) + if !ok { + return false + } + l.mu.Lock() + b := l.m[key] + if b == nil { + if len(l.m) >= maxAddresses { + for k, v := range l.m { + if v.tokens+now.Sub(v.at).Seconds()*v.rate >= v.burst { + delete(l.m, k) + } + } + if len(l.m) >= maxAddresses { + l.mu.Unlock() + return false + } + } + nl := newLimiter(l.burst, l.rate) + b = &nl + l.m[key] = b + } + l.mu.Unlock() + return b.allow(now) +} + +// clientPrefix is the caller's address as a bucket's key. TLS ends in this +// process, so RemoteAddr is the caller's own. +func clientPrefix(r *http.Request) (netip.Prefix, bool) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + a, err := netip.ParseAddr(host) + if err != nil { + return netip.Prefix{}, false + } + a = a.Unmap() + bits := 32 + if a.Is6() { + bits = 64 + } + p, err := a.Prefix(bits) + return p, err == nil +} + +// limited takes one token from both of an endpoint's buckets. +func (s *Server) limited(global *limiter, byIP *ipLimiter, r *http.Request) bool { + now := s.now() + return !byIP.allow(r, now) || !global.allow(now) +} + +// streams bounds the consent pages' event streams: a few per request, and +// a total. +type streams struct { + mu sync.Mutex + per map[string]int + total int +} + +const ( + maxStreamsPerRequest = 2 + maxStreams = 4096 +) + +func (st *streams) open(rid string) (func(), bool) { + st.mu.Lock() + defer st.mu.Unlock() + if st.per[rid] >= maxStreamsPerRequest || st.total >= maxStreams { + return nil, false + } + st.per[rid]++ + st.total++ + return func() { + st.mu.Lock() + defer st.mu.Unlock() + st.total-- + if st.per[rid]--; st.per[rid] <= 0 { + delete(st.per, rid) + } + }, true +} + func randomToken(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 7d13546..fb14ce5 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -9,7 +9,9 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "github.com/fxamacker/cbor/v2" + "html" "io" "log/slog" "net" @@ -22,6 +24,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" @@ -95,10 +98,15 @@ func newRig(t *testing.T) *rig { return nil, errors.New("no such host") } g.s.fetch.public = func(a netip.Addr) bool { return a == netip.MustParseAddr("127.0.0.1") || publicAddr(a) } + // Every test client lives on example.com. + g.s.fetch.hostBurst, g.s.fetch.hostRate = 1e4, 1e4 g.s.fetch.dial = func(ctx context.Context, network, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, network, g.cimd.Listener.Addr().String()) } + // One test client is one address: its own limits are looser than a + // stranger's, and TestLimitsPerAddress checks those. + g.s.authorizeByIP, g.s.registerByIP, g.s.lookupByIP = newIPLimiter(1e4, 1e4), newIPLimiter(1e4, 1e4), newIPLimiter(1e4, 1e4) mux := http.NewServeMux() g.s.Mount(mux) g.srv = httptest.NewServer(mux) @@ -269,13 +277,22 @@ func next(t *testing.T, ch <-chan event) event { // after checking the box's signature. func (g *rig) roster(h string, devs ...cloudwire.Device) { g.s.Roster(h, &cloudwire.Roster{RosterSeq: 1, TTLS: 86400, Devices: devs}) - // Run applies it on its own goroutine; a request on the store after - // this waits for it by draining the queue. + g.settle() +} + +// settle waits until Run has applied what is pending. +func (g *rig) settle() { deadline := time.Now().Add(2 * time.Second) - for len(g.s.roster) > 0 && time.Now().Before(deadline) { + for time.Now().Before(deadline) { + g.s.pmu.Lock() + n := len(g.s.pending) + g.s.pmu.Unlock() + if n == 0 && g.s.busy.Load() == 0 { + break + } time.Sleep(time.Millisecond) } - time.Sleep(20 * time.Millisecond) + time.Sleep(10 * time.Millisecond) } // agentDevice is an agent row as the box lists it in its roster, for the @@ -454,6 +471,7 @@ func TestTheFullFlowWithAMetadataDocument(t *testing.T) { if _, aerr := g.bearer(tk2.Access); aerr != nil { t.Fatal("the refreshed token was refused") } + g.advance(refreshGrace + time.Second) status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}) if status != http.StatusBadRequest { t.Fatalf("a spent refresh token: %d", status) @@ -476,7 +494,8 @@ func TestACodeWorksOnce(t *testing.T) { next(t, ch) g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") - form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}} + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}, + "redirect_uri": {cimdBack}} status, tk := g.token(form) if status != http.StatusOK { t.Fatalf("first use: %d", status) @@ -505,9 +524,8 @@ func TestPKCEIsRequired(t *testing.T) { } change(qq) resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + qq.Encode()) - loc, _ := url.Parse(resp.Header.Get("Location")) - if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_request" || loc.Query().Get("iss") != Issuer { - t.Fatalf("without S256 PKCE: %d %s", resp.StatusCode, loc) + if back := backLink(t, resp); back.Query().Get("error") != "invalid_request" || back.Query().Get("iss") != Issuer { + t.Fatalf("without S256 PKCE: %s", back) } } @@ -518,13 +536,31 @@ func TestPKCEIsRequired(t *testing.T) { g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") wrong, _ := pkce() - if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}}); status != http.StatusBadRequest { + if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}, + "redirect_uri": {cimdBack}}); status != http.StatusBadRequest { t.Fatalf("a wrong verifier: %d", status) } } -// Before client and redirect URI check out, nothing redirects; after, every -// error goes back with iss. +// backLink is the link an error page offers back to the client, with no +// redirect of its own. +func backLink(t *testing.T, resp *http.Response) *url.URL { + t.Helper() + body, _ := io.ReadAll(resp.Body) + m := regexp.MustCompile(`Go back to `).FindSubmatch(body) + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" || m == nil { + t.Fatalf("not an error page with a link back: %d %q", resp.StatusCode, resp.Header.Get("Location")) + } + u, err := url.Parse(html.UnescapeString(string(m[1]))) + if err != nil { + t.Fatal(err) + } + return u +} + +// Before client and redirect URI check out, nothing redirects. After, an +// error goes back with iss: at once for a client FTW Cloud knows, and by a +// link the person follows for any other (RFC 9700, 4.11.2). func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { g := newRig(t) for _, q := range []url.Values{ @@ -541,16 +577,24 @@ func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { q, _ := g.authorizeParams(cimdID, cimdBack, "") q.Set("resource", "https://cloud.ftw.energy/other") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - loc, _ := url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") != "invalid_target" || loc.Query().Get("iss") != Issuer || loc.Host != "example.com" { - t.Fatalf("another resource: %s", loc) + back := backLink(t, resp) + if back.Query().Get("error") != "invalid_target" || back.Query().Get("iss") != Issuer || back.Host != "example.com" { + t.Fatalf("another resource: %s", back) } q.Set("resource", Resource) q.Set("scope", "ftw.live.read admin") resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - loc, _ = url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") != "invalid_scope" { - t.Fatalf("an unknown scope: %s", loc) + if back := backLink(t, resp); back.Query().Get("error") != "invalid_scope" { + t.Fatalf("an unknown scope: %s", back) + } + // A client FTW Cloud knows goes back at once. + old := verifiedClients + verifiedClients = append(slices.Clone(old), cimdID) + t.Cleanup(func() { verifiedClients = old }) + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_scope" || loc.Query().Get("iss") != Issuer { + t.Fatalf("a known client's error: %d %s", resp.StatusCode, loc) } // A loopback redirect may take any port. q2, _ := g.authorizeParams(cimdID, "http://127.0.0.1:53121/cb", "") @@ -601,7 +645,8 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { g.roster(home, agentFor(f.agentKey(t), id, time.Now().Add(time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") stop() - if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { + if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}, + "redirect_uri": {"https://grok.example/cb"}}); status != http.StatusOK { t.Fatalf("public client token: %d %+v", status, tk) } @@ -616,7 +661,8 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { next(t, ch) g.roster(home, agentFor(f.agentKey(t), cid, time.Now().Add(time.Hour), ScopeLiveRead)) code = redirectOf(t, next(t, ch)).Query().Get("code") - form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}, + "redirect_uri": {"https://chatgpt.com/connector_platform_oauth_redirect"}} if status, _ := g.token(form); status != http.StatusUnauthorized { t.Fatalf("no secret: %d", status) } @@ -632,7 +678,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { // The page finishes only when the box's roster lists the very key this // request made, as a device that may open a session; and only the browser -// that started it gets the code. +// that started it gets the code, or keeps a stream open. func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, "") @@ -643,6 +689,9 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } + if _, open := <-stranger; open { + t.Fatal("a stranger's stream stayed open") + } // Another key; the right key under another id, for another client, // past its end, as support, or as a phone: none of them approves. other := bytes.Repeat([]byte{1}, 32) @@ -668,6 +717,8 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { } g.roster(home, agentDevice(key, ScopeLiveRead)) + stranger, stop3 := g.events(f, false) + defer stop3() if ev := next(t, stranger); ev.name != "approved" || strings.Contains(ev.data, "code") { t.Fatalf("a stranger's stream got %q %s", ev.name, ev.data) } @@ -760,7 +811,8 @@ func TestAnExpiredAgentFails(t *testing.T) { next(t, ch) g.roster(home, agentFor(f.agentKey(t), cimdID, g.now().Add(2*time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") - _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}}) + _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}, + "redirect_uri": {cimdBack}}) if _, aerr := g.bearer(tk.Access); aerr != nil { t.Fatal(aerr) } @@ -890,19 +942,32 @@ func TestTheConsentPage(t *testing.T) { } // Requests for keys are bounded: the authorize endpoint is rate limited -// for everyone together, since each request makes a key. +// for everyone together, since each request makes a key, and the limit +// comes before the client's document is fetched. func TestAuthorizeIsRateLimited(t *testing.T) { g := newRig(t) + var hits atomic.Int32 + inner := g.cimd.Config.Handler + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + inner.ServeHTTP(w, r) + }) limited := false - for range 40 { - q, _ := g.authorizeParams(cimdID, cimdBack, "") + for i := range 40 { + // A new document each time, so the cache does not hide a fetch. + id := fmt.Sprintf("https://example.com/oauth/c%d.json", i) + g.docsMu.Lock() + g.docs[fmt.Sprintf("/oauth/c%d.json", i)] = `{"client_id":"` + id + `","client_name":"C","redirect_uris":["` + cimdBack + `"]}` + g.docsMu.Unlock() + q, _ := g.authorizeParams(id, cimdBack, "") + before := hits.Load() resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - if resp.StatusCode == http.StatusFound { - loc, _ := url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") == "temporarily_unavailable" { - limited = true - break + if resp.StatusCode == http.StatusTooManyRequests { + if hits.Load() != before { + t.Fatal("a limited request fetched a document") } + limited = true + break } } if !limited { @@ -910,6 +975,53 @@ func TestAuthorizeIsRateLimited(t *testing.T) { } } +// One address cannot spend everyone's bucket: authorize, registration and +// code lookups each have one per address, an IPv6 address by its /64. +func TestLimitsPerAddress(t *testing.T) { + g := newRig(t) + g.s.authorizeByIP, g.s.registerByIP, g.s.lookupByIP = newIPLimiter(3, 0.001), newIPLimiter(2, 0.001), newIPLimiter(5, 0.001) + mux := http.NewServeMux() + g.s.Mount(mux) + do := func(addr, target string) int { + method := http.MethodGet + if strings.HasPrefix(target, "/oauth/register") { + method = http.MethodPost + } + r := httptest.NewRequest(method, target, strings.NewReader(`{"redirect_uris":["https://x.example/cb"]}`)) + r.RemoteAddr = addr + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + return w.Code + } + q, _ := g.authorizeParams(cimdID, cimdBack, "") + for name, c := range map[string]struct { + target string + n int + }{ + "authorize": {"/oauth/authorize?" + q.Encode(), 3}, + "register": {"/oauth/register", 2}, + "lookup": {"/oauth/requests?code=AAAA-AAAA", 5}, + } { + for i := range c.n { + if code := do("198.51.100.7:4000", c.target); code == http.StatusTooManyRequests { + t.Fatalf("%s: limited after %d", name, i) + } + } + if code := do("198.51.100.7:4001", c.target); code != http.StatusTooManyRequests { + t.Fatalf("%s: not limited per address: %d", name, code) + } + if code := do("[2001:db8:1:2::9]:4000", c.target); code == http.StatusTooManyRequests { + t.Fatalf("%s: another address was limited", name) + } + for range c.n - 1 { + do("[2001:db8:1:2::a]:4000", c.target) + } + if code := do("[2001:db8:1:2:ffff::1]:4000", c.target); code != http.StatusTooManyRequests { + t.Fatalf("%s: one /64 counts as many callers: %d", name, code) + } + } +} + // flakyKeys is a keys part that is away for a while. type flakyKeys struct { AgentKeys @@ -984,8 +1096,8 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { q, _ := g.authorizeParams(cimdID, cimdBack, "") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) page, _ := io.ReadAll(resp.Body) - if !regexp.MustCompile(`href="https://app\.ftw\.energy/a/[A-Za-z0-9_-]{22}#[A-Za-z0-9_-]{200,}"`).Match(page) { - t.Fatal("the app link does not carry the offer in its fragment") + if !regexp.MustCompile(`href="https://app\.ftw\.energy/a#[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{200,}"`).Match(page) { + t.Fatal("the app link does not carry the request and the offer in its fragment") } // A registered client is shown by its redirect URI's host. resp2, _ := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(`{"client_name":"X","redirect_uris":["https://x.example/cb"]}`)) @@ -1011,18 +1123,33 @@ func TestTheOfferKeyIsPublished(t *testing.T) { } } -// A token names its home, so any instance can tell where it belongs; the -// name proves nothing by itself. -func TestTokensNameTheirHome(t *testing.T) { +// A token is routed to its home, so any instance can tell where it belongs, +// but it shows no id a client could link across tokens: the home is sealed +// with a fresh nonce each time. The route proves nothing by itself. +func TestTokensRouteToTheirHomeWithoutNamingIt(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, "") tk := g.approve(f, cimdID) - if TokenHome(tk.Access) != home || TokenHome(tk.Refresh) != home || !strings.HasPrefix(tk.Access, accessPrefix+home+".") { - t.Fatalf("tokens %s %s", tk.Access, tk.Refresh) + for _, tok := range []string{tk.Access, tk.Refresh} { + if h, ok := g.s.TokenHome(tok); !ok || h != home { + t.Fatalf("token %s routes to %q", tok, h) + } + if strings.Contains(tok, home) { + t.Fatalf("token %s names its home", tok) + } + } + route := func(tok string) string { r, _, _ := strings.Cut(tok[len(accessPrefix):], "."); return r } + if route(tk.Access) == route(tk.Refresh) { + t.Fatal("two tokens share a route") } - forged := accessPrefix + home + "." + randomToken(32) + // A route made with another key, or edited, does not open. + other, _ := loadRouteKey(filepath.Join(t.TempDir(), "k")) + if _, ok := g.s.TokenHome(other.newToken(accessPrefix, home)); ok { + t.Fatal("a route under another key opened") + } + forged := g.s.route.newToken(accessPrefix, home) if _, aerr := g.bearer(forged); aerr == nil { - t.Fatal("a token with the right home and a made-up secret was accepted") + t.Fatal("a token with a good route and a made-up secret was accepted") } } @@ -1068,24 +1195,35 @@ func TestARefreshCanNarrow(t *testing.T) { } func TestTokenShape(t *testing.T) { - good := accessPrefix + home + "." + randomToken(32) + k, _ := loadRouteKey(filepath.Join(t.TempDir(), "k")) + good := k.newToken(accessPrefix, home) if !tokenShape(good, accessPrefix) { t.Fatal("a token this server makes") } - for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), refreshPrefix + home + "." + randomToken(32), - accessPrefix + "HOME." + randomToken(32), accessPrefix + home + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { + for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), k.newToken(refreshPrefix, home), + accessPrefix + home + "." + randomToken(32), strings.Replace(good, ".", "!.", 1), + accessPrefix + strings.Repeat("a", 60) + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { if tokenShape(bad, accessPrefix) { t.Errorf("%q passed", bad) } } } -// Only clients FTW Cloud knows, by their document's host, are verified. -func TestVerifiedHosts(t *testing.T) { - for host, want := range map[string]bool{"claude.ai": true, "www.claude.ai": true, "chatgpt.com": true, "grok.com": true, - "evilclaude.ai": false, "claude.ai.evil.example": false, "example.com": false} { - if verifiedHost(host) != want { - t.Errorf("%s: %v", host, !want) +// Only clients FTW Cloud knows, by the exact URL of their document, are +// verified: not another document on the same host, and not a host that +// merely ends like it. +func TestVerifiedClients(t *testing.T) { + for id, want := range map[string]bool{ + "https://claude.ai/oauth/mcp-oauth-client-metadata": true, + "https://claude.ai/oauth/mcp-oauth-client-metadata/": false, + "https://claude.ai/other": false, + "https://www.claude.ai/oauth/mcp-oauth-client-metadata": false, + "https://evilclaude.ai/oauth/mcp-oauth-client-metadata": false, + "https://claude.ai.evil.example/oauth/mcp-oauth-client-metadata": false, + "https://chatgpt.com/oauth/client.json": false, + } { + if verifiedClient(id) != want { + t.Errorf("%s: %v", id, !want) } } } diff --git a/internal/oauth/offer.go b/internal/oauth/offer.go index d2437dd..753fc18 100644 --- a/internal/oauth/offer.go +++ b/internal/oauth/offer.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "net/http" "slices" - "strings" "sync" "github.com/fxamacker/cbor/v2" @@ -57,7 +56,7 @@ func buildOffer(c consent) protocol.GrantOffer { // signOffer encodes and signs an offer as the contract's SignedOffer // {o, kid, sig}, base64url, for the link's fragment and the code lookup. -func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { +func (s *Server) signOffer(ctx context.Context, kid string, o protocol.GrantOffer) (string, error) { pub, err := s.offerPub(ctx) if err != nil { return "", err @@ -66,7 +65,7 @@ func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, if err != nil { return "", err } - sig, err := s.keys.SignOffer(ctx, raw) + sig, err := s.keys.SignOffer(ctx, kid, raw) if err != nil { return "", err } @@ -95,26 +94,21 @@ func (s *Server) offerPub(ctx context.Context) ([]byte, error) { return pub, nil } -// appLink is the universal link the QR and the button carry: the request -// id in the path, for the app's answer, and the signed offer in the -// fragment, which no server sees. -func appLink(rid, offer string) string { return AppLink + rid + "#" + offer } +// appLink is the universal link the QR and the button carry. Its fragment, +// which no server sees, holds the request id, a dot, and the signed offer; +// neither is base64url's alphabet's dot. +func appLink(rid, offer string) string { return AppLink + "#" + rid + "." + offer } -// verifiedHosts are clients FTW Cloud knows by the host of their metadata -// document. A client from one of them is shown as verified; any other is -// shown by its host and the name it gives itself. -var verifiedHosts = []string{"claude.ai", "claude.com", "anthropic.com", "chatgpt.com", "openai.com", "grok.com", "x.ai"} - -func verifiedHost(host string) bool { - host = strings.ToLower(host) - for _, h := range verifiedHosts { - if host == h || strings.HasSuffix(host, "."+h) { - return true - } - } - return false +// verifiedClients are clients FTW Cloud knows, by the exact URL of their +// metadata document. A client on the list is shown as verified; any other +// is shown by its host and the name it gives itself. An entry is added only +// once its document has been read at that URL and names it. +var verifiedClients = []string{ + "https://claude.ai/oauth/mcp-oauth-client-metadata", // read 15 Oct 2026 } +func verifiedClient(id string) bool { return slices.Contains(verifiedClients, id) } + // offerKey serves the offer key's public half, so anyone can check what the // app pins. type offerKey struct { diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 5996afc..a1595cb 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -35,13 +35,17 @@ type pageError struct { Nonce string Title string Text string + // Back is the client's error response, as a link the person may follow + // to BackHost. + Back string + BackHost string } func (s *Server) consentPage(w http.ResponseWriter, rid string, c consent) { u, _ := url.Parse(c.RedirectURI) d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID), RedirectHost: u.Host, Local: u.Scheme == "http" && isLoopback(u.Hostname()), - Code: c.Code[:3] + "-" + c.Code[3:], Link: appLink(rid, c.Offer)} + Code: showCode(c.Code), Link: appLink(rid, c.Offer)} for _, sc := range splitScope(c.Scope) { d.Scopes = append(d.Scopes, scopeWords[sc]) if !slices.Contains(ReadScopes, sc) { @@ -70,6 +74,7 @@ func pageHeaders(w http.ResponseWriter, nonce string) { h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") + h.Set("Strict-Transport-Security", "max-age=31536000") } // qrSVG draws text as a QR code, dark on light whatever the page's theme, @@ -113,7 +118,7 @@ button{font:inherit;background:none;border:1px solid var(--line);color:var(--fg) var consentTmpl = template.Must(template.New("consent").Parse(` Connect an assistant · FTW Cloud -
+

Let {{.Name}} into your home?

{{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}}

@@ -132,17 +137,22 @@ var consentTmpl = template.Must(template.New("consent").Parse(`

Waiting for your approval…

+