Skip to content

feat(runtime): device-key authentication and per-installation key migration (host) - #27493

Merged
OrcaWin merged 5 commits into
mainfrom
np/d2-device-key-host
Oct 11, 2026
Merged

OrcaWin merged 5 commits into
mainfrom
np/d2-device-key-host

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​800 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​800
Prod 27 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​822 $\color{#cf222e}{\Huge{\mathbf{−}}}$​224 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​598

ELI5

A paired phone or computer can now sign in to this desktop with its own private key, instead of a reusable pairing code. A device that already has a code swaps it for a key on its own, the first time an updated version connects. Other devices that share the same code are not affected. Nothing is cut off, and nobody has to re-pair. This PR is the desktop half; the phone, CLI and web halves come next.

What Changed

Before: the only way to authenticate a paired socket was to send the device token inside the encrypted channel. The token was also the stored credential, so anyone who captured it could connect as that device forever.

After (host only; no client sends the new shapes yet):

  • New sign-in shape, accepted only inside an E2EE v2 session. The shape is {type:'e2ee_auth', v:2, transcriptHashB64, deviceId, deviceProofB64, clientCapabilities?}.
    • The proof is an Ed25519 signature over "orca-device-proof/v1\0" + purpose + "\0" + transcriptHash, where transcriptHash is this connection's v2 transcript. The transcript contains a fresh host nonce, so a captured proof cannot be replayed on another connection, and no replay store is needed.
    • classifyE2EEAuth is now the single parser. Mixed shapes are rejected, and key auth over legacy framing is bad_auth.
  • Keyed device rows in orca-devices.json carry credentialKind:'device-key', devicePublicKeyB64 and forkedFrom, and have no token.
    • They are never offered for pairing and never rotated away.
    • A token can never match them.
    • An older build keeps them in the file but can do nothing with them: there is no token to match.
  • pairing.registerDeviceKey uses the new device-self permission. Every paired caller holds it, and it is on the phone allowlist.
    • The call is bound to the calling socket. Its proof uses purpose register and must be over that socket's own v2 transcript.
    • It forks the caller's token row into a new keyed row. Scope, grants, reach and the orchestration caller fingerprint are copied; authority never widens.
    • The token row stays in place, so other installations sharing the token, and their live sockets, keep working.
    • It is idempotent per (token row, key), so a lost reply is safe. Each installation's key gets its own row.
    • The registry write uses the durable (fsync'd) writer.
  • Sealed failure verdicts. Each is sent inside the encrypted channel, then the socket closes with 4001.
    • device_revoked {deviceId}: the id is unknown and the registry is readable. This is the only verdict that will let a client delete its key.
    • registry_unavailable: the registry could not be read; the client keeps its key and retries.
    • bad_proof: the signature did not verify.
    • Token auth keeps unauthorized exactly as today.
  • A key is never called revoked unless that is proven.
    • A registry file that fails to parse is moved aside (orca-devices.json.corrupt-<ts>) before the first rewrite.
    • While any such copy exists, an unlisted key gets registry_unavailable, never device_revoked. That is fail-safe: a really revoked device keeps retrying until the owner deletes the copy.
    • A missing file still counts as "empty and trustworthy".
  • Keyed sockets dispatch RPCs without a token. Any request token sent on a keyed socket is a mismatch.
  • A fork keeps its token row's Relay binding. A Relay socket bound to the token row may present that row's forked key; the proof still names the installation. A Relay socket bound to any other device is refused with unauthorized, never a revocation verdict.
  • Capability pairing.device-key.v1 is advertised. Clients migrate or present a key only when they see it.
  • Smaller changes:
    • DeviceEntry.token is optional, and TokenDeviceEntry types the rows that do have a token (offers, delegated children).
    • Keyed principals use the device id as their per-client key.
    • The registry's repeated "find row, replace, save" code moved into replaceRow. Row types moved to device-entry.ts, and legacy reply senders moved out of e2ee-channel.ts; both moves are behavior-preserving and keep the files under the size cap.

Why

The design splits the pairing token's four jobs (secret, credential, identity, authority). The device-principal groundwork (#27480) separated identity. This PR adds a credential that is not a bearer.

Fork instead of replace: one token is routinely shared by the QR, several browser tabs, or ORCA_PAIRING_CODE used on several machines. Replacing the row's credential would lock out every other holder.

Signing the existing transcript: this reuses the v2 handshake's fresh nonce, instead of adding a challenge round trip or a persisted replay store.

Sealed verdicts: a client must never delete its key because of storage trouble or an older host. Only an authenticated "this device id is gone, and I can read my registry" justifies deletion.

Linked Issue

Part of #17394 and #17398 (host side; closes with the client PRs)

Visual Proof

N/A: no UI change.

Testing

  • src/main/runtime/rpc/device-key-auth.test.ts runs end to end through MobileSocketWiring with real v2 crypto:

    • a token socket registers a key; a repeat returns the same row;
    • the keyed socket authenticates with the device id as its client id and the carried fingerprint;
    • another holder of the same token still connects and forks its own row;
    • a wrong-purpose proof, a proof replayed from another connection, and a mixed shape are all refused;
    • device_revoked is sent only with a readable registry, otherwise registry_unavailable;
    • registration over legacy framing is refused;
    • keyed rows are never pending or rotated, and are inert to token lookups.
  • device-key-fork.test.ts:

    • authority is copied and never widened; repeats are idempotent;
    • refusals: already keyed, delegated, unknown, key in use;
    • no commit on an unreadable registry;
    • a corrupt registry is set aside on the first save, and the next launch stays doubtful even if the rewrite never landed.
  • runtime-rpc-device-key-dispatch.test.ts: a keyed socket dispatches with its device id as client id, and is refused if it carries a token or once its row is revoked.

  • src/shared/device-key-proof.test.ts (domain separation, canonical base64, label sanitizing) and mobile-e2ee-auth-classification.test.ts (every shape, mixtures).

  • Cross-version (tests/e2e/cross-version-wire/cross-version-device-key-registry.unit.test.ts, released baseline):

    • the released registry keeps keyed rows intact and inert, and still authenticates token rows;
    • the released host refuses key auth with bad_auth (so a keyed client can show "host downgraded" and keep its key);
    • old registry files load unchanged.
  • pnpm tc, pnpm test src/main/runtime src/shared, oxlint, check:code-quality:changed, pnpm lint.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Mixed versions:
    • Old clients see no change; token auth is untouched.
    • An updated client checks for the capability before migrating, and an old host answers key auth with bad_auth.
    • The registry gains only optional top-level fields, with the grant vocabulary unchanged. A downgraded build keeps them and re-upgrading restores access.
  • Legacy retirement is not in this PR: token rows keep working. A captured legacy session stays replayable while its token row exists, and the retirement switch comes later.
  • SSH / Relay: a Relay socket's key auth obeys the same "relay device id must match" rule as token auth. Nothing is platform-specific. On Windows, the ACL-hardened durable write happens only on registration, never on a key-auth connect.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

Walkthrough

The change adds device-key registration and transcript-bound authentication for mobile devices. The registry supports device-key rows forked from token-backed rows and preserves registry state when reads fail or data is corrupt. RPC dispatch exposes registration and supports authenticated keyed sockets without bearer tokens. Token authentication remains supported, and tests cover registry behavior, authentication, dispatch, and cross-version compatibility.


Priority: ➖ Normal

Merge Risk: 🔵 Low · up to a2bf1

The change looks mergeable. The two device-key tests should be tightened so they would catch regressions in keyed dispatch and in the identity assigned at registration.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 36 functions across 34 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the main change: host-side device-key authentication and migration for runtime installations.
Description check Passed The description includes all required sections and provides clear ELI5, implementation details, rationale, issue references, testing coverage, compatibility notes, and checklist status. It notes that …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@OrcaWin
OrcaWin force-pushed the np/d2-device-key-host branch from 33768a9 to a2bf119 Compare October 11, 2026 14:36
@OrcaWin

OrcaWin commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review (P0/P1 only): the codex reviewer was unavailable (orchestration consumer_fenced; retried twice), so an Opus security reviewer ran three rounds.

  • Round 1: P0 — a corrupt (unparseable) registry made the host answer device_revoked to every keyed client; P1 — keyed sockets were refused on every request (token still required at dispatch); P1 — a Relay-only phone would be locked out after migrating (fork had a new id, Relay binding is on the token row). All fixed with tests.
  • Round 2: P1 — a corrupt file could still be overwritten by the next save, so the following launch answered device_revoked. Fixed: the corrupt file is moved aside before the first rewrite and keeps verdicts doubtful.
  • Round 3: P1 — after a failed rewrite following that move, the missing-file branch skipped the doubt check. Fixed.
  • Final round: No P0/P1 findings. Own review added: not-keyed rows never produce device_revoked, Relay binding mismatch is 'unauthorized' (key kept), forks capped at 32 per token row.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8c97283d-5f00-46f4-89b6-d3c9ffe55ffc
📥 Commits

Reviewing files that changed from the base of the PR and between 33768a9 and a2bf119.

⛔ Files ignored due to path filters (1)
  • src/shared/rpc-contract/rpc-params-catalog.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (1)
  • src/main/runtime/runtime-rpc-device-key-dispatch.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

const { server, keyed, send } = keyedServer()
const dispatch = vi.spyOn(server['dispatcher'], 'dispatchStreaming')
const reply = await send({ id: 'r1', method: 'status.get' })
expect(reply).not.toMatchObject({ error: { code: 'unauthorized' } })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that tokenless dispatch succeeds.

A reply with any error code other than unauthorized passes this assertion. The dispatch spy also records failed calls. Assert ok: true so this test fails when status.get cannot complete on a keyed socket.

Comment on lines +130 to +134
const reply: unknown = JSON.parse(replies.at(-1)!)
expect(reply).toMatchObject({ id: 'register', ok: true, result: { credentialRevision: 0 } })
expect(
registry.listDevices().filter((row) => row.forkedFrom === legacy.deviceId)
).toHaveLength(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the registered device ID and public key.

This test passes if registration returns a different deviceId or stores a different public key. It checks only the revision and the number of forks. Compare reply.result.deviceId with the forked row’s deviceId, and compare that row’s devicePublicKeyB64 with key.publicKey. Based on learnings: identity-sensitive tests must assert the fields that determine the resulting identity, not only adjacent metadata.

Source: Learnings

@OrcaWin
OrcaWin merged commit 6ffbdcd into main Oct 11, 2026
31 checks passed
LINDECKER-Charles added a commit to LINDECKER-Charles/orca that referenced this pull request Oct 12, 2026
Upstream now routes project group catalog calls by their owner host
(stablyai#27288): createProjectGroup takes the seeding project's host, and rows
are adopted through adoptFromEndpoint. Subgroup creation moves to its own
createProjectSubgroup action, routed by the parent group's owner like
moveProjectGroup, and the update/move helper uses the new owner routing.

protocol-version.ts re-exports the attention capabilities with a star,
as stablyai#27493 did for the resume ones, to stay within the line limit.
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