docs GitHub

Architecture

ADR 0045: Wink, scan-to-pair tickets

Status: accepted, 28 Sep 2026 · Workstream: tailnet · Amends ADR 0026 section 6 (pairing). Renumbered from this workstream's own "ADR 0037" (team/ADR-NUMBERS.md). The record encryption below is the lead's ruling of 28 Sep on the reviewer's LOW about the identity fingerprint.

Context

ADR 0026's pairing QR carries a whole offer: the relay, the route id, the box's public key, a 16-byte secret and the box's name. The Vyre code (ADR 0043) is a drawn avatar with room for far fewer bits, so it cannot carry an offer. It can carry a short ticket that points at one.

The relay is the obvious place to keep the offer for the few minutes a ticket lives, but the relay is also the party ADR 0026 trusts least. Whatever the relay holds, it must not be able to redeem the pairing, substitute a different box, or learn who the offer names.

Decision

The ticket. relay.pair.ticket (HUMAN_ONLY, presence on a box, refused on a Mac until vyre-core, ADR 0040) makes 8 random bytes, live for 5 minutes, single use. The Vyre code carries those 8 bytes and nothing else. Any number of tickets may be live at once, unlike the single live QR of relay.pair.start.

Four derived values. Every value the box or phone uses is sha256(tag + "\n" + ticket) under its own tag (core/relay/wire.js ticketDerive, mirrored byte for byte in relay/client/client.js):

TagValueWho sees it
vyre-pair-locthe locator the record is stored underthe relay
vyre-pair-secthe pairing secret, checked by the box's ordinary admissionthe box, inside the Noise channel
vyre-pair-macthe HMAC-SHA256 key over the stored recordthe box and the phone
vyre-pair-encthe AES-256-GCM key that seals the recordthe box and the phone

The sealed record. The box builds { v: 1, name, handle, identity, relay, route, box, exp }, seals the whole JSON with AES-256-GCM under the enc key (ticketSeal: a random 12-byte nonce in front of the ciphertext, so nonce safety never rests on tickets being unique across every box; AD "vyre-pair-record\n1"), MACs the sealed text with the mac key, and registers { loc, record, mac, exp } over its authenticated control socket. identity is the owner's fingerprint, sha256("vyre:person:v1:" + owner.id)[0:8] from the shared lib/identity.js (fingerprint8, the same helper system.info uses), or null on a box without an owner.id.

The relay. Both relays (relay/node/server.js and the Cloudflare worker's PairTicket Durable Object) store { record, mac, exp } by locator, refuse a record that is not opaque base64url (a plaintext JSON record never lands), and answer POST /v1/pair { loc } once: 200 { record, mac }, then the entry is gone. 404 covers unknown, expired and used alike. 429 is the per-IP and global rate limit. The locator travels in a POST body, never a URL, so it never reaches an access log.

Any origin may ask. The phone's lookup is a plain cross-origin fetch from whatever page scanned the code: phone.vyre.run, an owner's <handle>.vyre.run, or a self-hosted box's own address. So /v1/pair, and only /v1/pair, answers Access-Control-Allow-Origin: * on every answer (errors included, so the phone can read ticket_gone or a 429) and answers the OPTIONS preflight for POST with Content-Type. It sends no Access-Control-Allow-Credentials and reads no cookies. * is right here, not an origin list: the endpoint's safety comes from the ticket (an opaque locator, a sealed and MAC'd record, single use), never from who is calling, and any origin that holds a live ticket already holds everything the lookup could reveal. Every other relay route stays without CORS headers.

The phone. resolveTicket() derives the four values, resolves the locator, checks the MAC before anything else, opens the record with the enc key, refuses a record whose own exp has passed, sanitises the name and handle, and returns the offer with the box's key fingerprint for a "Pair with alex's box (a1b2 c3d4)?" screen. pairOffer() then runs ADR 0026's ordinary handshake with the ticket-derived secret. Errors carry stable codes: ticket_gone, rate_limited, bad_record, pair_failed, bad_input.

What the relay can and cannot do

  • Redeem the pairing: no. It never learns the secret.
  • Substitute a box: no. It never learns the MAC key, and GCM's tag would fail too.
  • Read who the record names: not casually. It holds ciphertext only and sees a locator, a size and two times (register, resolve), the same metadata class as ADR 0026's relay row. The seal's real strength against a determined operator is 64 bits, the ticket's own size: the relay holds the locator, sha256(tag || ticket), so it can search all 2^64 tickets offline with no time limit and then open the record. That is out of reach in the 5 minutes that matter for the pairing (which the MAC and single use protect anyway), and expensive but not impossible for reading one record later. The ciphertext's length also shows the name and handle's length.
  • Link pairings to one owner over time: no longer through the record. The identity fingerprint and handle are inside the ciphertext (ADR 0026's threat row, now mitigated).
  • Drop or delay a ticket: yes. Availability stays the relay's, as everywhere in ADR 0026.
  • Be flooded with lookups: on the self-hosted relay, no; it caps them per IP and globally. On the hosted Cloudflare relay, the rate-limit binding was deployed but never fired in the 28 Sep smoke test (bursts of up to 90 from one IP, Workers Free). Guessing a live locator is still out of reach, so this costs only hygiene. It's in the 0.1.2 backlog: investigate, or add a zone rule with the user's OK.

What the MAC and the seal do not prove is that the box is the person's own: someone else's code, scanned by mistake, still opens and checks out. The confirm screen, with the box's name and key fingerprint, is what catches that, which is why resolveTicket() and pairOffer() are separate calls.

Consequences

  • The Vyre code stays small enough to draw: 8 bytes, not an offer.
  • The relay is a dumb, short-lived mailbox for ciphertext. A self-hosted relay needs no key and no change of trust to support Wink.
  • A box and phone must agree on the tags, nonce and AD byte for byte. Tests pin this in both directions (Node box, WebCrypto phone).
  • A ticket is 64 bits live for 5 minutes behind a per-IP and global rate limit, so guessing a live locator is out of reach; the rate limit is hygiene, not the defence.