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):
| Tag | Value | Who sees it |
|---|---|---|
vyre-pair-loc | the locator the record is stored under | the relay |
vyre-pair-sec | the pairing secret, checked by the box's ordinary admission | the box, inside the Noise channel |
vyre-pair-mac | the HMAC-SHA256 key over the stored record | the box and the phone |
vyre-pair-enc | the AES-256-GCM key that seals the record | the 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.