Skip to content

feat(pairing): single-use, hashed pairing tickets with owner approval hook and orca pairing commands - #27558

Draft
OrcaWin wants to merge 5 commits into
mainfrom
np/d2b-tickets
Draft

OrcaWin wants to merge 5 commits into
mainfrom
np/d2b-tickets

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1141 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1141
Prod 33 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1470 $\color{#cf222e}{\Huge{\mathbf{−}}}$​150 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1320

ELI5

Today a pairing code is a reusable password: whoever holds the QR or link can connect as that device for good. This PR adds pairing tickets instead. A ticket works once and expires soon. Redeeming one creates a new device that signs in with its own private key, not a shared code.

It also adds owner-only commands to manage tickets from a terminal:

  • orca pairing create makes a ticket;
  • orca pairing list shows the outstanding ones;
  • orca pairing revoke cancels one.

Phones and the QR dialog are unchanged in this PR: the QR still hands out today's code, so every existing phone keeps pairing and connecting as before.

What Changed

Before:

  • A pairing offer carried a long-lived device token, stored in plaintext in orca-devices.json.
  • Anyone who captured the QR or link could reuse it.
  • The owner was never shown which device was pairing.

After (host side):

  • Ticket book (src/main/runtime/pairing-ticket-book.ts, in memory only).
    • Each ticket is 256 random bits. The book keeps only sha256(ticket).
    • Each ticket is bound to a scope, grants and reach chosen by the host, plus a reserved device id.
    • Lifetimes run on the host clock:
      • tickets from the desktop UI last 10 min, and repeated requests return the same ticket until 1 min before it expires; a renewal cancels the old one;
      • CLI tickets last 30 min by default;
      • anything above 1 h is rejected with an error, never clamped.
    • A restart drops every unredeemed ticket.
    • Five bad proofs burn a ticket.
  • Redemption, inside an E2EE v2 session only. The client sends {ticket, deviceId, devicePublicKeyB64, deviceProofB64, deviceLabel}.
    • The proof is an Ed25519 signature for purpose redeem over this connection's v2 transcript, so a captured proof is useless on another connection.
    • The host checks the proof, then locks the ticket synchronously. A redemption racing it gets ticket_consumed.
    • The host writes one new keyed device row durably before it replies.
    • The device id must be the one the ticket reserved. A key already registered is refused with device_key_in_use.
    • Labels are cleaned of control and bidi characters.
    • An optional ephemeral: true (sent by session-only browsers) makes the new row session-only, so the host drops it on restart and after idle time.
    • Over legacy framing or a Relay socket, the redemption shape is refused with bad_auth.
  • Owner approval for tickets minted by the desktop UI.
    • The host extends the handshake to about 2 minutes and asks an approver. The approver sees the host's grants, the cleaned label, a key fingerprint and a 6-character comparison code.
    • The comparison code is base32(HKDF-SHA256(transcriptHash, "orca-pair-sas/v1"))[0..6]. The client derives the same code from the same transcript, so a man in the middle shows a different code.
    • If a device with the same name already exists, the owner must type the code. The host checks the typed code itself, and on approval the old device is revoked through the normal unpair path.
    • Deny, a timeout, the socket closing, or any unexpected error all burn the ticket.
    • While a ticket is still open or mid-redemption, key auth under its reserved id answers registry_unavailable, never device_revoked, so a retrying client keeps its pending key. Once the ticket is spent, an absent row is a real revocation. This PR adds the approval hook (setPairingTicketApprover). The desktop dialog that fills it comes next; until then, UI-minted tickets are denied and nothing mints them yet.
  • Headless tickets come from orca pairing create [--ttl 30m] [--json], orca pairing list and orca pairing revoke <id>.
    • They go through the owner's local RPC. The methods need pairing-admin (never granted to paired runtimes, and not on the phone allowlist), and they also need an owner-only context that only the local owner transport sets.
    • create prints only the verified https link (https://www.onorca.dev/pair#<code>) on stdout, with the secret in the fragment. A status line without the secret goes to stderr.
    • The link is never orca://…: any app can claim that scheme, and a headless ticket redeems without approval. Ticket input accepts only a bare code or the verified link.
    • Remote --environment and --pairing-code flags are refused.
  • New sealed refusal codes: ticket_invalid, ticket_consumed, approval_denied, device_key_in_use.
  • New runtime capability pairing.ticket.v1. The new offer format is a v3 code (src/shared/pairing-ticket-offer.ts), which today's v2 parser rejects, so an older app reports an invalid code rather than misreading it.
  • To fit the size cap, e2ee-channel.ts lost its hello parsing and legacy inbound handling to two helpers. Behaviour is unchanged and covered by the existing channel suites.

Why

The pairing token currently does four jobs at once: it is the pairing secret, the standing credential, the device's identity and its authority. Device keys (#27493) separated the credential. Tickets now separate the pairing secret.

  • Single use and hashed: a stolen QR or link becomes useless once used, and the host keeps no reusable bearer.
  • Comparison code: a thief who wins the race is shown to the owner and refused before any device exists.
  • No approval for headless tickets: they are minted by someone who already has a shell as the owner and are delivered out of band (owner decision). The 1 h cap bounds how long a leaked one stays stealable.
  • Alternatives considered:
    • Approving inside a paired RPC would let a paired client mint credentials.
    • Persisting tickets would leave secrets on disk across restarts.
    • Clamping --ttl would hide the limit from scripts.

Linked Issue

Part of #17397, #17398 and #17400 (host side). They close once a client redeems tickets and the desktop approval dialog ships.

Visual Proof

N/A for this PR: no UI yet. The approval dialog lands in the follow-up. It will show the host name, what the device will be allowed to do, the device's label and key fingerprint, and the 6-character code, with Allow and Deny. When the new device would replace a same-named one, it adds a field where the owner types the code.

Testing

  • pairing-ticket-book.test.ts, written first:

    • hashing, and no plaintext kept for CLI tickets;
    • the TTL cap is an error;
    • UI tickets coalesce, and a renewal invalidates the old ticket;
    • the redemption lock, the consumed tombstone, burn versus release;
    • the five-bad-proof limit, host-clock expiry, a lock outliving expiry during approval, and revoke.
  • rpc/pairing-ticket-redemption.test.ts: end to end through the real socket wiring with real v2 crypto.

    • Redeem once, then key auth with the reserved id (lost reply), then ticket_consumed.
    • A wrong-purpose proof, and burning after five failures.
    • A forged device id, and a key that is already in use.
    • Refusal over Relay and over legacy framing.
    • Approve and deny, checking that the code matches the client-side derivation.
    • No approver means denied, and a racing thief gets ticket_consumed while the owner decides.
    • A socket closing during approval burns the ticket, and the approval window times out.
    • Type-to-confirm when replacing a device, and headless tickets never prompt.
  • pairing-comparison-code.test.ts: a pinned vector that the host (node:crypto) and client (pure JS) both produce.

  • pairing-ticket-offer.test.ts: round trip, only the verified https link, a ticket inside an orca:// URL is refused, and the v2 parser rejects it.

  • rpc/pairing-ticket-methods.test.ts: the owner can mint, list and revoke, and the secret never appears in the list. Paired runtime, phone and delegated callers get forbidden even if an owner context leaked.

  • src/cli/index-pairing-commands.test.ts:

    • stdout carries only the link, and stderr never the secret;
    • --ttl 2h is refused before contacting the runtime;
    • remote flags are refused;
    • list and revoke work.
  • Cross-version (tests/e2e/cross-version-wire/cross-version-pairing-ticket.unit.test.ts, against the latest release):

    • only this host advertises pairing.ticket.v1;
    • a released host refuses a redemption without creating anything;
    • a released registry keeps a redeemed row intact and inert through its own writes;
    • a released client's token auth still works on a host that redeems tickets.
  • Existing suites: all of src/main/runtime/rpc, runtime-rpc, device-key, pairing, CLI, and the B3/B2 cross-version tests pass.

  • pnpm tc, oxlint, and pnpm run check:code-quality:changed (0 findings) pass.

  • pnpm lint passes except the layout-writer ratchet, which fails identically on the base branch for two untouched src/main/ipc/pty files.

  • Automated tests added/updated

Agent skill upstream boundary

  • Not applicable.

Notes

  • SSH and headless serve: orca pairing create runs on the host it pairs with (remote selection is refused). No desktop UI means no approver, which is why headless tickets don't need one.
  • Windows: durable registry writes happen only on redeem, revoke and replacement. Minting, listing and the approval wait write nothing.
  • Wire compatibility: the redemption shape is new and only valid inside v2, and clients send it only to hosts advertising pairing.ticket.v1. The v3 offer is rejected by older parsers. Redeemed rows are top-level optional fields that an older loader keeps but cannot use.
  • Not in this PR:
    • the desktop approval dialog and a UI mint path;
    • client redemption (web, then CLI and phones);
    • a ticket field in serve readiness;
    • flipping the QR dialog to tickets, which waits for the phone release.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • N/A for visuals, with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered

Handoff status (2026-10-11)

m4air added 5 commits October 11, 2026 15:17
…reate/list/revoke`

The host now mints opaque single-use tickets kept in memory as sha256 only.
A client redeems one inside an E2EE v2 session with a fresh Ed25519 key and a
proof over that session's transcript; the ticket is locked synchronously, the
keyed device row is written durably before the reply, and a second redemption
is refused as ticket_consumed. Tickets the desktop UI mints wait for the
owner's approval, which shows a comparison code both screens derive from the
transcript and requires typing it before a same-named device is replaced.
Headless tickets come from the new owner-only `orca pairing` commands: TTL
defaults to 30m and anything above 1h is rejected. The secret only appears in
the printed link's fragment. Hosts advertise pairing.ticket.v1; the QR dialog
still hands out the legacy offer, so existing phones are unaffected.
… never revokes its pending key

- `orca pairing create` prints the verified https link (secret in the
  fragment); any app can claim orca://, and a headless ticket needs no
  approval. Ticket input accepts only a bare code or that link.
- An unexpected throw after the lock burns the ticket instead of leaving it
  mid-redemption, and key auth for a still-reserved id answers
  registry_unavailable, never device_revoked.
- Replacement matching sanitizes the stored device name too.

This branch has not been deployed

No deployments
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