From febe17e14d3a22692feaf11e7e7bcfdca250ced2 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 03:07:38 +0530 Subject: [PATCH] feat(relay): self-certifying machine ids and a per-IP rate rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machine ids become -: the slug is the old mint verbatim (hostname slug + 4 random hex), the tag the first 8 lowercase hex of HMAC-SHA256(DAEMON_SECRET, "flue-machine-id/" + slug). Setup, join and the Remote-screen deploy all hold the secret at mint time and mint through the same config.MintMachineID; the Worker recomputes the tag statelessly before idFromName, so a forged or stale id earns the same 404 a malformed one does and no Durable Object wakes. On the daemon leg the tag is checked after the bearer secret — tag-first would hand the one unmetered route a tag oracle (404 vs 401) and a free HMAC per anonymous probe. The credential-less routes (/client/*, POST /api/pair/*) gain a Cloudflare rate-limiting binding (CLIENT_RATE, 300 per minute per IP), carried identically by relay/wrangler.jsonc for dev and by the API-driven deploy (internal/relaydeploy, a "ratelimit" metadata binding in internal/cloudflare). The Worker fails open without the binding: the rule bounds quota burn, not access. The daemon leg stays unmetered — secret-gated, one socket per machine. testdata/relay/machine-ids.json pins the tag arithmetic across languages: internal/config generates it (go test ./internal/config/ -update) and re-derives every case on ordinary runs, and the Worker suite walks the committed file. Breaking change, no back-compat: pre-tag ids are refused everywhere, and the sole deployment re-joins. Implements stage 1 of spec/fleet-trust.md (the spec rides its own branch and is deliberately not part of this change). Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/flue/main_test.go | 6 +- cmd/flue/relay.go | 13 +- cmd/flue/relay_test.go | 58 ++++-- cmd/flue/relayui.go | 5 +- docs/RELAY.md | 66 +++++-- internal/cloudflare/client.go | 43 +++++ internal/cloudflare/client_test.go | 1 + .../cloudflare/testdata/deploy_metadata.json | 9 + internal/config/machineid_fixture_test.go | 115 ++++++++++++ internal/config/relay.go | 56 ++++-- internal/config/relay_test.go | 72 ++++++-- internal/relaydeploy/deploy.go | 23 +++ internal/transport/relay/relay.go | 19 +- internal/transport/relay/relay_test.go | 13 +- relay/src/index.ts | 118 ++++++++++++- relay/test/harness.ts | 21 ++- relay/test/machineid.test.ts | 166 ++++++++++++++++++ relay/test/routing.test.ts | 106 +++++++++-- relay/wrangler.jsonc | 9 + spec/relay-protocol.md | 61 ++++++- testdata/relay/machine-ids.json | 60 +++++++ web/src/relay/machines.ts | 12 +- 22 files changed, 941 insertions(+), 111 deletions(-) create mode 100644 internal/config/machineid_fixture_test.go create mode 100644 relay/test/machineid.test.ts create mode 100644 testdata/relay/machine-ids.json diff --git a/cmd/flue/main_test.go b/cmd/flue/main_test.go index 7c06433..0aeef48 100644 --- a/cmd/flue/main_test.go +++ b/cmd/flue/main_test.go @@ -1259,7 +1259,7 @@ func TestStatusReportsAConfiguredRelayWithoutItsSecret(t *testing.T) { URL: "wss://flue-relay.example", Secret: secret, Origin: "https://flue-relay.example", - MachineID: "karns-macbook-pro-a1b2", + MachineID: "karns-macbook-pro-a1b2-0f9a12cd", MachineName: "Karn's MacBook Pro", }); err != nil { t.Fatalf("SaveRelay: %v", err) @@ -1304,7 +1304,7 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) { URL: "ws" + strings.TrimPrefix(ts.URL, "http"), Secret: secret, Origin: "https://r.example", - MachineID: "karns-macbook-pro-a1b2", + MachineID: "karns-macbook-pro-a1b2-0f9a12cd", MachineName: "Karn's MacBook Pro", }); err != nil { t.Fatalf("SaveRelay: %v", err) @@ -1330,7 +1330,7 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) { } // The machine id from relay.json rides the dial path — it is how the // Worker knows which machine's hub this socket is. - if got, want := path.Load().(string), "/daemon/karns-macbook-pro-a1b2"; got != want { + if got, want := path.Load().(string), "/daemon/karns-macbook-pro-a1b2-0f9a12cd"; got != want { t.Errorf("dial path = %q, want %q", got, want) } } diff --git a/cmd/flue/relay.go b/cmd/flue/relay.go index 9f25ee4..99e0d0d 100644 --- a/cmd/flue/relay.go +++ b/cmd/flue/relay.go @@ -276,8 +276,11 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri // This machine's identity on the relay: the id is the slot it dials // (/daemon/) and the name is its human label. Minted fresh on every // run like the secret — setup is the recovery path, and a stale id would - // resurrect whatever state the old hub was left holding. - machineID := config.MintMachineID(hostname, rand.Reader) + // resurrect whatever state the old hub was left holding. Minted under the + // fresh secret, necessarily: the id carries a MAC tag the Worker verifies + // against DAEMON_SECRET before routing (config.MachineIDTag), so an id and + // the secret it was minted under can only ever travel together. + machineID := config.MintMachineID(hostname, secret, rand.Reader) machineName := truncateRunes(hostname, machineNameMaxRunes) // Last, deliberately. relay.json is what makes the daemon dial, and every @@ -384,7 +387,11 @@ func runRelayJoin(w io.Writer, args []string) error { if machineName == "" { machineName = truncateRunes(hostname, machineNameMaxRunes) } - machineID := config.MintMachineID(hostname, rand.Reader) + // Minted under the secret from the join line: the id's MAC tag is what the + // Worker checks before routing (config.MachineIDTag), so a join pasted + // with the wrong secret produces an id the relay 404s — the same failure + // the daemon leg's 401 reports, found one dial later. + machineID := config.MintMachineID(hostname, *secret, rand.Reader) // The same shape setup writes, derived the same way: bare wss:// URL, the // https origin on the same host. SaveRelay is 0600 — the file holds the diff --git a/cmd/flue/relay_test.go b/cmd/flue/relay_test.go index d6f178a..353b569 100644 --- a/cmd/flue/relay_test.go +++ b/cmd/flue/relay_test.go @@ -48,10 +48,15 @@ type wireMetadata struct { MainModule string `json:"main_module"` CompatibilityDate string `json:"compatibility_date"` Bindings []struct { - Type string `json:"type"` - Name string `json:"name"` - ClassName string `json:"class_name"` - Text string `json:"text"` + Type string `json:"type"` + Name string `json:"name"` + ClassName string `json:"class_name"` + Text string `json:"text"` + NamespaceID string `json:"namespace_id"` + Simple *struct { + Limit int `json:"limit"` + Period int `json:"period"` + } `json:"simple"` } `json:"bindings"` KeepBindings []string `json:"keep_bindings"` Migrations *struct { @@ -357,9 +362,25 @@ func writeAPIErrorCode(w http.ResponseWriter, code int, msg string) { // --- helpers ----------------------------------------------------------------- -// machineIDRe is the relay's id grammar (relay/src/index.ts), which every id -// this command mints has to satisfy or the Worker answers the dial with 404. -var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) +// machineIDRe is the relay's id grammar (relay/src/index.ts): a slug, then an +// 8-hex MAC tag. Every id this command mints has to satisfy it — and its tag +// has to verify under the secret saved beside it (config.MachineIDTag) — or +// the Worker answers the dial with 404. +var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$`) + +// assertMachineIDMinted checks an id the CLI minted: inside the grammar, and +// tagged under the secret it will dial with — the property the Worker's +// verifyMachineId enforces before any id picks a Durable Object. +func assertMachineIDMinted(t *testing.T, id, secret string) { + t.Helper() + if !machineIDRe.MatchString(id) { + t.Fatalf("machine id %q is not inside the relay's grammar", id) + } + slug, tag := id[:len(id)-9], id[len(id)-8:] + if want := config.MachineIDTag(secret, slug); tag != want { + t.Fatalf("machine id %q carries tag %q, want %q — the tag must be the MAC of the slug under the saved secret, or the relay will never route this machine", id, tag, want) + } +} func oneAccount() []cloudflare.Account { return []cloudflare.Account{{ID: "acct-0123456789abcdef", Name: "Karn's Account"}} @@ -462,7 +483,7 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) { // running: without the ASSETS binding every /api/* fallthrough calls fetch // on undefined, and without observability there are no logs to find out // with. Both are silent in a deploy that otherwise reports success. - var gotAssets, gotHub, gotVersion bool + var gotAssets, gotHub, gotVersion, gotRate bool for _, b := range f.meta.Bindings { switch { case b.Type == "assets" && b.Name == "ASSETS": @@ -473,6 +494,14 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) { // The version stamp: what the Worker reports on /api/health, and // what lets a daemon see a relay older than itself. gotVersion = true + case b.Type == "ratelimit" && b.Name == "CLIENT_RATE": + // The per-IP bound on the credential-less routes. The numbers are + // pinned here because they have a dev-only twin in + // relay/wrangler.jsonc, and this deploy is the one users run. + if b.NamespaceID != "1001" || b.Simple == nil || b.Simple.Limit != 300 || b.Simple.Period != 60 { + t.Fatalf("ratelimit binding = %+v, want namespace 1001, 300 per 60s", b) + } + gotRate = true } } if !gotVersion { @@ -484,6 +513,9 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) { if !gotHub { t.Fatalf("no HUB -> DaemonHub durable object binding in %+v", f.meta.Bindings) } + if !gotRate { + t.Fatalf("no CLIENT_RATE ratelimit binding in %+v; the deployed relay would serve its credential-less routes unmetered", f.meta.Bindings) + } if f.meta.Observability == nil || !f.meta.Observability.Enabled { t.Fatalf("observability = %+v, want enabled", f.meta.Observability) } @@ -557,9 +589,7 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) { if saved.Origin != "https://"+host { t.Fatalf("relay.json origin = %q", saved.Origin) } - if !machineIDRe.MatchString(saved.MachineID) { - t.Fatalf("relay.json machine_id = %q, which is not a valid machine id", saved.MachineID) - } + assertMachineIDMinted(t, saved.MachineID, saved.Secret) hostname, err := os.Hostname() if err != nil { t.Fatalf("os.Hostname: %v", err) @@ -883,9 +913,7 @@ func TestRunRelayJoinWritesTheRelayConfig(t *testing.T) { if saved.Secret != "s3cr3t-from-setup" { t.Fatalf("relay.json secret = %q, want the one given", saved.Secret) } - if !machineIDRe.MatchString(saved.MachineID) { - t.Fatalf("relay.json machine_id = %q, which is not a valid machine id", saved.MachineID) - } + assertMachineIDMinted(t, saved.MachineID, saved.Secret) hostname, err := os.Hostname() if err != nil { t.Fatalf("os.Hostname: %v", err) @@ -1009,7 +1037,7 @@ func TestRunRelayStatusReportsTheConfiguredRelay(t *testing.T) { URL: "wss://flue-relay.karn.workers.dev", Secret: "s3cret-value", Origin: "https://flue-relay.karn.workers.dev", - MachineID: "karns-macbook-pro-a1b2", + MachineID: "karns-macbook-pro-a1b2-0f9a12cd", MachineName: "Karn's MacBook Pro", }); err != nil { t.Fatalf("SaveRelay: %v", err) diff --git a/cmd/flue/relayui.go b/cmd/flue/relayui.go index 50fc08f..5496530 100644 --- a/cmd/flue/relayui.go +++ b/cmd/flue/relayui.go @@ -339,8 +339,9 @@ func (s *relayUIService) Provision(ctx context.Context, req daemon.RelayUIDeploy } // The same record `flue relay setup` writes, for the same reasons — see - // runRelaySetup for why the id and secret are fresh and the write is last. - machineID := config.MintMachineID(hostname, rand.Reader) + // runRelaySetup for why the id and secret are fresh, why the id is minted + // under the fresh secret, and why the write is last. + machineID := config.MintMachineID(hostname, secret, rand.Reader) machineName := truncateRunes(hostname, machineNameMaxRunes) if err := config.SaveRelay(config.Relay{ URL: "wss://" + host, diff --git a/docs/RELAY.md b/docs/RELAY.md index 6b389fd..d3e16c2 100644 --- a/docs/RELAY.md +++ b/docs/RELAY.md @@ -25,10 +25,14 @@ daemon ---- wss /daemon/ ----> Worker + one DO per machine <---- wss /cli ``` - **The `` in the path is routing, not identity.** It is the machine id a - daemon joined under (`-<4 hex>`, minted by setup and join) and - the Worker turns it into that machine's own Durable Object (`idFromName`), - handing the hub the bare path. One lowercase slug of 1–63 characters; - anything else, including a bare `/daemon` or `/client`, is answered + daemon joined under (`-<4 hex>-<8 hex tag>`, minted by setup and + join) and the Worker turns it into that machine's own Durable Object + (`idFromName`), handing the hub the bare path. The tag is a MAC over the + rest of the id under the daemon secret, and the Worker verifies it before + routing — so only ids this relay's own setup or join minted exist, and a + guessed or hand-made id wakes nothing. One lowercase slug of at most 63 + characters ending in the 8-hex tag; anything else — a bare `/daemon` or + `/client`, a tampered tag, an id from before tags — is answered `404 {"error":"no such machine"}` and never with an asset. - **The daemon leg is outbound.** Nothing on your machine listens for the relay's sake. Every binary message on it is `[4-byte big-endian channel]` then @@ -89,8 +93,11 @@ flue relay join wss://flue-relay..workers.dev --secret <...> Run it there and restart that daemon. That is the whole of adding a machine. Join never touches the Cloudflare API: the Worker exists and the secret is the whole credential, so everything it does is local, check the address, mint -this machine a fresh id (`-<4 hex>`, its slot on the relay and the -`` in both wss paths), and write the same `relay.json` shape setup writes. +this machine a fresh id (`-<4 hex>-<8 hex tag>`, its slot on the +relay and the `` in both wss paths — the tag is a MAC under the secret +from the join line, which is why an id minted with a mistyped secret dials +into `404 no such machine`), and write the same `relay.json` shape setup +writes. `--name` sets the label the machine picker shows; it defaults to the hostname and rides the pairing link's query (`n=`) so the pairing browser can write it down, never a path, and never anything the Worker routes on. The printed line carries the secret (that is the @@ -110,7 +117,10 @@ and the secret are upserts) but it is a reset, not a repair: every run mints a fresh secret *and* a fresh machine id. The fresh secret is deliberate: setup is the recovery path for a leaked one, and a run that reused the old could never rotate it, and it means every machine that joined is now -presenting a stale secret and has to run the newly printed join line. The +presenting a stale secret and has to run the newly printed join line — and, +because ids carry a MAC tag minted under the secret, every old id stops +routing at the same moment (the re-join each machine runs anyway mints its +fresh one). The fresh id means the old hub slot is simply abandoned: a browser that paired against it is dialling a slot no daemon dials, answered `503 daemon offline` until it pairs this machine again and forgets the old row in the picker. To @@ -298,6 +308,25 @@ machine: | pairing body cap | 4 KiB | an oversized POST; over it, `413` | | pairing answer deadline | 10 s | a daemon that never answers; `504` | +Two more run in the Worker itself, before any hub is picked: + +- **MAC machine ids.** An id only routes if its 8-hex tag verifies under the + daemon secret (`spec/relay-protocol.md`, Auth), so the whole space of + guessed, scanned or hand-made ids answers `404` without waking a Durable + Object. What used to be "any grammar-valid id wakes an object" is now "only + ids this relay minted exist". +- **A per-IP rate rule.** A Cloudflare rate-limiting binding covers + `/client/*` and `POST /api/pair/*`: 300 requests per minute per IP (per + Cloudflare location), `429 {"error":"rate limited"}` over it. A fleet of + tabs — reconnect storms included — never sees it; spending a free-plan + relay's daily request allowance, or brute-walking the 2^32 tag space, + needs a botnet. The daemon leg is exempt: secret-gated, one socket per + machine. Wired by `flue relay setup`/`update` (internal/relaydeploy) and + by `relay/wrangler.jsonc` for dev, so a deployed relay and the one under + `pnpm dev` carry the same rule. Cloudflare's own WAF rate-limiting rules + (dashboard → Security) remain available on top if your traffic wants a + tighter number. + The message cap is the one whose *number* matters beyond itself: the daemon reads the socket carrying every browser on your machine with a 2 MiB limit that kills the connection rather than the message, so the relay's 1 MiB has to stay @@ -310,16 +339,14 @@ anyone holding the relay URL can probe which of your machines are up, which machines exist and when they are online, never what they carry, because everything a channel forwards is still behind Noise. -**Worth adding yourself: a rate limit on `/api/pair`.** That endpoint carries no -credential by design, and the caps above bound how many attempts one caller can -*hold* rather than how fast they can arrive. A wrong token costs you nothing: -it does not spend your pairing window, so a flood cannot stop you pairing, but -it can spend a free-plan relay's daily request allowance. Cloudflare's own -**Rate Limiting rules** (dashboard → your Worker's route → Security → WAF) are -free, run at the edge before the Durable Object wakes, and are the right place -for a limit that depends on your traffic rather than on this code. Something -like 10 requests per minute per IP on `/api/pair` is far above any human -ceremony (`docs/FOLLOW-UPS.md` item 13). +The rate rule above is the shipped answer to the flood that used to be worth +adding a WAF rule for: a wrong pairing token still costs you nothing (it does +not spend your pairing window), and now the arrival *rate* is bounded too, +not just how many attempts one caller can hold. What the in-code rule cannot +be is traffic-aware — 300/min/IP is a ceiling for abuse, not a fit to your +usage — so a WAF Rate Limiting rule on `/api/pair` (something like 10 +requests per minute per IP, far above any human ceremony) remains a sensible +addition on a relay that sees hostile traffic (`docs/FOLLOW-UPS.md` item 13). **What does not exist yet is an output-rate cap.** A session that streams continuously (`yes`, `tail -f` on a firehose) pins the object active and floods @@ -429,8 +456,9 @@ a dev build carries no Worker to deploy. - [ ] The isolation check, by hand: stop machine A's daemon, leave B's up, and `curl -si --http1.1 -H 'Upgrade: websocket' https:///client/` answers `503` `{"error":"daemon offline"}`. B's daemon being up must - never answer for A. The same command against a bare `/client`, or an id - with a capital in it, answers `404` `{"error":"no such machine"}`. + never answer for A. The same command against a bare `/client`, an id + with a capital in it, or A's id with one tag character changed, answers + `404` `{"error":"no such machine"}`. - [ ] Re-run `flue relay setup` on the same account. It succeeds (the deploy and the secret are upserts) and it is a reset: the phone pairs this machine again (the fresh machine id abandons the slot its old row diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go index 9f53636..4184654 100644 --- a/internal/cloudflare/client.go +++ b/internal/cloudflare/client.go @@ -391,6 +391,13 @@ type DeployInput struct { // script in the dashboard — nothing secret may ever ride one. PlainTextVars map[string]string + // RateLimits become bindings of type "ratelimit" — Cloudflare's Workers + // rate-limiting binding, the same object wrangler's `ratelimits` config + // key produces. The wire shape is + // {"type":"ratelimit","name":…,"namespace_id":…,"simple":{"limit":…,"period":…}}, + // pinned by testdata/deploy_metadata.json. + RateLimits []RateLimit + // AssetHeaders is a `_headers` document — response headers for the static // assets, in the file format Cloudflare's asset router parses. Empty sends // no header config at all. See assetsConfig.Headers for why this travels in @@ -445,6 +452,28 @@ type binding struct { ClassName string `json:"class_name,omitempty"` // Text is only present on plain_text bindings; every other type omits it. Text string `json:"text,omitempty"` + // NamespaceID and Simple are only present on ratelimit bindings. The + // namespace id is a string on the wire — Cloudflare's schema says so, + // wrangler sends it so — even though its content is an integer. + NamespaceID string `json:"namespace_id,omitempty"` + Simple *rateLimitSimple `json:"simple,omitempty"` +} + +// RateLimit is one Workers rate-limiting binding: name is what the Worker +// reads it off env as, namespace_id keys the limiter's state (unique per +// limiter within the account), and limit/period are the rule — at most limit +// requests per period seconds per key per Cloudflare location. Cloudflare +// accepts only 10 or 60 for period. +type RateLimit struct { + Name string + NamespaceID string + Limit int + Period int +} + +type rateLimitSimple struct { + Limit int `json:"limit"` + Period int `json:"period"` } type observability struct { @@ -536,6 +565,20 @@ func (c *Client) Deploy(ctx context.Context, in DeployInput) error { }) } + // Rate limiters after the plain-text vars, sorted by name like the rest: + // deterministic order is what keeps two deploys of the same input the same + // request. + limiters := append([]RateLimit(nil), in.RateLimits...) + sort.Slice(limiters, func(i, j int) bool { return limiters[i].Name < limiters[j].Name }) + for _, rl := range limiters { + meta.Bindings = append(meta.Bindings, binding{ + Type: "ratelimit", + Name: rl.Name, + NamespaceID: rl.NamespaceID, + Simple: &rateLimitSimple{Limit: rl.Limit, Period: rl.Period}, + }) + } + // The assets binding goes after the Durable Object ones, which is the order // wrangler emits, and is only meaningful when there are assets to bind to. if in.AssetsBinding != "" && len(in.Assets) > 0 { diff --git a/internal/cloudflare/client_test.go b/internal/cloudflare/client_test.go index 64f4834..d8062b2 100644 --- a/internal/cloudflare/client_test.go +++ b/internal/cloudflare/client_test.go @@ -53,6 +53,7 @@ func deployFixture() DeployInput { AssetsRunWorkerFirst: []string{"/daemon", "/client", "/api/*"}, AssetHeaders: "/*\n X-Fixture: yes\n", AssetsBinding: "ASSETS", + RateLimits: []RateLimit{{Name: "CLIENT_RATE", NamespaceID: "1001", Limit: 300, Period: 60}}, Observability: true, } } diff --git a/internal/cloudflare/testdata/deploy_metadata.json b/internal/cloudflare/testdata/deploy_metadata.json index 827f075..cbfd695 100644 --- a/internal/cloudflare/testdata/deploy_metadata.json +++ b/internal/cloudflare/testdata/deploy_metadata.json @@ -7,6 +7,15 @@ "name": "HUB", "class_name": "DaemonHub" }, + { + "type": "ratelimit", + "name": "CLIENT_RATE", + "namespace_id": "1001", + "simple": { + "limit": 300, + "period": 60 + } + }, { "type": "assets", "name": "ASSETS" diff --git a/internal/config/machineid_fixture_test.go b/internal/config/machineid_fixture_test.go new file mode 100644 index 0000000..c72cf84 --- /dev/null +++ b/internal/config/machineid_fixture_test.go @@ -0,0 +1,115 @@ +package config + +import ( + "encoding/json" + "flag" + "os" + "regexp" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "regenerate testdata/relay/machine-ids.json") + +// machineIDFixturePath is the cross-language contract for the machine-id MAC +// tag: this package generates it, the Worker's suite walks it +// (relay/test/machineid.test.ts), and the two implementations of +// HMAC-SHA256(secret, "flue-machine-id/"+slug) are thereby pinned to each +// other — the same role testdata/relay/frames.json plays for the framing. +const machineIDFixturePath = "../../testdata/relay/machine-ids.json" + +type machineIDFixtureFile struct { + Cases []machineIDFixtureCase `json:"cases"` +} + +// machineIDFixtureCase is one (secret, slug) pair with the tag and the full +// id they must produce. The secret rides in cleartext because none of these +// are credentials: they exist to pin arithmetic. +type machineIDFixtureCase struct { + Name string `json:"name"` + Secret string `json:"secret"` + Slug string `json:"slug"` + Tag string `json:"tag"` + ID string `json:"id"` +} + +// machineIDFixtureCases are the (secret, slug) pairs the fixture pins; tags +// and ids are derived at regeneration time. The slugs cover the mint's edges: +// the hostname fallback, a single character of hostname, the 24-character +// truncation ceiling, consecutive dashes (a hostname like "a .b" sanitizes to +// them), and a slug that is itself hex-shaped — the case a parser that hunts +// for "the hex part" instead of "the last 9 characters" gets wrong. Two +// entries share a slug under different secrets, which is what pins that the +// secret is actually in the MAC. "test-secret" is deliberately the secret the +// relay vitest pool binds (relay/vitest.config.ts), so the Worker suite +// exercises the same values its own router runs under. +func machineIDFixtureCases() []machineIDFixtureCase { + pairs := []struct{ name, secret, slug string }{ + {"ordinary", "test-secret", "karns-macbook-pro-a1b2"}, + {"hostname-fallback", "test-secret", "machine-ff00"}, + {"single-char-host", "test-secret", "a-0000"}, + {"truncated-24", "test-secret", strings.Repeat("a", 24) + "-ffff"}, + {"digit-led", "test-secret", "0g-b2c3"}, + {"inner-double-dash", "test-secret", "a--b-1a2b"}, + {"hex-shaped-slug", "test-secret", "deadbeef-cafe"}, + {"same-slug-other-secret", "wqLmxN2NKlWNy2qk_tt1kaB4-JJqMRC0lYAllcnrRlk", "karns-macbook-pro-a1b2"}, + } + cases := make([]machineIDFixtureCase, 0, len(pairs)) + for _, p := range pairs { + tag := MachineIDTag(p.secret, p.slug) + cases = append(cases, machineIDFixtureCase{ + Name: p.name, + Secret: p.secret, + Slug: p.slug, + Tag: tag, + ID: p.slug + "-" + tag, + }) + } + return cases +} + +// TestMachineIDFixture re-derives every committed case on every run — which is +// what catches a drifted prefix or truncation — and, with -update, rewrites +// the file. The committed file is the artifact; the Worker asserts against it +// without regenerating. +func TestMachineIDFixture(t *testing.T) { + derived := machineIDFixtureCases() + + if *update { + b, err := json.MarshalIndent(machineIDFixtureFile{Cases: derived}, "", " ") + if err != nil { + t.Fatalf("marshalling the fixture: %v", err) + } + b = append(b, '\n') + if err := os.WriteFile(machineIDFixturePath, b, 0o644); err != nil { + t.Fatalf("writing %s: %v", machineIDFixturePath, err) + } + } + + raw, err := os.ReadFile(machineIDFixturePath) + if err != nil { + t.Fatalf("reading %s (regenerate with -update): %v", machineIDFixturePath, err) + } + var committed machineIDFixtureFile + if err := json.Unmarshal(raw, &committed); err != nil { + t.Fatalf("decoding %s: %v", machineIDFixturePath, err) + } + if len(committed.Cases) != len(derived) { + t.Fatalf("%s carries %d cases, this package derives %d; regenerate with -update", machineIDFixturePath, len(committed.Cases), len(derived)) + } + + // Every id in the fixture must be inside the grammar every consumer + // enforces (relay/src/index.ts MACHINE_ID, internal/transport/relay + // machineIDRe) — a fixture that pinned an unroutable id would pin a bug. + idRe := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$`) + + for i, want := range committed.Cases { + got := derived[i] + if got != want { + t.Errorf("case %q: this package derives %+v, the committed fixture says %+v; regenerate with -update if the change is intended", want.Name, got, want) + } + if !idRe.MatchString(want.ID) { + t.Errorf("case %q: id %q is outside the machine-id grammar", want.Name, want.ID) + } + } +} diff --git a/internal/config/relay.go b/internal/config/relay.go index 99d4d67..5dee03f 100644 --- a/internal/config/relay.go +++ b/internal/config/relay.go @@ -1,6 +1,8 @@ package config import ( + "crypto/hmac" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -61,8 +63,9 @@ type Relay struct { } // Machine-id shape. The relay refuses ids outside its grammar with a 404 -// (relay/src/index.ts, one lowercase slug of at most 63 characters), so -// everything here exists to make a mint from any hostname land inside it. +// (relay/src/index.ts: one lowercase slug ending in an 8-hex tag, at most 63 +// characters), so everything here exists to make a mint from any hostname +// land inside it. const ( // machineIDHostChars is how much of the sanitized hostname an id keeps. // Enough to recognise the machine in a list; short enough that the id @@ -71,18 +74,50 @@ const ( // machineIDRandBytes is the entropy after the hostname, hex-encoded. Two // bytes is not a credential — the id is public, it appears in URLs — it is // what keeps two machines that share a hostname from silently replacing - // each other on the relay. + // each other on the relay. The MAC tag below is deterministic and cannot + // do that job. machineIDRandBytes = 2 + + // machineIDTagPrefix is the domain separator the tag's HMAC runs under, so + // a machine-id tag can never be confused with any other MAC the secret + // might one day compute. The exact string is part of the wire contract: + // the Worker recomputes it (relay/src/index.ts, machineTag) and + // testdata/relay/machine-ids.json pins both sides to it. + machineIDTagPrefix = "flue-machine-id/" + // machineIDTagBytes is how much of the HMAC the tag keeps, hex-encoded to + // 8 characters. The tag is not a credential — the id is public, it rides + // pairing links — it only has to make *minting* a routable id require the + // daemon secret, and 2^32 online guesses through a rate-limited Worker is + // the bound the spec asks for (spec/fleet-trust.md). + machineIDTagBytes = 4 ) +// MachineIDTag is the self-certifying suffix of a machine id: the first 8 +// lowercase hex characters of HMAC-SHA256(secret, "flue-machine-id/"+slug). +// +// The Worker recomputes exactly this before it lets any id pick a Durable +// Object (relay/src/index.ts, machineTag), which is what closes the open id +// namespace: an id whose tag does not verify is answered with the same 404 a +// malformed id gets, and no object wakes. The two implementations are pinned +// to each other by testdata/relay/machine-ids.json. +func MachineIDTag(secret, slug string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(machineIDTagPrefix + slug)) + return hex.EncodeToString(mac.Sum(nil)[:machineIDTagBytes]) +} + // MintMachineID makes the id a machine joins the relay under: -// `-<4 lowercase hex>`. +// `-<4 lowercase hex>-<8 hex tag>`. // -// The hostname part is for the human reading a machine list; the hex is for -// the relay, where the id is the routing key and a collision means the second -// machine evicts the first from its hub. r is crypto/rand.Reader everywhere -// but tests, which inject fixed bytes to pin the format. -func MintMachineID(hostname string, r io.Reader) string { +// The hostname part is for the human reading a machine list; the random hex +// is for the relay, where the id is the routing key and a collision means the +// second machine evicts the first from its hub; the tag is MachineIDTag over +// everything before it, and is what makes the id verifiable by the Worker +// without any registry. secret is the relay's DAEMON_SECRET — both minting +// commands (`flue relay setup`, `flue relay join`) hold it at mint time, and +// an id minted under any other string is unroutable. r is crypto/rand.Reader +// everywhere but tests, which inject fixed bytes to pin the format. +func MintMachineID(hostname, secret string, r io.Reader) string { host := sanitizeHostname(hostname) if host == "" { // A hostname of dots, dashes or characters no slug can keep. The id @@ -97,7 +132,8 @@ func MintMachineID(hostname string, r io.Reader) string { // should fail loudly rather than register on a relay. panic(fmt.Sprintf("config: reading randomness for a machine id: %v", err)) } - return host + "-" + hex.EncodeToString(raw[:]) + slug := host + "-" + hex.EncodeToString(raw[:]) + return slug + "-" + MachineIDTag(secret, slug) } // sanitizeHostname folds a hostname into the id grammar: lowercased, spaces diff --git a/internal/config/relay_test.go b/internal/config/relay_test.go index b27fa85..b46445f 100644 --- a/internal/config/relay_test.go +++ b/internal/config/relay_test.go @@ -206,30 +206,49 @@ func TestLoadRelayKeepsAnIncompleteFile(t *testing.T) { } // TestMintMachineID pins the id format machines join the relay under: -// `-<4 lowercase hex>`. The hostname part -// is what makes an id readable in a machine list; the random suffix is what -// keeps two machines with the same hostname from silently replacing each other -// on the relay. +// `-<4 lowercase hex>-<8 hex tag>`. The +// hostname part is what makes an id readable in a machine list; the random +// hex is what keeps two machines with the same hostname from silently +// replacing each other on the relay; the tag is what makes the id verifiable +// by the Worker (MachineIDTag — its exact arithmetic is pinned cross-language +// by testdata/relay/machine-ids.json, so this test is about composition). func TestMintMachineID(t *testing.T) { + const secret = "test-secret" + // The exact slug, pinned: lowercased, the apostrophe dropped, spaces and - // dots folded to dashes, and the injected randomness hex-encoded on the end. + // dots folded to dashes, the injected randomness hex-encoded, and the MAC + // tag over everything before it appended. fixed := bytes.NewReader([]byte{0xa1, 0xb2}) - if got, want := MintMachineID("Karn's MacBook Pro.local", fixed), "karns-macbook-pro-local-a1b2"; got != want { + slug := "karns-macbook-pro-local-a1b2" + if got, want := MintMachineID("Karn's MacBook Pro.local", secret, fixed), slug+"-"+MachineIDTag(secret, slug); got != want { t.Fatalf("MintMachineID = %q, want %q", got, want) } // The hostname part is truncated to 24 characters so the id stays readable // and comfortably inside the relay's 63-character limit, whatever a fleet's // naming convention produces. - long := MintMachineID(strings.Repeat("a", 40), bytes.NewReader([]byte{0, 0})) - if want := strings.Repeat("a", 24) + "-0000"; long != want { + long := MintMachineID(strings.Repeat("a", 40), secret, bytes.NewReader([]byte{0, 0})) + longSlug := strings.Repeat("a", 24) + "-0000" + if want := longSlug + "-" + MachineIDTag(secret, longSlug); long != want { t.Fatalf("MintMachineID(40×a) = %q, want %q", long, want) } + // The tag depends on the secret: the same hostname and randomness under + // another secret is another id, which is exactly why re-setup (a fresh + // secret) abandons every old slot. + other := MintMachineID("Karn's MacBook Pro.local", "another-secret", bytes.NewReader([]byte{0xa1, 0xb2})) + if other == slug+"-"+MachineIDTag(secret, slug) { + t.Fatalf("MintMachineID under a different secret minted the same id %q", other) + } + if !strings.HasPrefix(other, slug+"-") { + t.Fatalf("MintMachineID under a different secret = %q, want the same slug %q with a different tag", other, slug) + } + // Every mint matches the relay's id grammar (relay/src/index.ts), whatever // the hostname looked like — including ones that sanitize to nothing at - // all, which still have to produce a diallable id rather than a bare "-hex". - idRe := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + // all, which still have to produce a diallable id rather than a bare "-hex" + // — and its tag always verifies against the secret it was minted under. + idRe := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$`) for _, hostname := range []string{ "Karn's MacBook Pro.local", "plain", @@ -242,13 +261,40 @@ func TestMintMachineID(t *testing.T) { "名前", strings.Repeat("é", 30), } { - got := MintMachineID(hostname, bytes.NewReader([]byte{0xff, 0x00})) + got := MintMachineID(hostname, secret, bytes.NewReader([]byte{0xff, 0x00})) if !idRe.MatchString(got) { t.Errorf("MintMachineID(%q) = %q, which is not a valid machine id", hostname, got) } - if !strings.HasSuffix(got, "-ff00") { - t.Errorf("MintMachineID(%q) = %q, want the -ff00 suffix from the injected randomness", hostname, got) + if len(got) < 14 { // 1 slug char + "-ff00" + "-" + 8 tag chars + t.Errorf("MintMachineID(%q) = %q, impossibly short", hostname, got) + continue + } + mintedSlug := got[:len(got)-9] + if !strings.HasSuffix(mintedSlug, "-ff00") { + t.Errorf("MintMachineID(%q) = %q, want the -ff00 slug suffix from the injected randomness", hostname, got) } + if want := mintedSlug + "-" + MachineIDTag(secret, mintedSlug); got != want { + t.Errorf("MintMachineID(%q) = %q, want %q — the tag must be the MAC of the slug before it", hostname, got, want) + } + } +} + +// TestMachineIDTag pins the tag's shape and its inputs: 8 lowercase hex, +// deterministic, and sensitive to both the secret and the slug. The exact +// values are pinned cross-language by testdata/relay/machine-ids.json. +func TestMachineIDTag(t *testing.T) { + tag := MachineIDTag("test-secret", "karns-macbook-pro-a1b2") + if !regexp.MustCompile(`^[0-9a-f]{8}$`).MatchString(tag) { + t.Fatalf("MachineIDTag = %q, want 8 lowercase hex characters", tag) + } + if again := MachineIDTag("test-secret", "karns-macbook-pro-a1b2"); again != tag { + t.Fatalf("MachineIDTag is not deterministic: %q then %q", tag, again) + } + if MachineIDTag("other-secret", "karns-macbook-pro-a1b2") == tag { + t.Fatal("MachineIDTag ignores the secret") + } + if MachineIDTag("test-secret", "karns-macbook-pro-b3d4") == tag { + t.Fatal("MachineIDTag ignores the slug") } } diff --git a/internal/relaydeploy/deploy.go b/internal/relaydeploy/deploy.go index 167114d..ea2997c 100644 --- a/internal/relaydeploy/deploy.go +++ b/internal/relaydeploy/deploy.go @@ -52,6 +52,20 @@ const ( // deployed relay is older than the binary looking at it. VersionVar = "FLUE_VERSION" + // RateLimitBinding is the Cloudflare rate-limiting binding the Worker + // checks on its credential-less routes (/client/*, POST /api/pair/*), + // keyed by connecting IP (relay/src/index.ts, allowRate). The numbers are + // the spec's "order of 100/min per IP" (spec/fleet-trust.md, "Rate + // rule"): 300 requests per 60 s per IP per Cloudflare location is far + // past a fleet of tabs — even a whole office reconnecting through one NAT + // — while a quota-burning flood, or the 2^32 id-tag guessing walk, needs + // a botnet's worth of addresses to get anywhere. All three values have + // twins in relay/wrangler.jsonc (`ratelimits`); edit both or neither. + RateLimitBinding = "CLIENT_RATE" + rateLimitNamespaceID = "1001" + rateLimitRequests = 300 + rateLimitPeriodSecs = 60 + // StepTimeout bounds one ordinary API call; DeployTimeout bounds the // deploy itself, which uploads the whole web bundle over whatever link the // user has. Each step gets its own deadline rather than the whole flow @@ -162,6 +176,15 @@ func Deploy(in Input) error { // binding that call is on undefined. AssetsBinding: AssetsBinding, PlainTextVars: map[string]string{VersionVar: in.Version}, + // The Worker reads this binding optionally (fail-open), so a + // deploy from an older flue that never sent it leaves a working + // relay — just one without the per-IP bound this one carries. + RateLimits: []cloudflare.RateLimit{{ + Name: RateLimitBinding, + NamespaceID: rateLimitNamespaceID, + Limit: rateLimitRequests, + Period: rateLimitPeriodSecs, + }}, // A self-hosted relay has no operator but its user; Workers Logs // is the only way they will ever see why it did something. Observability: true, diff --git a/internal/transport/relay/relay.go b/internal/transport/relay/relay.go index 6023b4d..4f3d507 100644 --- a/internal/transport/relay/relay.go +++ b/internal/transport/relay/relay.go @@ -164,16 +164,21 @@ type Config struct { // the message names which field it was. var ErrIncompleteConfig = errors.New("relay: incomplete config") -// machineIDRe is the relay's own id grammar (relay/src/index.ts, MACHINE_ID; -// the browser's records are held to the same expression in -// web/src/relay/machines.ts): one lowercase slug of 1–63 characters. New +// machineIDRe is the relay's own id grammar (relay/src/index.ts, MACHINE_ID): +// one lowercase slug ending in an 8-hex MAC tag, at most 63 characters. New // holds MachineID to it because the id is the path this transport dials, and // the Worker answers anything outside the grammar with the same 404 a missing // machine gets — minted ids are always inside it (config.MintMachineID), so -// what this catches is a relay.json edited by hand, a `machine_id: "My-Mac"` -// that would otherwise dial into "no such machine" forever while the config -// looked complete. -var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) +// what this catches is a relay.json edited by hand — a `machine_id: "My-Mac"` +// — or one written before ids carried tags; either would otherwise dial into +// "no such machine" forever while the config looked complete. Shape only, +// deliberately: whether the tag *verifies* is the Worker's call, made against +// the secret the Worker holds, and a local recomputation could only agree +// with a relay.json that is self-consistent, not with the deployed relay. +// (The browser's records are held to the looser pre-tag expression in +// web/src/relay/machines.ts, which tagged ids also satisfy — the browser +// receives ids and never mints one, so it has no tag to check.) +var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$`) // Server is the surface the adapter drives — implemented by *daemon.Server. // diff --git a/internal/transport/relay/relay_test.go b/internal/transport/relay/relay_test.go index d60a3dc..d4db5ae 100644 --- a/internal/transport/relay/relay_test.go +++ b/internal/transport/relay/relay_test.go @@ -111,8 +111,10 @@ func (s *syncBuffer) String() string { // testMachineID is the id every transport in this file dials as. Distinctive // on purpose: the dial-path assertion is only worth something if a match could -// not be a coincidence. -const testMachineID = "karns-macbook-pro-a1b2" +// not be a coincidence. Tag-shaped (slug, then 8 hex) because New holds ids +// to the minted grammar; the tag need not *verify* here — that is the +// Worker's check, and this file's relay is a fake. +const testMachineID = "karns-macbook-pro-a1b2-0f9a12cd" // newTestTransport builds an adapter pointed at r. The identity and the device // store are the zero values: this task never runs a handshake, and a test that @@ -166,7 +168,7 @@ func runTransport(t *testing.T, tr *Transport) func() { func TestNewRefusesAnIncompleteConfig(t *testing.T) { t.Parallel() - full := Config{URL: "wss://relay.example", Secret: "s3cr3t", Origin: "https://relay.example", MachineID: "karns-mbp-a1b2"} + full := Config{URL: "wss://relay.example", Secret: "s3cr3t", Origin: "https://relay.example", MachineID: "karns-mbp-a1b2-0f9a12cd"} // Origin is the one worth stating a reason for. It is what every announced // open and every forwarded pair is checked against, so an empty one does @@ -190,8 +192,9 @@ func TestNewRefusesAnIncompleteConfig(t *testing.T) { {"no machine id", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin}}, {"nothing at all", Config{}}, {"a machine id with a capital", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: "My-Mac"}}, - {"a machine id led by a dash", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: "-a1b2"}}, - {"a machine id past 63 characters", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: strings.Repeat("a", 64)}}, + {"a machine id led by a dash", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: "-a1b2-0f9a12cd"}}, + {"a machine id without a MAC tag — the pre-tag mint", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: "karns-mbp-a1b2"}}, + {"a machine id past 63 characters", Config{URL: full.URL, Secret: full.Secret, Origin: full.Origin, MachineID: strings.Repeat("a", 55) + "-0f9a12cd"}}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/relay/src/index.ts b/relay/src/index.ts index 6a485e2..28a1278 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -2,6 +2,14 @@ export interface Env { HUB: DurableObjectNamespace ASSETS: Fetcher DAEMON_SECRET: string + /** The per-IP rate limiter over the credential-less routes (`/client/*`, + * `POST /api/pair/*`) — a Cloudflare rate-limiting binding, declared in + * wrangler.jsonc (`ratelimits`) and in the deploy `flue relay setup` builds + * (internal/relaydeploy, RateLimitBinding); the two must agree. Optional + * and fail-open: the rule bounds quota burn, it is not auth, and a Worker + * mid-upgrade (or an old deploy) must keep serving the fleet rather than + * refuse everyone until the binding lands. */ + CLIENT_RATE?: RateLimit /** Handshake deadline in ms — a test seam (vitest binds 50). Unset in * production, where the hub defaults to 30 000. */ HANDSHAKE_TIMEOUT_MS?: string | number @@ -37,8 +45,15 @@ export function authorizeDaemon(req: Request, env: Env): boolean { return diff === 0 } -/** The machine-id grammar: a lowercase hostname-shaped slug, 1–63 characters. */ -const MACHINE_ID = /^[a-z0-9][a-z0-9-]{0,62}$/ +/** + * The machine-id grammar: `-`, at most 63 characters in all — a + * lowercase hostname-shaped slug, then a dash, then an 8-hex MAC tag + * (spec/relay-protocol.md, Auth). The grammar is the cheap gate; whether the + * tag actually *verifies* under DAEMON_SECRET is `verifyMachineId`, run only + * on ids this expression admits, because `run_worker_first` puts this router + * in front of every request and a regex reject must not cost an HMAC. + */ +const MACHINE_ID = /^([a-z0-9][a-z0-9-]{0,53})-([0-9a-f]{8})$/ /** * The machine id in `/`, or null when the path is not exactly @@ -52,6 +67,47 @@ export function machineIdFrom(pathname: string, prefix: string): string | null { return MACHINE_ID.test(id) ? id : null } +/** + * The MAC tag for a slug: the first 8 lowercase hex characters of + * HMAC-SHA256(secret, "flue-machine-id/" + slug). The Go mint is the other + * half of this contract (internal/config, MachineIDTag) and + * testdata/relay/machine-ids.json pins the two to each other. + */ +export async function machineTag(secret: string, slug: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`flue-machine-id/${slug}`)) + return [...new Uint8Array(mac, 0, 4)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** + * Does this grammar-valid id end in the tag the secret would mint for its + * slug? This is what closes the open id namespace: a stateless router cannot + * know which ids exist, but it holds the one credential ids are minted under + * (spec/fleet-trust.md, "Self-certifying machine ids"). An id that fails is + * answered with the same 404 a malformed id gets, and no Durable Object + * wakes. False when the secret was never bound: no secret means no mint ever + * happened, so no id can be routable — the same fail-closed rule + * authorizeDaemon applies. + */ +export async function verifyMachineId(id: string, secret: string): Promise { + if (!secret) return false + const m = MACHINE_ID.exec(id) + if (!m) return false + const want = await machineTag(secret, m[1] as string) + const got = m[2] as string + // Constant-time compare, same shape as authorizeDaemon: both strings are 8 + // hex characters by construction. + let diff = 0 + for (let i = 0; i < want.length; i++) diff |= got.charCodeAt(i) ^ want.charCodeAt(i) + return diff === 0 +} + /** Does this path claim the prefix — the prefix itself or anything under it? */ function claims(pathname: string, prefix: string): boolean { return pathname === prefix || pathname.startsWith(`${prefix}/`) @@ -70,6 +126,40 @@ const noSuchMachine = () => headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }) +const rateLimited = () => + new Response('{"error":"rate limited"}', { + status: 429, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }) + +/** + * May this credential-less request proceed, under the per-IP rate rule? + * + * MAC ids close the *fake*-id surface (no Durable Object wakes for a forged + * id), but the real id is semi-public — it rides pairing links — and + * `run_worker_first` bills every request before any of this runs. This is the + * bound on that quota burn: one Cloudflare rate-limiting rule, keyed by + * connecting IP, generous enough that a fleet of tabs never sees it and tight + * enough that burning the daily allowance needs a botnet + * (spec/fleet-trust.md, "Rate rule"). It runs after the grammar check (a + * regex reject should not spend a limiter token) and before the tag HMAC (an + * over-limit caller gets no more crypto out of us, and the 2^32 tag-guessing + * walk the spec prices in is throttled to this same rule). + * + * Absent binding means allow: the rule bounds cost, it is not auth, and a + * deploy mid-upgrade must not refuse the whole fleet. The daemon leg is + * deliberately not behind this — it is secret-gated and one socket per + * machine. + */ +async function allowRate(req: Request, env: Env): Promise { + if (!env.CLIENT_RATE) return true + // Absent header (local dev, the vitest pool) means one shared bucket, + // which is exactly what those environments are. + const key = req.headers.get('CF-Connecting-IP') ?? '' + const { success } = await env.CLIENT_RATE.limit({ key }) + return success +} + export default { async fetch(req: Request, env: Env): Promise { const url = new URL(req.url) @@ -85,20 +175,40 @@ export default { if (claims(url.pathname, '/daemon')) { const id = machineIdFrom(url.pathname, '/daemon') if (id === null) return noSuchMachine() + // Bearer before tag, deliberately. The daemon leg is the one route the + // rate rule does not cover (it is secret-gated, one socket per + // machine), so the order is what keeps it that way: tag-first would + // hand the unthrottled route a free HMAC per anonymous request *and* a + // tag oracle — 404 for a bad tag, 401 for a good one — that lets the + // 2^32 guessing walk run where nothing meters it. Bearer-first answers + // every secretless probe 401, tag right or wrong. And a caller who + // passes the bearer check holds the very secret tags are minted from, + // so the tag check behind it can only ever catch honest staleness — an + // id minted under a secret that has since rotated away — for which "no + // such machine" is the truthful answer: re-setup abandoned that slot. if (!authorizeDaemon(req, env)) return unauthorized() + if (!(await verifyMachineId(id, env.DAEMON_SECRET))) return noSuchMachine() return toHub(id, '/daemon') } if (claims(url.pathname, '/client')) { const id = machineIdFrom(url.pathname, '/client') if (id === null) return noSuchMachine() + if (!(await allowRate(req, env))) return rateLimited() + if (!(await verifyMachineId(id, env.DAEMON_SECRET))) return noSuchMachine() return toHub(id, '/client') } if (claims(url.pathname, '/api/pair')) { const id = machineIdFrom(url.pathname, '/api/pair') if (id === null) return noSuchMachine() - if (req.method === 'POST') return toHub(id, '/api/pair') + if (req.method === 'POST') { + if (!(await allowRate(req, env))) return rateLimited() + if (!(await verifyMachineId(id, env.DAEMON_SECRET))) return noSuchMachine() + return toHub(id, '/api/pair') + } // A GET of a well-formed pair URL is a browser following a link; the - // SPA below answers it, the API does not. + // SPA below answers it, the API does not — so it spends no limiter + // token and earns no HMAC: asset requests are unmetered, and the tag + // check exists to guard Durable Object wakes, not page loads. } if (url.pathname === '/api/health' && req.method === 'GET') { // Liveness of the Worker and nothing else — no id, no Durable Object diff --git a/relay/test/harness.ts b/relay/test/harness.ts index 0da8b3e..1a00855 100644 --- a/relay/test/harness.ts +++ b/relay/test/harness.ts @@ -10,7 +10,7 @@ import { expect } from 'vitest' import { decodeFrame, encodeFrame } from '../src/frame' // Aliased: workers-types also declares a global `Env`, which would shadow a // bare `Env` inside the augmentation below. -import { machineIdFrom, type Env as RelayEnv } from '../src/index' +import { machineIdFrom, machineTag, type Env as RelayEnv } from '../src/index' // vitest-pool-workers 0.20 types `env` as `Cloudflare.Env`; teach it our bindings. declare global { @@ -21,13 +21,28 @@ declare global { export const BASE = 'https://relay.example' +/** The DAEMON_SECRET the vitest pool binds (vitest.config.ts). Ids the Worker + * suites dial must carry tags minted under it, or the router 404s them the + * way it 404s any forged id. */ +export const TEST_SECRET = 'test-secret' + +/** + * A MAC-valid machine id for a slug, tagged under the pool's secret — the + * shared helper every suite mints its ids through, exactly as `flue relay + * setup`/`join` mint real ones (internal/config, MintMachineID). + */ +export async function machineId(slug: string): Promise { + return `${slug}-${await machineTag(TEST_SECRET, slug)}` +} + /** * The one machine the DO suites live on. Which id is irrelevant to hub * internals — the Worker has already picked the object by the time the hub * runs — but every dial spells the public shape, so the suites read like the - * traffic they stand in for. + * traffic they stand in for: slug, then the MAC tag the router would have + * verified before any real request reached the hub. */ -export const MACHINE = 'test-machine-0a1b' +export const MACHINE = await machineId('test-machine-0a1b') /** * The path the hub itself receives for a public machine path. The Worker owns diff --git a/relay/test/machineid.test.ts b/relay/test/machineid.test.ts new file mode 100644 index 0000000..873e519 --- /dev/null +++ b/relay/test/machineid.test.ts @@ -0,0 +1,166 @@ +import { env, SELF } from 'cloudflare:test' +import { describe, expect, it } from 'vitest' + +import fixture from '../../testdata/relay/machine-ids.json' +import worker, { machineTag, verifyMachineId, type Env } from '../src/index' +import { machineId, TEST_SECRET } from './harness' + +const BASE = 'https://relay.example' + +/** + * The machine-id MAC against the shared fixture: internal/config generates + * testdata/relay/machine-ids.json from the Go mint (`go test + * ./internal/config/ -update`) and this suite is the other party to the + * contract — the Worker must derive the same tag from the same (secret, slug) + * or a daemon's minted id would 404 on its own relay forever. + */ +describe('the machine-id tag against the shared fixture', () => { + for (const c of fixture.cases) { + it(`derives ${c.name}: ${c.slug} → ${c.tag}`, async () => { + expect(await machineTag(c.secret, c.slug)).toBe(c.tag) + expect(`${c.slug}-${await machineTag(c.secret, c.slug)}`).toBe(c.id) + }) + + it(`verifies ${c.name} as routable under its own secret only`, async () => { + expect(await verifyMachineId(c.id, c.secret)).toBe(true) + expect(await verifyMachineId(c.id, `${c.secret}x`)).toBe(false) + }) + } +}) + +/** A stub env for router unit tests: every request the rate rule admits ends + * at a recognisable canned response instead of a real Durable Object. */ +function stubEnv(overrides: Partial): Env { + return { + DAEMON_SECRET: TEST_SECRET, + HUB: { + idFromName: (name: string) => name, + get: () => ({ fetch: async () => new Response('reached the hub', { status: 299 }) }), + }, + ASSETS: { fetch: async () => new Response('spa', { status: 200 }) }, + ...overrides, + } as unknown as Env +} + +const denyAll = { limit: async () => ({ success: false }) } + +describe('the rate rule on the credential-less routes', () => { + it('is bound by wrangler.jsonc for the pool, as it is by the deploy for users', () => { + // The binding must exist in the test env: wrangler.jsonc's `ratelimits` + // is the dev twin of the ratelimit binding internal/relaydeploy sends, + // and a pool without it would be exercising a Worker users never run. + expect(env.CLIENT_RATE).toBeDefined() + expect(typeof env.CLIENT_RATE?.limit).toBe('function') + }) + + it('answers 429 on /client when the limiter refuses', async () => { + const id = await machineId('limited-0a0a') + const res = await worker.fetch( + new Request(`${BASE}/client/${id}`, { headers: { Upgrade: 'websocket' } }), + stubEnv({ CLIENT_RATE: denyAll }), + ) + expect(res.status).toBe(429) + expect(await res.json()).toEqual({ error: 'rate limited' }) + }) + + it('answers 429 on POST /api/pair when the limiter refuses', async () => { + const id = await machineId('limited-0b0b') + const res = await worker.fetch( + new Request(`${BASE}/api/pair/${id}`, { method: 'POST', body: '{}' }), + stubEnv({ CLIENT_RATE: denyAll }), + ) + expect(res.status).toBe(429) + expect(await res.json()).toEqual({ error: 'rate limited' }) + }) + + it('spends no limiter token on a malformed id: the regex reject is free', async () => { + let calls = 0 + const counting = { + limit: async () => { + calls += 1 + return { success: true } + }, + } + const res = await worker.fetch( + new Request(`${BASE}/client/NOT-AN-ID`, { headers: { Upgrade: 'websocket' } }), + stubEnv({ CLIENT_RATE: counting }), + ) + expect(res.status).toBe(404) + expect(calls).toBe(0) + }) + + it('keys the limiter by the connecting IP', async () => { + const keys: string[] = [] + const recording = { + limit: async ({ key }: { key: string }) => { + keys.push(key) + return { success: true } + }, + } + const id = await machineId('keyed-0c0c') + const res = await worker.fetch( + new Request(`${BASE}/client/${id}`, { + headers: { Upgrade: 'websocket', 'CF-Connecting-IP': '203.0.113.9' }, + }), + stubEnv({ CLIENT_RATE: recording }), + ) + expect(res.status).toBe(299) + expect(keys).toEqual(['203.0.113.9']) + }) + + it('does not meter GET /api/pair: a browser following a pairing link is an asset request', async () => { + const id = await machineId('linked-0d0d') + const res = await worker.fetch( + new Request(`${BASE}/api/pair/${id}`), + stubEnv({ CLIENT_RATE: denyAll }), + ) + expect(res.status).toBe(200) + expect(await res.text()).toBe('spa') + }) + + it('does not meter the daemon leg: it is secret-gated, one socket per machine', async () => { + const id = await machineId('exempt-0e0e') + const res = await worker.fetch( + new Request(`${BASE}/daemon/${id}`, { + headers: { Upgrade: 'websocket', Authorization: `Bearer ${TEST_SECRET}` }, + }), + stubEnv({ CLIENT_RATE: denyAll }), + ) + expect(res.status).toBe(299) + }) + + it('fails open when the binding is absent: the rule bounds cost, it is not auth', async () => { + const id = await machineId('unbound-0f0f') + const res = await worker.fetch( + new Request(`${BASE}/client/${id}`, { headers: { Upgrade: 'websocket' } }), + stubEnv({ CLIENT_RATE: undefined }), + ) + expect(res.status).toBe(299) + }) +}) + +describe('the tag check through the real Worker', () => { + it('routes a fixture id minted under the pool secret', async () => { + // "test-secret" cases in the fixture are minted under the same secret the + // pool binds, so the real router accepts them end to end: this is the Go + // mint dialling the TS verifier. 503 is the empty hub's own refusal — + // past the router, an object woken, no daemon attached. + const minted = fixture.cases.find((c) => c.secret === TEST_SECRET) + expect(minted).toBeDefined() + const res = await SELF.fetch(`${BASE}/client/${minted?.id}`, { + headers: { Upgrade: 'websocket' }, + }) + expect(res.status).toBe(503) + expect(await res.json()).toEqual({ error: 'daemon offline' }) + }) + + it('404s the same slug under the fixture’s other secret: rotation, end to end', async () => { + const foreign = fixture.cases.find((c) => c.secret !== TEST_SECRET) + expect(foreign).toBeDefined() + const res = await SELF.fetch(`${BASE}/client/${foreign?.id}`, { + headers: { Upgrade: 'websocket' }, + }) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'no such machine' }) + }) +}) diff --git a/relay/test/routing.test.ts b/relay/test/routing.test.ts index bfdaa90..78793e5 100644 --- a/relay/test/routing.test.ts +++ b/relay/test/routing.test.ts @@ -1,14 +1,22 @@ import { SELF } from 'cloudflare:test' import { describe, expect, it } from 'vitest' -import { authorizeDaemon, machineIdFrom, type Env } from '../src/index' -import { controlFrame, decoder, Leg } from './harness' +import { authorizeDaemon, machineIdFrom, verifyMachineId, type Env } from '../src/index' +import { controlFrame, decoder, Leg, machineId, TEST_SECRET } from './harness' const BASE = 'https://relay.example' -/** The two machines this suite talks about: alpha gets a daemon, beta never does. */ -const ALPHA = 'alpha-1a2b' -const BETA = 'beta-9f8e' +/** The two machines this suite talks about: alpha gets a daemon, beta never + * does. Minted through the shared helper, so their tags verify under the + * pool's DAEMON_SECRET — the router checks the MAC before any hub wakes. */ +const ALPHA = await machineId('alpha-1a2b') +const BETA = await machineId('beta-9f8e') + +/** id with its MAC tag's last character flipped: grammar-valid, unroutable. */ +function tagFlipped(id: string): string { + const last = id.slice(-1) === '0' ? '1' : '0' + return id.slice(0, -1) + last +} function open(path: string, headers: Record = {}): Promise { return SELF.fetch(`${BASE}${path}`, { headers: { Upgrade: 'websocket', ...headers } }) @@ -39,8 +47,8 @@ async function daemonLeg(machine: string): Promise { describe('machineIdFrom', () => { it('reads the id out of /', () => { - expect(machineIdFrom('/daemon/alpha-1a2b', '/daemon')).toBe('alpha-1a2b') - expect(machineIdFrom('/api/pair/x0', '/api/pair')).toBe('x0') + expect(machineIdFrom('/daemon/alpha-1a2b-0123abcd', '/daemon')).toBe('alpha-1a2b-0123abcd') + expect(machineIdFrom('/api/pair/x0-01234567', '/api/pair')).toBe('x0-01234567') }) it('refuses the bare prefix and the empty id', () => { @@ -49,16 +57,27 @@ describe('machineIdFrom', () => { }) it('refuses uppercase — ids are minted lowercase, never case-folded here', () => { - expect(machineIdFrom('/daemon/ALPHA-1A2B', '/daemon')).toBeNull() + expect(machineIdFrom('/daemon/ALPHA-1A2B-0123ABCD', '/daemon')).toBeNull() + }) + + it('refuses an id without a MAC tag: the pre-tag mint is not grandfathered', () => { + expect(machineIdFrom('/daemon/alpha-1a2b', '/daemon')).toBeNull() }) - it('takes 63 characters and refuses 65: the hostname bound', () => { - expect(machineIdFrom(`/daemon/${'a'.repeat(63)}`, '/daemon')).toBe('a'.repeat(63)) - expect(machineIdFrom(`/daemon/${'a'.repeat(65)}`, '/daemon')).toBeNull() + it('refuses a tag that is not exactly 8 hex', () => { + expect(machineIdFrom('/daemon/alpha-1a2b-0123abc', '/daemon')).toBeNull() + expect(machineIdFrom('/daemon/alpha-1a2b-0123abcde', '/daemon')).toBeNull() + expect(machineIdFrom('/daemon/alpha-1a2b-0123abcg', '/daemon')).toBeNull() + }) + + it('takes 63 characters and refuses 64: the hostname bound, tag included', () => { + const max = `${'a'.repeat(54)}-01234567` + expect(machineIdFrom(`/daemon/${max}`, '/daemon')).toBe(max) + expect(machineIdFrom(`/daemon/a${max}`, '/daemon')).toBeNull() }) it('refuses a trailing slash', () => { - expect(machineIdFrom('/daemon/alpha-1a2b/', '/daemon')).toBeNull() + expect(machineIdFrom('/daemon/alpha-1a2b-0123abcd/', '/daemon')).toBeNull() }) it('refuses an embedded slash: one segment, not a subtree', () => { @@ -66,6 +85,24 @@ describe('machineIdFrom', () => { }) }) +describe('verifyMachineId', () => { + it('accepts an id whose tag the secret minted', async () => { + expect(await verifyMachineId(ALPHA, TEST_SECRET)).toBe(true) + }) + + it('refuses the same id with one tag character flipped', async () => { + expect(await verifyMachineId(tagFlipped(ALPHA), TEST_SECRET)).toBe(false) + }) + + it('refuses a valid id under a different secret: rotation invalidates every id', async () => { + expect(await verifyMachineId(ALPHA, 'rotated-away')).toBe(false) + }) + + it('fails closed on an empty secret, like authorizeDaemon', async () => { + expect(await verifyMachineId(ALPHA, '')).toBe(false) + }) +}) + describe('the relay Worker routes by machine id', () => { it('404s /daemon with no id: no such machine, never an asset', async () => { const res = await open('/daemon') @@ -79,6 +116,39 @@ describe('the relay Worker routes by machine id', () => { expect(await res.json()).toEqual({ error: 'no such machine' }) }) + it('404s a grammar-valid id whose MAC tag does not verify: /client', async () => { + // The same 404, the same body, as an id whose shape does not parse: a + // forged id and a malformed one are indistinguishable from outside, and + // neither wakes a Durable Object (spec/fleet-trust.md). + const res = await open(`/client/${tagFlipped(ALPHA)}`) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'no such machine' }) + }) + + it('404s a bad MAC tag on POST /api/pair', async () => { + const res = await SELF.fetch(`${BASE}/api/pair/${tagFlipped(ALPHA)}`, { + method: 'POST', + body: '{}', + }) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'no such machine' }) + }) + + it('answers a secretless probe of /daemon 401 whatever the tag: bearer before MAC', async () => { + // If the tag were checked first, 404-versus-401 would hand the one + // unthrottled route a free tag oracle. Both probes read the same. + expect((await open(`/daemon/${ALPHA}`)).status).toBe(401) + expect((await open(`/daemon/${tagFlipped(ALPHA)}`)).status).toBe(401) + }) + + it('404s an authorized daemon dial whose tag does not verify: a stale id after rotation', async () => { + const res = await open(`/daemon/${tagFlipped(ALPHA)}`, { + Authorization: 'Bearer test-secret', + }) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'no such machine' }) + }) + it('404s a path with an embedded slash: /daemon/a/b', async () => { const res = await open('/daemon/a/b') expect(res.status).toBe(404) @@ -117,13 +187,16 @@ describe('the relay Worker routes by machine id', () => { it('answers a client whose machine has no daemon from that hub: 503 offline', async () => { // The 503 is the hub's own refusal (src/hub.ts, offline), so the id // picked an object and the object ran. An asset answer would be a 200. - const res = await open('/client/lonely-0a0a') + const res = await open(`/client/${await machineId('lonely-0a0a')}`) expect(res.status).toBe(503) expect(await res.json()).toEqual({ error: 'daemon offline' }) }) it('parks no pairing for a machine with no daemon: 503 offline', async () => { - const res = await SELF.fetch(`${BASE}/api/pair/lonely-0b0b`, { method: 'POST', body: '{}' }) + const res = await SELF.fetch(`${BASE}/api/pair/${await machineId('lonely-0b0b')}`, { + method: 'POST', + body: '{}', + }) expect(res.status).toBe(503) expect(await res.json()).toEqual({ error: 'daemon offline' }) }) @@ -188,9 +261,10 @@ describe('the relay Worker routes by machine id', () => { // claim made of the Worker path: the re-wrap in toHub (a new Request on // the bare prefix) must hand the hub the exact body that was POSTed. // Key order, inner whitespace and characters a re-encoder would escape. - const daemon = await daemonLeg('pairful-3c4d') + const pairful = await machineId('pairful-3c4d') + const daemon = await daemonLeg(pairful) const body = '{"z": 1,\n "a": "<&>é", "token":"t"}' - const res = SELF.fetch(`${BASE}/api/pair/pairful-3c4d`, { method: 'POST', body }) + const res = SELF.fetch(`${BASE}/api/pair/${pairful}`, { method: 'POST', body }) const payload = await daemon.nextControlBytes() expect(decoder.decode(payload)).toBe(`{"type":"pair","id":1,"origin":"${BASE}","body":${body}}`) daemon.ws.send(controlFrame({ type: 'pairResult', id: 1, status: 200, body: { ok: true } })) diff --git a/relay/wrangler.jsonc b/relay/wrangler.jsonc index 10a96fb..1a449b8 100644 --- a/relay/wrangler.jsonc +++ b/relay/wrangler.jsonc @@ -29,5 +29,14 @@ }, "durable_objects": { "bindings": [{ "name": "HUB", "class_name": "DaemonHub" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["DaemonHub"] }], + // The per-IP bound on the credential-less routes (/client/*, POST + // /api/pair/*): generous enough that a fleet of tabs never sees it, tight + // enough that burning the daily request allowance needs a botnet + // (spec/fleet-trust.md, "Rate rule"). internal/relaydeploy ships the same + // binding — name, namespace, numbers — in the deploy request `flue relay + // setup` builds; edit both or neither. + "ratelimits": [ + { "name": "CLIENT_RATE", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } } + ], "observability": { "enabled": true } } diff --git a/spec/relay-protocol.md b/spec/relay-protocol.md index a69f408..dd04d8d 100644 --- a/spec/relay-protocol.md +++ b/spec/relay-protocol.md @@ -184,14 +184,50 @@ size of one client message. Both legs, and `POST /api/pair`, carry a **machine id** in the path — `/daemon/`, `/client/`, `/api/pair/` — and the Worker routes on it: `idFromName(id)` selects that machine's Durable Object, and the hub receives -the bare prefix, never the id. The id is a lowercase hostname-shaped slug of -1–63 characters (`^[a-z0-9][a-z0-9-]{0,62}$`), minted at setup or join time; -anything outside that — a bare prefix, an empty id, an embedded segment — is -answered `404 {"error":"no such machine"}` before any hub wakes. The id is -routing, not identity: the one bearer secret authorizes the daemon leg of -*every* machine's hub, and what keeps a machine's sessions its own is the -per-machine Noise key a browser pins at pairing, not the path. The honest +the bare prefix, never the id. The id is **self-certifying**: + +``` +machine-id = "-" +slug = lowercase hostname slug + "-" + 4 random hex, minted at setup + or join time (the randomness is what keeps two machines named + "mac" distinct — the tag is deterministic and cannot) +tag = first 8 lowercase hex of + HMAC-SHA256(DAEMON_SECRET, "flue-machine-id/" + slug) +``` + +The whole id matches `^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$` — at most 63 +characters, tag included. The Worker checks the tag statelessly beside the +grammar: an id whose tag does not verify is answered the same +`404 {"error":"no such machine"}` a malformed id gets — a bare prefix, an +empty id, an embedded segment, a pre-tag id — and no hub wakes. That is what +closes the open id namespace a stateless router would otherwise have: forging +a routable id means holding the secret, or driving 2^32 online guesses +through a Worker whose credential-less routes are rate limited (below). On +the daemon leg the tag is checked *after* the bearer secret, deliberately: +that leg is the one route the rate rule does not meter, and checking the tag +first would hand it an unthrottled tag oracle (404 for a bad tag, 401 for a +good one) plus an HMAC per anonymous probe — while a caller past the bearer +check holds the very secret tags are minted from, so the check behind it only +catches an id minted under a secret that has since rotated away. Rotating +`DAEMON_SECRET` (re-setup) therefore invalidates every id, which re-setup's +re-join re-mints anyway. + +The id is routing, not identity: the one bearer secret authorizes the daemon +leg of *every* machine's hub, and what keeps a machine's sessions its own is +the per-machine Noise key a browser pins at pairing, not the path. The honest limit of that shared secret is `docs/RELAY.md`, "One secret for the fleet". +The tag changes none of that — it authenticates the *mint*, not the caller. + +The real id is semi-public (it rides pairing links), and the Worker bills +every request before any of this runs, so the credential-less routes also sit +behind a **rate rule**: one Cloudflare rate-limiting binding, keyed by +connecting IP, over `/client/*` and `POST /api/pair/*` — 300 requests per +60 s per IP per Cloudflare location, answered `429 {"error":"rate limited"}` +over it. Generous enough that a fleet of tabs never sees it; tight enough +that burning quota, or walking the tag space, needs a botnet. The daemon leg +is not rate limited: it is secret-gated and one socket per machine. The rule +is fail-open by design — a Worker deployed without the binding routes rather +than refuses — because it bounds cost, not access. The size cap is **1 MiB**, and it is the relay's to enforce rather than the daemon's. A client frame over it closes that socket alone with `1009` @@ -252,3 +288,14 @@ role `testdata/noise/ik.json` plays for the handshake and Regenerate with `go test ./internal/relaywire/ -update`; the committed file is the artifact, and every case in it is asserted on every ordinary test run. + +`testdata/relay/machine-ids.json` plays the same role for the machine-id MAC: +`{name, secret, slug, tag, id}` cases, generated from `internal/config` +(`go test ./internal/config/ -update`, which also re-derives every committed +case on ordinary runs) and walked by the Worker suite +(`relay/test/machineid.test.ts`). An implementation must derive `tag` from +`(secret, slug)` exactly, and accept `id` under `secret` and under nothing +else. The cases include the hostname-fallback slug, the 24-character +truncation ceiling, inner double dashes, a hex-shaped slug — the case a +parser that hunts for "the hex part" instead of "the last nine characters" +gets wrong — and one slug tagged under two secrets. diff --git a/testdata/relay/machine-ids.json b/testdata/relay/machine-ids.json new file mode 100644 index 0000000..20a0581 --- /dev/null +++ b/testdata/relay/machine-ids.json @@ -0,0 +1,60 @@ +{ + "cases": [ + { + "name": "ordinary", + "secret": "test-secret", + "slug": "karns-macbook-pro-a1b2", + "tag": "8ac6565b", + "id": "karns-macbook-pro-a1b2-8ac6565b" + }, + { + "name": "hostname-fallback", + "secret": "test-secret", + "slug": "machine-ff00", + "tag": "282e38e4", + "id": "machine-ff00-282e38e4" + }, + { + "name": "single-char-host", + "secret": "test-secret", + "slug": "a-0000", + "tag": "34733cbe", + "id": "a-0000-34733cbe" + }, + { + "name": "truncated-24", + "secret": "test-secret", + "slug": "aaaaaaaaaaaaaaaaaaaaaaaa-ffff", + "tag": "435da8bc", + "id": "aaaaaaaaaaaaaaaaaaaaaaaa-ffff-435da8bc" + }, + { + "name": "digit-led", + "secret": "test-secret", + "slug": "0g-b2c3", + "tag": "a1bee1d8", + "id": "0g-b2c3-a1bee1d8" + }, + { + "name": "inner-double-dash", + "secret": "test-secret", + "slug": "a--b-1a2b", + "tag": "2ea84b7b", + "id": "a--b-1a2b-2ea84b7b" + }, + { + "name": "hex-shaped-slug", + "secret": "test-secret", + "slug": "deadbeef-cafe", + "tag": "abafc2d8", + "id": "deadbeef-cafe-abafc2d8" + }, + { + "name": "same-slug-other-secret", + "secret": "wqLmxN2NKlWNy2qk_tt1kaB4-JJqMRC0lYAllcnrRlk", + "slug": "karns-macbook-pro-a1b2", + "tag": "65bd6458", + "id": "karns-macbook-pro-a1b2-65bd6458" + } + ] +} diff --git a/web/src/relay/machines.ts b/web/src/relay/machines.ts index dd67647..222970b 100644 --- a/web/src/relay/machines.ts +++ b/web/src/relay/machines.ts @@ -19,10 +19,14 @@ import { deletePinnedDaemonKeyFor } from '@/crypto/keys' /** - * The machine-id grammar, exactly as the relay routes by it - * (relay/src/index.ts, MACHINE_ID): a hostname-shaped slug of 1–63 characters, - * no capitals. An id outside it never reaches a URL from here — the Worker - * answers 404 for it, and a record carrying one is treated as corrupt. + * The machine-id shape: a hostname-shaped slug of 1–63 characters, no + * capitals. Deliberately the superset of the grammar the relay routes by — + * relay/src/index.ts, MACHINE_ID, which additionally requires the trailing + * 8-hex MAC tag every minted id ends in. The browser receives ids from + * pairing links and never mints or verifies one (it holds no secret to + * verify with), so what this expression is for is narrower: an id outside it + * never reaches a URL from here — the Worker answers 404 for it, and a + * record carrying one is treated as corrupt. */ export const MACHINE_ID = /^[a-z0-9][a-z0-9-]{0,62}$/