What happens
A Devices screen can list several rows that all read Browser on karn.local, with nothing to tell them apart but a paired-at and a last-seen date. Four rows, three of them identical, on a screen whose only action is Revoke.
Why there are several
Not duplication, and not a bug in enrolment. A browser's identity is the X25519 static key in IndexedDB (web/src/crypto/keys.ts:134, record device-static), and IndexedDB is scoped to the origin. So the same browser reaching the same daemon from a different origin is a different device to that daemon, and self-enrols again. web/src/crypto/keys.ts says as much at the top: "'Every origin' is two" — the daemon's own origin and the relay's.
In a development loop it is more than two:
http://127.0.0.1:7719 — the daemon direct
https://<relay>.workers.dev — the relay
http://127.0.0.1:5173 — the vite dev server under make web-dev
http://localhost:7719 — a different origin from 127.0.0.1, same daemon
That part is the trust model working as designed and should not change.
Why they look the same
enrolLabel (internal/daemon/fleet.go:266) returns "Browser on " + hostname. Naming the machine is deliberate and the comment above it is worth keeping: the row also appears on other machines' Devices screens, because a browser reaching machine B makes B write a row of its own from the certificate, and any label saying "this machine" would be read on B as a claim about B.
So the label is right and simply does not carry the thing that differs. crypto.Device (internal/crypto/devices.go:19) holds ID, Label, PublicKey, PairedAt, LastSeen and Cert — nothing about where the device connects from.
What to do
Record the origin at enrolment and show it on the row. The daemon has the Origin header on the enrolment POST, so nothing new has to be asked for or trusted.
Points to settle while building it:
- Origin is a hint, not an identity. It is whatever the browser sent, it is only known for devices that enrolled over loopback, and a device paired by QR has no meaningful one. The row should treat a missing origin as ordinary rather than as suspicious.
- Does it belong in the certificate, or only in the local row? The label travels the fleet inside the certificate; an origin is a fact about how this machine was reached and probably should not. If it stays local, a row on machine B still cannot be told apart, which may be acceptable.
- A first-seen origin, or all of them? One device key can legitimately reach a daemon over loopback and later over the relay.
- Older rows have no origin. They should stay readable and revocable rather than being singled out.
- Secondary, and possibly the better half of the fix: make the row easier to place regardless — a short key fingerprint, a truer last-seen, and whether the device is connected right now. "Which one is the tab I have open" is the real question at the moment of revoking, and last-seen answers it badly.
Where to look
internal/daemon/fleet.go — enrolRequest, enrolLabel, the enrolment handler
internal/crypto/devices.go — the stored record
internal/wire/control.go:492 — what the device list puts on the wire
- the Devices screen in
web/src/routes/
What happens
A Devices screen can list several rows that all read
Browser on karn.local, with nothing to tell them apart but a paired-at and a last-seen date. Four rows, three of them identical, on a screen whose only action is Revoke.Why there are several
Not duplication, and not a bug in enrolment. A browser's identity is the X25519 static key in IndexedDB (
web/src/crypto/keys.ts:134, recorddevice-static), and IndexedDB is scoped to the origin. So the same browser reaching the same daemon from a different origin is a different device to that daemon, and self-enrols again.web/src/crypto/keys.tssays as much at the top: "'Every origin' is two" — the daemon's own origin and the relay's.In a development loop it is more than two:
http://127.0.0.1:7719— the daemon directhttps://<relay>.workers.dev— the relayhttp://127.0.0.1:5173— the vite dev server undermake web-devhttp://localhost:7719— a different origin from127.0.0.1, same daemonThat part is the trust model working as designed and should not change.
Why they look the same
enrolLabel(internal/daemon/fleet.go:266) returns"Browser on " + hostname. Naming the machine is deliberate and the comment above it is worth keeping: the row also appears on other machines' Devices screens, because a browser reaching machine B makes B write a row of its own from the certificate, and any label saying "this machine" would be read on B as a claim about B.So the label is right and simply does not carry the thing that differs.
crypto.Device(internal/crypto/devices.go:19) holds ID, Label, PublicKey, PairedAt, LastSeen and Cert — nothing about where the device connects from.What to do
Record the origin at enrolment and show it on the row. The daemon has the
Originheader on the enrolment POST, so nothing new has to be asked for or trusted.Points to settle while building it:
Where to look
internal/daemon/fleet.go—enrolRequest,enrolLabel, the enrolment handlerinternal/crypto/devices.go— the stored recordinternal/wire/control.go:492— what the device list puts on the wireweb/src/routes/