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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions spec/fleet-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,57 @@ honours — so adopting the new one would trade a browser that lists what it can
reach for one that lists what it cannot. Pairing again is what mints a
certificate, and it stays the way out of that state.

### The third delivery: a machine's own browser

Neither delivery above reaches the tab a user opened on
`http://127.0.0.1:7717`. It ran no ceremony because it never needed one — the
session cookie is its credential and it was only ever talking to the machine it
is on — so it holds no device cert to present to a sibling; and the rule above
excludes it by name, because a session cookie authenticates no key. Nor can it
be sent to the QR: a pairing link lands on the *relay's* origin, which is a
different storage partition, so the ceremony would admit a browser that is not
this one. Before this the fleet silently collapsed to one machine on a fully
joined laptop, and nothing on any screen said why.

The daemon that served the page answers instead, on its loopback HTTP surface,
behind the same session token as everything else there:

```
POST /api/fleet/enrol body {publicKey} → {deviceId, deviceCert, fleetPub, machineId}
GET /api/fleet/directory the relay's own answer, byte for byte
```

**Enrolment grants no authority that the caller does not already hold.** A
client that can open `/ws` on loopback can spawn a shell, and a shell can read
`relay.json`, which holds the fleet *seed* — so it could already mint a cert for
any key it liked, valid on every machine. The endpoint collapses three steps
into one for the honest case and changes nothing for the dishonest one. For the
same reason it is HTTP on loopback and **must never acquire a wire-protocol
equivalent**: a relay-origin device cannot read `relay.json`, so a `wire.Enrol`
*would* be an escalation — admission to one machine becoming the power to
manufacture admission to every machine, for keys nobody has proved they hold.
It is idempotent by lookup, so a browser asks on every load rather than
remembering.

**Why the browser may pin a fleet key here.** The rule above is about a
connection to a *peer*, and the danger it guards against is an intermediary or
an unknown party choosing the anchor every machine cert hangs from. On loopback
there is no such party: the socket goes to one process on this computer, the one
that owns the key and served the page. The pin *is* replaced here, unlike
above, and for a reason that is the mirror of the one given there — the cert
arrives with the key, from the process that minted both, so the pair is coherent
on arrival; and a loopback tab has no ceremony to be sent back to.

**The directory route is transport and not trust.** It exists because the relay
answers `GET /directory` without an `Access-Control-Allow-Origin` header, so a
loopback tab's cross-origin fetch is discarded before it can be read — and
`readDirectory` reports every fault as "no machines", so the tab showed a fleet
of one. The daemon forwards the bytes unchanged and the browser verifies every
blob under the pinned fleet key exactly as it does when it reads the relay
directly. A directory the daemon had "checked" would be one the browser could be
tempted to trust on the daemon's say-so, which is the property the fleet key
exists to keep out of every intermediary's reach.

## The fleet directory

Auto-pair needs one piece of distribution: a device paired on machine A must
Expand Down
52 changes: 52 additions & 0 deletions web/src/components/cloudflare-connect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ describe('CloudflareConnectCard', () => {
expect(screen.getByLabelText(/API token/)).toBeTruthy()
})

it('tells the tab that ran the deploy to reload, and offers the reload', async () => {
// The trap this closes. Three facts about this page were settled when the
// daemon served it and cannot be revised from here: its connect policy
// names no relay (LocalCSPFor), its fleet was told the relay leg was off
// and nothing broadcasts a change, and its enrolment was answered 409 by a
// machine that then had no fleet key. One reload fixes all three; without
// being told, the reader is left on a screen that says a relay exists and
// a sessions list that will never show another machine.
stubFetch([{ steps: ['worker deployed: flue-relay'], origin: 'https://r.example' }])
render(<CloudflareConnectCard info={INFO} setupCommand="flue relay setup" />)

await typeTokenAndDeploy('cf-token-4')

expect(await screen.findByText(/loaded before the relay existed/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reload flue' })).toBeTruthy()
})

it('shows the reason instead of the form when the daemon cannot deploy', () => {
render(
<CloudflareConnectCard
Expand Down Expand Up @@ -253,6 +270,41 @@ describe('the card after an update lands', () => {
expect(screen.getByText('worker deployed: flue-relay')).toBeTruthy()
expect(screen.getByRole('dialog')).toBeTruthy()
})

it('does not ask for a reload, because this tab was served with the relay', async () => {
// The other side of the reload notice. A tab looking at the configured
// card was served by a daemon that already had relay.json, so its connect
// policy names the relay and its fleet learned the origin from the first
// welcome. Telling this reader to reload would be asking for something
// that buys them nothing.
stubFetch([
{
configured: true,
can_deploy: true,
version: '0.3.0',
deployed_version: '0.2.0',
worker: 'flue-relay',
has_token: true,
},
{ steps: ['worker deployed: flue-relay'] },
{
configured: true,
can_deploy: true,
version: '0.3.0',
deployed_version: '0.3.0',
worker: 'flue-relay',
has_token: true,
},
])
render(<LiveConfiguredCard />)

const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Update relay…' }))
await user.click(screen.getByRole('button', { name: 'Update relay' }))

expect(await screen.findByText('worker deployed: flue-relay')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Reload flue' })).toBeNull()
})
})

describe('RelayDisconnect', () => {
Expand Down
55 changes: 55 additions & 0 deletions web/src/components/cloudflare-connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ function DeployFlow({
storedToken,
accountName,
onDone,
reloadAfter,
}: {
endpoint: string
/** The verb on the button: "Deploy" or "Update relay". */
Expand All @@ -240,6 +241,11 @@ function DeployFlow({
accountName?: string
/** Called once the deploy has landed, for a caller with something to close. */
onDone?: () => void
/**
* Whether the tab that ran this deploy has to be reloaded before it can use
* the relay it just created. True for the setup flow; see ReloadAfterDeploy.
*/
reloadAfter?: boolean
}) {
const [phase, setPhase] = useState<Phase>('form')
const [token, setToken] = useState('')
Expand Down Expand Up @@ -306,6 +312,7 @@ function DeployFlow({
<Copyable text={result.join_command} />
</div>
)}
{reloadAfter && <ReloadAfterDeploy />}
</div>
)
}
Expand Down Expand Up @@ -398,6 +405,49 @@ function DeployFlow({
)
}

/**
* The one thing a successful first deploy cannot do for the tab that ran it.
*
* This page was served before the relay existed, and three separate facts about
* it were settled at that moment and cannot be revised from here:
*
* - **The Content-Security-Policy.** The daemon builds it from relay.json
* when it serves the document (internal/daemon/server.go, `LocalCSPFor`),
* so a page served without a relay carries a policy naming none — and the
* browser will block `wss://<relay>/client/<id>` and the directory read
* whatever the app tries. A header cannot be changed after the fact, by
* anything.
* - **The relay origin.** A loopback tab learns it from the welcome, and
* `relayInfo()` reports nothing while the leg is off. Nothing broadcasts a
* change of relay status, so the tab's fleet would go on believing the last
* thing it was told for as long as it stayed open.
* - **This browser's fleet identity.** It enrols once per load
* (fleet/enrol.ts), and a tab loaded before the relay was answered 409:
* this machine held no fleet key to certify anything with. It does now.
*
* So the honest thing is to say so and offer the one act that fixes all three
* at once. It is a button rather than an automatic reload because the steps
* above it are the reader's only account of what was just done to their
* Cloudflare account, and a page that navigated away from its own receipt is
* the thing every other flow on this card is careful not to do.
*/
function ReloadAfterDeploy() {
return (
<div className="flex flex-col gap-y-1.5">
<p className={NOTE}>
This page was loaded before the relay existed, so it cannot use it yet — what a page may
connect to is fixed when it is served. Reload to pick up the relay: the sessions on every
machine you join will be on this browser’s own list.
</p>
<div>
<Button size="sm" onClick={() => location.reload()}>
Reload flue
</Button>
</div>
</div>
)
}

/**
* The integration card for a daemon with no relay: what a deploy will create,
* spelled out before any credential is asked for, then the form. The CLI
Expand Down Expand Up @@ -439,6 +489,11 @@ export function CloudflareConnectCard({
defaultWorker={info?.worker || 'flue-relay'}
storedToken={info?.has_token}
accountName={info?.account_name}
// This card is the one that renders on a tab with no relay behind
// it, which is exactly the tab that has to be reloaded before it
// can use the one it just deployed. The update flow does not: that
// tab was served with the relay already named.
reloadAfter
/>
)}
<div className="flex flex-col gap-y-1.5">
Expand Down
32 changes: 20 additions & 12 deletions web/src/crypto/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,22 @@ export async function loadPinnedDaemonKeyFor(
* overwrite would be stranded on a key nothing signs under any more, with
* clearing site data as the only way back.
*
* Which puts the weight on the caller, and the two callers carry it
* differently. The ceremony writes what the QR said, unconditionally. The other
* — fleet/fleet.ts's adoptFleetKey — writes only into an empty record, and only
* a key that reached this browser over a Noise session keyed to a daemon static
* key it pinned at a ceremony of its own. Neither is trust-on-first-use: the
* first learned the key out of band, and the second learned it from a party it
* had already authenticated out of band. What would be is a key taken off a
* connection to a peer this browser never pinned — whoever supplied it could
* then mint a machine certificate for every machine this browser will ever
* dial — and there is no path here that does that.
* Which puts the weight on the caller, and the three callers carry it
* differently. The ceremony writes what the QR said, unconditionally.
* fleet/fleet.ts's adoptFleetKey writes only into an empty record, and only a
* key that reached this browser over a Noise session keyed to a daemon static
* key it pinned at a ceremony of its own. fleet/enrol.ts writes what this
* machine's own daemon answered on loopback, overwriting if it differs, because
* there the answer comes from the process that owns the key over a socket with
* no room in it for anybody else — and because a loopback tab has no ceremony
* to be sent back to.
*
* None of the three is trust-on-first-use: the first learned the key out of
* band, the second from a party it had already authenticated out of band, the
* third from the machine the page itself came from. What would be is a key
* taken off a connection to a peer this browser never pinned — whoever supplied
* it could then mint a machine certificate for every machine this browser will
* ever dial — and there is no path here that does that.
*/
export async function savePinnedFleetKey(
publicKey: Uint8Array,
Expand Down Expand Up @@ -310,8 +316,10 @@ export async function loadPinnedFleetKey(
* is worth knowing before relying on it: a loopback connection authenticates a
* machine-local session token rather than a device key, so the daemon does not
* know whose certificate to send and sends none (internal/daemon/server.go,
* fleetCertFor). A browser paired only over loopback holds what the ceremony
* wrote here and has no second source for it.
* fleetCertFor). What a loopback tab has instead is the third writer,
* fleet/enrol.ts: it names its device key over `POST /api/fleet/enrol` and the
* daemon signs one for it, which is how a browser that never ran a ceremony
* comes to hold a certificate at all.
*
* It is public data either way: a certificate is a signed statement about a
* public key, and holding one grants nothing without the private half of the
Expand Down
Loading