Skip to content

Lock a box to the devices paired with it (protocol v29) - #468

Merged
pallaoro merged 5 commits into
mainfrom
box-pairing
Nov 3, 2026
Merged

pallaoro merged 5 commits into
mainfrom
box-pairing

Conversation

@pallaoro

@pallaoro pallaoro commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Why

A box's WebSocket, the port the phone and Tailscale-only Macs connect to, has no login of its own. A tailnet's default policy lets every device reach every other, so anything on the tailnet that reaches that port gets the box: terminals, agents' output, its GitHub token. Today a connection receives every event, terminal output included, the moment it opens, before it makes a single call.

What

A box can serve that port only to the devices paired with it.

  • Gate: an unpaired connection to a locked box gets no events and every call except system:hello and pairing:redeem is refused with "This box needs pairing." SSH (the attach relay over the unix socket) is never gated.
  • Pairing: a device trades a one-time code (8 characters, 10 minutes, once) for its own token, sent with every hello after that. Codes come only from a locked box: on an open one a code would unlock nothing, and a device paired before the lock would keep its way in. They come from:
    • Settings → Boxes → Access → Pair a phone on a paired Mac: a QR the iPhone's Camera opens as ateamgo://pair?..., plus the code to type;
    • the desktop over SSH;
    • ateam pair in the box's terminal.
  • Store: ~/.ateam/pairings.json on the box (0600), with only hashes of tokens and codes, read afresh on every use so the CLI and the daemon agree.
  • Revoking: unpairing a device drops its live connections at once.
  • Brute force: five wrong codes drop a connection, and many across connections pause codes for a while.
  • Defaults:
    • Every box starts open, a new one too, until it's locked with Lock it, on the Mac or the phone, which pairs that device first. Its card says anyone on the tailnet can control it. Ateam Go on the App Store (1.3.x) can't pair, so a box that came up locked would shut that phone out. LOCK_NEW_BOXES in cli.ts makes a fresh box come up locked once an Ateam Go that pairs is live there; an upgraded box stays as it was either way.
    • install.sh reports the box as locked, or as open with where to lock it.
    • ateam pair prints a code for a locked box, and on an open one says to lock it first. ateam pair --unlock reopens a box when no paired device is left.
  • Mac: the token is kept in the encrypted provider-secrets store and sent in the handshake. A locked box it can't open shows a code field and stops retrying. Settings → Boxes gets an Access section:
    • the open-box warning and Lock it;
    • the paired devices, with Unpair;
    • Pair a phone, which refreshes the list while the code is on screen.
  • Phone: the token is kept in the Keychain. A locked box's card offers a code field, and an open box's card offers Lock it, which asks first and says a Mac reaching the box over Tailscale will need a code from ateam pair. ateamgo://pair links pair and save the box, after asking.

Protocol v29: system:hello takes { token } and answers pairing, both optional, so an older engine reads as an open box. pairing:* are new methods (FEATURE_MIN_VERSION.pairing).

Checked

  • Tests:
    • 13 new server tests: the store, and the gate on one connection (open, locked, paired, trusted, no codes on an open box, lock dropping open connections, revoke dropping a device, the rate limits).
    • Phone tests: a locked box isn't redialled, and pair links are checked.
    • The full server (792), desktop (206), protocol (104) and phone (83) suites all pass.
  • End to end: a throwaway local engine with the gate stood in for a box, an isolated dev app played the Mac, and a script played the phone.
    • Open box: works as before.
    • Lock it: pairs the Mac first, and the box then lists it.
    • Unpaired phone: gets "needs pairing", no events, and refused calls.
    • Pair a phone: shows a QR and a code. The phone redeems the code, typed with its dash, and connects as paired. The code is refused a second time, and the Mac's list updates by itself.
    • Unpair: a connected phone is dropped at once, and its token gets "needs pairing".
    • Box restart: the Mac reconnects with its token in 9 s.
    • Mac revoked: the Mac stops retrying and shows the code field. A code from the real ateam pair CLI pairs it again.
    • ateam pair, the real CLI: on a fresh data folder it says the box is open and exits 1, writing nothing; on a locked one it prints a code. The installer's check reads both correctly.

Pairing links

Any app or web page can open an ateamgo:// link, and Apple's guidance for custom URL schemes is to validate every parameter and limit what a link can do on its own. So the phone drops a pair link with anything but a plain host, a port and an 8-character code, and asks "Pair with this box?", naming its address, before it does anything (apps/mobile/src/pair-link.ts, with tests).

Two things stay open by design:

  • Scheme collisions: another app could register the same scheme and receive the one-time code. The code works once, for 10 minutes, and an unexpected device shows in the Mac's list to unpair. The full fix is a universal link on a domain Ateam controls, with the code in the URL fragment. That needs a domain and the Associated Domains entitlement, so it's a separate decision.
  • The code format: 8 characters with a dash, confusable characters left out, uppercased and stripped before comparing. That matches RFC 8628 §5.1 and §6.1 and GitHub's device flow (15-minute codes, 50 submissions an hour).

Not run live: the phone's own screens (the code field, Lock it, the ateamgo://pair link and its prompt) in a simulator or on a device, and whether the iPhone's Camera opens an ateamgo:// QR at all. Apple documents universal links for QR codes, and the typed code is the fallback. These ship in the next TestFlight build.

A box's WebSocket, the port the phone and Tailscale-only Macs use, has no
login of its own, and a default tailnet lets every device reach every other.
A box can now serve that port only to devices paired with it:

- A connection that isn't paired gets no events (no terminal output, no
  board) and every call but hello and pairing:redeem is refused with
  "This box needs pairing." SSH is never gated.
- A device pairs by trading a one-time code (8 characters, 10 minutes, once)
  for a token of its own. Codes come from Settings → Boxes → Access → Pair a
  phone on a paired Mac (a QR the iPhone's Camera opens as ateamgo://pair),
  from the desktop over SSH, or from `ateam pair` in the box's terminal.
- The box keeps pairings in ~/.ateam/pairings.json (0600), token and code
  hashes only. Unpairing a device drops its live connections at once.
  Wrong codes drop a connection after five, and codes are refused for a
  while after many across connections.
- A new box comes up locked. A box that ran before stays open until it is
  locked from a paired device (Lock it, on the Mac or the phone), so an
  upgrade never locks a phone out; its card says anyone on the tailnet can
  control it.
- The Mac keeps its token in its encrypted store and sends it with every
  hello; a locked box it can't open shows a code field instead of retrying.
  The phone keeps its token in the Keychain, shows the same field on the
  box's card, offers Lock it on an open box, and opens ateamgo://pair links.

`ateam pair --unlock` opens a box again when no paired device is left.
Any app or web page can open an ateamgo:// link, and the phone used to pair
with, and save, whatever host a pair link named, at once. Now a link with
anything but a plain host, a port and an 8-character code is dropped, and a
good one asks first: "Pair with this box?", naming its address. Apple's
guidance for custom URL schemes: validate every parameter and limit what a
URL can do on its own.
- A box gives out pairing codes only once it is locked (or over SSH). On an
  open box a code adds nothing, and a device that paired before the owner
  locked it would have kept its way in.
- The phone keeps a locked box's refusal exact even when a wake failed too,
  so the card still offers its code field.
- Lock it on the phone asks first, and says the Mac needs a code too if it
  reaches the box over Tailscale, from `ateam pair` in the box's terminal,
  which the phone can open. The phone has no other way to hand out a code.
- The Mac's lock dialog says a phone needs Ateam Go 1.4 or later to pair.
Ateam Go on the App Store (1.3.x) has no pairing, so a box that came up locked
would shut that phone out with nothing in the app to let it in. A fresh box now
starts open, like one that has run before, with the warning on its card and
Lock it a click away. LOCK_NEW_BOXES in cli.ts turns the old behaviour back on
once an Ateam Go that pairs is live on the App Store.

- `ateam pair` on an open box says to lock it first instead of printing a code
  that neither app has a field for.
- The installer reports the box as locked, or open with where to lock it, in
  place of the line about pairing a phone.
- The docs say a box starts open, and that a phone needs Ateam Go 1.4 or later
  to use a locked one.
@pallaoro
pallaoro merged commit 52273f3 into main Nov 3, 2026
5 checks passed
@pallaoro pallaoro mentioned this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant