diff --git a/.agents/skills/dsh-ui/SKILL.md b/.agents/skills/dsh-ui/SKILL.md index 35a5e25a7e..c84dbb26a0 100644 --- a/.agents/skills/dsh-ui/SKILL.md +++ b/.agents/skills/dsh-ui/SKILL.md @@ -7,11 +7,13 @@ description: Use when building, styling, or reviewing in-harness UI — the BotH The in-harness UI is part of the shell, not a standalone app (ADR-0028): style with `--dsw-*` tokens, compose with `ui-primitives`, and copy the native layout contract instead of inventing spacing. **Measure the running shell; do not guess.** +Before designing or reviewing a changed surface, apply the shared [UI design standard](../../../docs/agents/ui-design.md) and its review gate: layout, icons and interaction carry the task; prose supplies only necessary context. + ## Build order 1. **Slot wiring** — register through the documented seams: `sidebar.footer.action` for the persistent entry; shadow `sidebar.workspaces` and `main key=conversation` only while a mode is active, reconciling from one store and disposing on exit. Desktop Bot mode also shadows the whole `sidebar` slot at `-1` to draw the Bot mode sidebar (ADR-0203); a shadow cannot re-render the native `sidebar.*` sub-slots, so it must provide its own way out of Bot mode. Keep one entry per concept. Done when the entry appears in the running shell and the shipped UI returns intact when the mode is off. 2. **Tokens** — every colour, font, and radius reads from `--dsw-*` (static/alias/specific layers); dark arrives through `body[data-ds-dark-theme]`. Brand accents stay inside the ≤3-entry alias block. Done when the `styles-tokens` guard passes and a dark pass shows no unreadable text. -3. **Design system** — build Bot-mode surfaces from `packages/client/src/client/ds/` (ADR-0194): `SettingsDialog`, `SettingsSection`, `SettingsRow`, `Select`, `NumberStepper`, `Note`, `EmptyState`, styled with StyleX and the tokens in `ds/tokens.stylex.ts`. Follow its content rules: one title, one line of description and one control per row; switches, choices and steppers apply on change; one text field saves on Enter or blur; at most one primary button; internal ids and revisions stay out of the default view. Visible text follows [UI copy](../../../docs/agents/ui-copy.md). Done when the `design-system-tokens` guard passes and no new `bh-` rule duplicates a component. +3. **Design system** — build Bot-mode surfaces from `packages/client/src/client/ds/` (ADR-0194): `SettingsDialog`, `SettingsSection`, `SettingsRow`, `Select`, `NumberStepper`, `Note`, `EmptyState`, styled with StyleX and the tokens in `ds/tokens.stylex.ts`. Follow its content rules: one title and one control per row, with an optional short description only when it adds a needed fact; switches, choices and steppers apply on change; one text field saves on Enter or blur; at most one primary button; internal ids and revisions stay out of the default view. Visible text follows [UI copy](../../../docs/agents/ui-copy.md). Done when the `design-system-tokens` guard passes and no new `bh-` rule duplicates a component. 4. **Composition** — for a net-new surface or control, pick the closest precedent in `references/design-references.md` before choosing layout; borrow composition, hierarchy, and states, not styling. Done when the PR names the borrowed pattern and every deliberate deviation. 5. **Layout** — apply the native inset contract in `references/native-contract.md`. Done when top-level content measures 16px from both sidebar edges and list-row backgrounds reach the scrollbar gutter. 6. **Primitives & icons** — use the exported set (`Input`, `Tag`, `Pill`, `StateDot`, `Tooltip`, …) and `Icon*Outline16` glyphs where geometry matches; keep a custom icon only where the set has no analogue. Done when controls sit on the shell's grid; record every deliberate omission (for example the 36px capsule `Button`) in the PR. diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 5de859cdbc..9bb5923dea 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -2,7 +2,9 @@ | Feature | Needs a model | Scripted journey | File | | --- | --- | --- | --- | +| Connect encrypted pairing, resources and native private input | yes for DM/tool/private request | `verify-app` isolated Host and shared native QA | [connect-channel.md](connect-channel.md) | | Browser Remote Host pairing, icon strip, Host Feed and Host-level settings | only for a reply | `verify-app` two-Host path | [remote-hosts.md](remote-hosts.md) | + | VRM companion Panel | yes for speaking | `scripts/e2e-vrm-companion.mjs` | [vrm-companion.md](vrm-companion.md) | | Desktop whole-window zoom (controlled S1 / Windows candidate) | no | `scripts/e2e-desktop-zoom-launch.mjs`, `scripts/e2e-desktop-zoom-windows.mjs`, `scripts/e2e-dock-zoom.mjs`, `scripts/e2e-workspace-chrome.mjs` | [desktop-window-zoom.md](desktop-window-zoom.md) | | Global search: Alt/Option+K, settings results and pinyin matching | no | `verify-app` Bot mode path | [global-search.md](global-search.md) | diff --git a/.agents/skills/verify-deepseekbot/features/connect-channel.md b/.agents/skills/verify-deepseekbot/features/connect-channel.md new file mode 100644 index 0000000000..af747180b4 --- /dev/null +++ b/.agents/skills/verify-deepseekbot/features/connect-channel.md @@ -0,0 +1,27 @@ +# Connect encrypted channel + +## Sub-features + +- Host fingerprint: Devices and `connect-status` expose one stable public identity across restart. +- Pairing: new phone/browser independently compares Host fingerprint; trusted Host approves the exact device key. No ordinary operation works while pending. +- Traffic: DM, image upload/download, avatar, approval, native private input, push registration and logical streams use the channel. +- Failure: changed/missing keys, replay, malformed frames, cancellation, revocation and reconnect fail closed without plaintext Connect fallback. +- Removal: full phone WebView remains absent; Connect Browser/Computer viewers remain unavailable. + +## How to get to it (user POV) + +Host Settings → Devices shows the Host fingerprint and pending device keys. Pair from the native phone or Add remote in another trusted Host's browser. Compare the complete Host fingerprint with the independent Host display, then compare and approve the device fingerprint on the Host. In chat, open a pending private-input request and choose Fill privately. Activity opens the same native form. + +## Driving it with verify-app + +Build and launch a named isolated Host with `verify-app`; run doctor with local network/process permissions. Use a second trusted browser Host for remote chat. Enable only a task-owned local connector or specifically authorized real tunnel. Qualify a real phone through the shared QA queue; a browser or Node carrier is not native proof. Use official DeepSeek credentials for a real DM and disposable private input. + +Capture HTTP headers/URLs and WebSocket frames at the Connect origin before channel decryption. Compare synthetic sentinels with those bytes; preserve only redacted pass/fail summaries for publication. Read the owning Host's canonical timeline, approval outcome and private-input status. Verify `device-approve-key --confirm` and `connect-status` against this live Host. Restart the same isolated Profile and verify a stable fingerprint; reconnect must use new ephemeral keys. Revoke the device and verify open streams close. + +## Gotchas + +- A Node socket mapped from a Connect hostname to a local origin qualifies the application carrier and native DSH dispatch, not Cloudflare reachability or native key storage. +- The mobile QA queue may be paused. Keep iOS/Android/Human checks explicitly unrun rather than claiming web-export pixels as native evidence. +- A browser served through an untrusted intermediary is outside the executable-code trust claim. Origin-local storage is not hardware protection. +- Never capture real credentials or private chats. Only synthetic test input belongs in published evidence. +- Protocol vectors and peer reviews do not constitute an external security audit. diff --git a/.agents/skills/verify-deepseekbot/features/phone-pairing.md b/.agents/skills/verify-deepseekbot/features/phone-pairing.md index 0941592a37..32389b04ed 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-pairing.md +++ b/.agents/skills/verify-deepseekbot/features/phone-pairing.md @@ -11,6 +11,8 @@ A Human pairs the phone app with their Host by a one-time QR code, and the phone - `link-errors` words a refusal by how the phone got the offer: a pasted or opened link that is broken, expired or already used says the link is wrong, and a pasted one empties the field; a scan keeps the code wording. - `manual-input` keeps the empty link field and Pair button above the Android keyboard. A manually scanned or pasted offer registers the phone, loads the Host roster and survives app process restart. - `cloud-exit` leaves the native pairing modal before opening Cloud services, so Workspaces exposes a visible, operable drawer. +- `fingerprint-steps` opens a fresh encrypted invitation. The scan/link form is replaced by a focused computer verification page with all 64 fingerprint digits, selectable grouped text and native actions. Expand/collapse the explanation without granting trust. A mismatch or Back must cancel without opening a channel; a fresh invitation still works. Explicitly compare with the trusted Host, tap Fingerprints match, then compare the full phone fingerprint with Host Devices and approve only that key. The phone waits without a second trust action and remains cancellable. Check light/dark and default/intermediate/maximum text; compare full fingerprints before scrolling to reachable actions. Both account and direct-link routes use the same verification view. +- `pair-return` uses the existing assigned iOS Simulator: compare the real Connect Host fingerprint, approve the exact displayed device key, then immediately open Chats, Settings (close and reopen) and Activity without restarting/reloading the app. Require no `sheetPresentationController` error or blocked taps. Repeat with the first-use notice unread/read, light/dark and default/intermediate/maximum system text. `apps/mobile/.maestro/sim/pairing-return.yaml` runs the post-approval interaction check without launching or clearing the app; keep native approval and capture evidence alongside it. - `roster` shows the Host's PersonaBots in the app through the gateway, with the live stream. - `restart` keeps the phone connected after a Host restart without pairing again. - `devices` lists each phone with its pairing and last-seen time. diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 189ab6dc63..722b5bc8f8 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -13,7 +13,7 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. - `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. - `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. -- `secret-route`: Connect private input shows a bilingual trusted-route notice without secret fields. Cancellation remains available; the shared Host client refuses before serialization. Loopback/Tailscale retain submission. +- `secret-route`: pinned Connect pairings carry private input, resources and logical streams through Noise; legacy pairings fail closed. The browser must be served by its own trusted Host. Loopback/Tailscale retain their contract. - `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control. - `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing. - `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. @@ -22,6 +22,7 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu Discovery uses the Host's notification query, not the Activity display window. Keep more than 30 old mentions and produce a new ordinary DM reply and a non-mention group reply; both must alert once when enabled. Traverse `deepseekbot inbox --notifications` cursors and compare the Host-decided items. Verify stable same-timestamp pagination, a live invalidation during paging, a partial-page failure followed by recovery, and no historical replay. A refused Notification constructor records a bounded redacted `remote-notification-show-*` diagnostic through the existing Client diagnostics surface. Settings apply immediately and close through the normal close control. + ## How to get to it (user POV) Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. @@ -58,6 +59,8 @@ For message bodies, upload an image and a text file to Host B through its attach - Dismiss Continue and Skip onboarding overlays before selecting Messages. Retain any failed drive evidence before retrying. For #2541, pair a Connect Host and open a pending private input card. Confirm the notice in both themes, absence of password fields and working Cancel. Verify marked unary and mux calls return `secret-route-refused`, and ordinary Host Feed traffic still works. The removed `/botharness-device/web/` route must return 404. Use synthetic secrets only; a model quota failure blocks live card creation and must remain an explicit QA gap. + +For #2542, independently compare the displayed Host fingerprint, then approve the exact pending device key in Host Settings → Devices. Keep the browser's key/pin/credential in one atomic origin-local record. Open remote chat, send text and an image, approve a tool request and fill a synthetic private request. Correlate visible state with the owning Host's timeline and private-input status. Capture the outer Connect origin before decryption: no device credential, offer, secret, inner path or attachment name may appear. Test revocation, changed pins and stripped capability; none may retry a plaintext Connect path. WebView and Connect viewer URLs remain refused. See [the Connect journey](connect-channel.md) for transport-specific gates. Activation race regression: while replies are disabled, write 35 old mentions and keep a notification page pending, then enable replies. The switch/CLI write commits only after a complete fresh baseline; old mentions stay silent. After success, send a new group reply and ordinary DM (including tied timestamps in automated fixtures): each alerts once. Fail a page and retry, disable/re-enable, change Host, close settings, revoke permission and dispose while activation waits; failed/cancelled operations cannot commit later. Preserve the original 37-alert failing trace separately from any subsequent pass. CLI deadline regression: let the real appearance relay refuse at eight seconds, then complete the notification baseline at nine seconds. Browser preferences and persisted storage must remain unchanged, and no late acknowledgement is sent. Repeat after control unmount; a pre-deadline successful write remains operable. Change the app locale while the DOM language is unchanged and verify private-input notification copy follows the bound translator without exposing the input. diff --git a/.github/pr-screenshots/2542/README.md b/.github/pr-screenshots/2542/README.md new file mode 100644 index 0000000000..659b02397e --- /dev/null +++ b/.github/pr-screenshots/2542/README.md @@ -0,0 +1,31 @@ +# Connect Noise XK — review evidence + +Issue #2542, stacked PR #2547 above Track A #2546. Synthetic accounts and values only. + +## Captures + +- `private-before-{light,dark}.png`: Track A's existing remote Web card blocks secret input over Connect. Copied without alteration from #2546 evidence; the captured behavior is unchanged at stack base `b842d3ce0750b5085106defef21c4d4eb0112f81`. +- `private-after-{light,dark}.png`: a trusted second Host's browser paired through the actual encrypted channel, after independent Host fingerprint comparison and explicit approval of its exact device key. The remote card shows a masked field and a working submit action. The visible result was **Submitted**, canonical status was **configured**, and the official DeepSeek Bot resumed with `ENCRYPTED-PRIVATE-OK`. No page errors were observed. The driver's final English wait initially expected “Saved”; the captured “Submitted” state was reconciled with canonical state separately. +- `mobile-web-{light,dark}.png` and `mobile-web-saved.png`: Expo Web at 390 × 844, using the real isolated Host through the encrypted carrier. The input is `type=password`; submitting the synthetic value clears the field, renders **已处理**, and settles canonical state to **configured**. The corrected bilingual driver completed with no page errors. These are **Web export captures, not iOS or Android evidence**. + +Browser/Host code corresponds to `9a08d65cc114b978b904cc626ab0c8c4c1a6d07e`; the Host implementation is unchanged since `db7fa00`. Mobile export was built at `db7fa00a4dd8dfd501ddea953e1bbb53a5338237`; the form and encrypted wire implementation are unchanged at `9a08d65`. The later commit only adds terminal handling for unsuccessful Connect HTTP responses in push/removal and its regression. + +## Runtime scope + +Two task-owned DSH `0.2.1-rc.2` Profiles. A synthetic Connect hostname maps locally to the real task-owned Connect origin; no real Cloudflare tunnel or relay deployment is claimed. Actual Device Gateway admission and DSH dispatch were used. Public test vectors use independent Cacophony fixtures; they are not an external cryptographic audit. + +Verified: fingerprint + CLI exact-key approval, `connect-status`, encrypted list and synthetic credential set/unset, live roster, image upload with an official DeepSeek reply, byte-identical encrypted attachment download, browser private input, and the Expo Web private-input form. Outer-frame checks found none of the synthetic credential/offer/value/method sentinels. These observations do not prove resistance to every active attack. + +Focused tests, static checks, full-suite results and CI status are reported separately in the PR. Native key storage, iOS/Android rendering and lifecycle, real Cloudflare traversal, account enrollment in the deployed relay, accessibility, Human QA and external security audit remain unrun. The native queue is paused by the Human. No merge, release or deployment occurred. + +## Real Cloudflare follow-up (2026-10-22) + +Runtime code: `683b0cbe9c0f158e80929601f59a312ff5f9c4bf`; this follow-up adds evidence only. See [real-tunnel.md](real-tunnel.md) for method, failure matrix, native build correction and the unresolved first-pairing sheet error. `cf-web-*` captures trusted Web Client submission before/error/success over real Cloudflare. `cf-ios-input/fail/ok` captures the rebuilt native app on the existing QA iOS27, including the real keyboard. All values/accounts are synthetic. No device credential, pairing payload, private key, cloud identity or raw traffic capture is published. These are runtime state evidence; earlier Track A/B comparison images remain above. Physical iPhone, Android, deployed account enrollment, Human QA and external audit are not covered. + +## Native pairing fix follow-up (2026-10-22) + +[Native pairing report](native-pairing.md) and [redacted results](native-pairing-results.json) supersede the earlier first-pairing limitation: the iOS pairing modal and first-notice transition are fixed, with six light/dark × text-size cases and the Maestro interaction regression passing without a post-pairing restart. Existing QA iOS27 and its qualified native binary were reused. `pair-before.png` records the original error; `pair-*` after images show usable Chats, Activity and the existing QA conversation. This is bounded native pairing acceptance, not a claim that all mobile features, physical iPhone, Android or Human QA are complete. + +## Concise native verification follow-up (2026-10-22) + +[Fingerprint UI report](fingerprint-ui.md) and [exact-source results](fingerprint-results.json) record the replacement of the long native alert with focused verification and phone-approval steps. Complete selectable fingerprints, native actions and optional details passed six iOS theme/text cases, mismatch and cancellation checks. The previous password matrix remains separate; Android/physical-device/Chinese-rendering/assistive-technology acceptance is not inferred from this run. diff --git a/.github/pr-screenshots/2542/cf-ios-fail.png b/.github/pr-screenshots/2542/cf-ios-fail.png new file mode 100644 index 0000000000..324667947f Binary files /dev/null and b/.github/pr-screenshots/2542/cf-ios-fail.png differ diff --git a/.github/pr-screenshots/2542/cf-ios-input.png b/.github/pr-screenshots/2542/cf-ios-input.png new file mode 100644 index 0000000000..9cf6d18700 Binary files /dev/null and b/.github/pr-screenshots/2542/cf-ios-input.png differ diff --git a/.github/pr-screenshots/2542/cf-ios-ok.png b/.github/pr-screenshots/2542/cf-ios-ok.png new file mode 100644 index 0000000000..f37b40c7db Binary files /dev/null and b/.github/pr-screenshots/2542/cf-ios-ok.png differ diff --git a/.github/pr-screenshots/2542/cf-ios-sheet.png b/.github/pr-screenshots/2542/cf-ios-sheet.png new file mode 100644 index 0000000000..29e7e284b2 Binary files /dev/null and b/.github/pr-screenshots/2542/cf-ios-sheet.png differ diff --git a/.github/pr-screenshots/2542/cf-web-before.png b/.github/pr-screenshots/2542/cf-web-before.png new file mode 100644 index 0000000000..4510e7ffec Binary files /dev/null and b/.github/pr-screenshots/2542/cf-web-before.png differ diff --git a/.github/pr-screenshots/2542/cf-web-fail.png b/.github/pr-screenshots/2542/cf-web-fail.png new file mode 100644 index 0000000000..b7faf730ae Binary files /dev/null and b/.github/pr-screenshots/2542/cf-web-fail.png differ diff --git a/.github/pr-screenshots/2542/cf-web-ok.png b/.github/pr-screenshots/2542/cf-web-ok.png new file mode 100644 index 0000000000..39be8b6d6c Binary files /dev/null and b/.github/pr-screenshots/2542/cf-web-ok.png differ diff --git a/.github/pr-screenshots/2542/fingerprint-results.json b/.github/pr-screenshots/2542/fingerprint-results.json new file mode 100644 index 0000000000..837f87ccb8 --- /dev/null +++ b/.github/pr-screenshots/2542/fingerprint-results.json @@ -0,0 +1,112 @@ +{ + "baseline": "cb65e435e76f14c435a462863ebd3a3e374104cf", + "runtime": { + "ios": "27.0", + "expo": "58.0.17", + "reactNative": "0.88.0", + "reusedSimulator": true, + "reusedNativeBuild": true, + "newSimulator": false, + "erased": false + }, + "sourceSha256": { + "apps/mobile/src/ui/shell/use-pairing.ts": "9394d06daf59a3170806a5246b6353c9fa527b23d4480f92bc540fe7e3dd1dbc", + "apps/mobile/src/ui/shell/pairing-verification.tsx": "9bed2430df78627ff0e0dbf06babf462bdda841ca7dc050910c7207d185c7029", + "apps/mobile/src/ui/primitives/pairing-actions.tsx": "7f153c02c13dda25474d0f1fea24150cb14a9aca29ade68ca603345bf4d6b41c", + "apps/mobile/src/ui/primitives/pairing-actions.ios.tsx": "54782371c9ece22f4d7c13d98f276440e2d0b71bfb51eea8f9b3e9192a58f61c", + "apps/mobile/app/pair/account.tsx": "b6c033e1a1c9463c36ebfaf5811d2e77f94e4167af4ba669ab6721522a81b243", + "apps/mobile/app/pair/tailscale.tsx": "0b77379cf8c90c5a8db490c30e082ba1c5fd869272b91ba37eb9911d04fbcf87", + "apps/mobile/src/ui/strings.ts": "3885488557f1526f8573d3d0c41c5ad5a028214d094bc9099fde9667c2b9a74f" + }, + "cases": [ + { + "theme": "light", + "textSize": "large", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + }, + { + "theme": "dark", + "textSize": "large", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + }, + { + "theme": "light", + "textSize": "accessibility-medium", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + }, + { + "theme": "dark", + "textSize": "accessibility-medium", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + }, + { + "theme": "light", + "textSize": "accessibility-extra-extra-extra-large", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + }, + { + "theme": "dark", + "textSize": "accessibility-extra-extra-extra-large", + "completeHostFingerprintCompared": true, + "completeDeviceFingerprintApproved": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false, + "noSheetError": true + } + ], + "refusals": { + "detailsNoTrust": true, + "mismatchNoChannel": true, + "backCancelsConfirmation": true, + "freshInvitationAfterCancel": true, + "fallbackRequests": 0, + "pendingApprovalCancelAtMaximumText": true, + "pendingCancelSameProcess": true + }, + "wire": { + "outerEntries": 822, + "upgrades": 18, + "channelPathOnly": true, + "noCredentialHeaders": true, + "fallbackRequests": 0, + "scope": "Pairing and navigation only; no new password submission in this follow-up." + } +} diff --git a/.github/pr-screenshots/2542/fingerprint-ui.md b/.github/pr-screenshots/2542/fingerprint-ui.md new file mode 100644 index 0000000000..2215220413 --- /dev/null +++ b/.github/pr-screenshots/2542/fingerprint-ui.md @@ -0,0 +1,30 @@ +# Native fingerprint verification + +Baseline: `cb65e435e76f14c435a462863ebd3a3e374104cf`. The exact changed source hashes and native results are in [fingerprint-results.json](fingerprint-results.json). + +The long system alert over the scan/link form becomes a focused verification step inside the existing pairing modal. A short instruction, computer name and complete selectable fingerprint appear above native actions. Explanation is collapsed by default. After the Human compares every group with the trusted computer and explicitly confirms, the page switches to the phone fingerprint and waits for the computer to approve that exact key. Back, mismatch and Cancel do not grant trust. Switching steps resets the scroll position. Both account and direct pairing use this view; deployed account enrollment was not exercised here. + +SwiftUI buttons use semantic text and native sizing on iOS; the shared action adapter uses native Compose controls on Android. No dependency or native-build input changed. No additional sheet or alert is presented over the pairing route. + +## Actual captures + +All captures use the existing assigned QA iOS27 Simulator at 402 × 874 and the qualified signed Debug development client. The computer name is synthetic, and the fingerprints are public identities in a dedicated QA Profile. No private keys, invitation payloads, account credentials, temporary tunnel hostname or raw captures are published. + +- `fp-before.png`: original Host verification alert, light/default text, captured before editing. +- `fp-light.png` / `fp-dark.png`: new focused Host verification, default text. +- `fp-phone.png`: new phone-approval step on the same real tunnel. The former inline fingerprint lived below the scan/link form; the before capture shows that original form behind its confirmation alert. +- `fp-mid.png` / `fp-mid-dark.png`: intermediate system text. +- `fp-max.png` / `fp-max-dark.png`: maximum system text, fingerprint reached by scrolling. +- `fp-actions.png`: reachable native confirmation actions at maximum text/dark. + +Every fingerprint character remains available; no ellipsis, abbreviated comparison, reduced font scaling or automatic trust is used. Text can wrap and the page scrolls. The evidence captures are English; Chinese copy is maintained, but Chinese native rendering is not claimed by this run. + +## Verification and limits + +The real Cloudflare public DNS/TLS tunnel and production Connect adapter were used. Six light/dark × default/intermediate/maximum text cases compared the complete Host fingerprint, then compared the full phone fingerprint with the pending Host key before approving it. Each returned to Chats and opened/closed Settings, opened Activity and returned to Chats in the same application process. No post-pairing cold restart or reload, native sheet error, or blocked action was required. First-use notice dismissal preceded each case. + +Explanation expansion does not grant trust. Native mismatch and Back cancellation opened no channel; a new invitation worked immediately. The capture also checks fixed channel URLs, no outer credential headers and zero fallback requests. These are pairing/navigation observations; the earlier [real password failure matrix](real-tunnel.md) remains separate and was not rerun for this presentation change. + +Focused regression tests cover full fingerprint preservation, optional details, explicit confirmation, rejection, stale confirmation after cancellation and cancellation while awaiting phone approval. Device dismissal and actual touch behavior are supported by the native runs, not the mocked component tests. + +Android, physical iPhone, deployed account enrollment, VoiceOver/TalkBack, Human QA and external security audit remain separate acceptance work. The PR stays Draft and stacked; no merge, release or product-service deployment. diff --git a/.github/pr-screenshots/2542/fp-actions.png b/.github/pr-screenshots/2542/fp-actions.png new file mode 100644 index 0000000000..1f7bbbd122 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-actions.png differ diff --git a/.github/pr-screenshots/2542/fp-before.png b/.github/pr-screenshots/2542/fp-before.png new file mode 100644 index 0000000000..6d0d03b2a1 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-before.png differ diff --git a/.github/pr-screenshots/2542/fp-dark.png b/.github/pr-screenshots/2542/fp-dark.png new file mode 100644 index 0000000000..e2205bd3cd Binary files /dev/null and b/.github/pr-screenshots/2542/fp-dark.png differ diff --git a/.github/pr-screenshots/2542/fp-light.png b/.github/pr-screenshots/2542/fp-light.png new file mode 100644 index 0000000000..abd501ca12 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-light.png differ diff --git a/.github/pr-screenshots/2542/fp-max-dark.png b/.github/pr-screenshots/2542/fp-max-dark.png new file mode 100644 index 0000000000..1f7bbbd122 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-max-dark.png differ diff --git a/.github/pr-screenshots/2542/fp-max.png b/.github/pr-screenshots/2542/fp-max.png new file mode 100644 index 0000000000..4e57d2650b Binary files /dev/null and b/.github/pr-screenshots/2542/fp-max.png differ diff --git a/.github/pr-screenshots/2542/fp-mid-dark.png b/.github/pr-screenshots/2542/fp-mid-dark.png new file mode 100644 index 0000000000..3f9165b12b Binary files /dev/null and b/.github/pr-screenshots/2542/fp-mid-dark.png differ diff --git a/.github/pr-screenshots/2542/fp-mid.png b/.github/pr-screenshots/2542/fp-mid.png new file mode 100644 index 0000000000..70db2d4980 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-mid.png differ diff --git a/.github/pr-screenshots/2542/fp-phone.png b/.github/pr-screenshots/2542/fp-phone.png new file mode 100644 index 0000000000..71464779a2 Binary files /dev/null and b/.github/pr-screenshots/2542/fp-phone.png differ diff --git a/.github/pr-screenshots/2542/integration-security.md b/.github/pr-screenshots/2542/integration-security.md new file mode 100644 index 0000000000..c8b94c616e --- /dev/null +++ b/.github/pr-screenshots/2542/integration-security.md @@ -0,0 +1,28 @@ +# Connect integration security verification + +This report covers the final integration of current main (`8f4e650db`), Track A and Track B. Earlier screenshots and real Cloudflare/native receipts retain their recorded runtime revisions; they are not recaptured evidence for this integration. + +## Current main integration + +- The source-Host message implementation from #2538 supersedes closed #2533. The old parallel adapter is removed. +- Private input asks the owning Host client whether its route supports secrets. Unpinned Connect refuses; encrypted Connect can submit. Cancel remains available. +- The canonical message-resource seam uses the owning source's encrypted `read` function. A rejected encrypted read propagates; it never retries direct fetch or the local Host. +- Current main's Remote Host settings, viewers, notification lifecycle and source routing are preserved. Connect Browser/Computer remains refused by the encrypted gateway. + +## Additional #2542 acceptance mapping + +| Contract | Implementation and evidence | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| First enrollment and account pairing bind intended keys | ADR-0244 requires independent full Host fingerprint comparison and exact device-key approval; `pairing.ts`, `connect-pairing.ts` and the native verification flow enforce that ceremony. Account discovery never supplies trust by itself. Prior native fingerprint evidence remains separately linked in the PR; deployed account enrollment is not qualified. | +| Fresh channel binds Host, device and protocol/route | `noise/channel.ts` binds Host id and protocol/route in the prologue, authenticates the device static key, and uses the fresh handshake transcript plus direction/counter/length as frame AAD. Gateway admission binds the approved device key to its credential. | +| Receiving endpoint rejects tamper and replay | `connect-encrypted-gateway.test.ts` injects tampered and replayed ciphertext directly into the real WebSocket gateway. Reconnect replay uses the same static identities and matching next counter after fresh admission; fresh transcript/keys reject it. No extra upstream application effect is accepted. Substituted device keys and revoked-device reconnect are refused. | +| Concurrent sends and bounded work | Encryption/counter advancement plus frame sends are synchronous. Endpoint tests exercise concurrent requests and an overloaded slow upstream; at most 32 requests are admitted. Existing channel tests cover fragment/message bounds; native-carrier tests cover unacknowledged-byte/message limits without bufferedAmount. | +| Ready and cleanup ownership | Gateway message/error/close handlers are installed before admission. Exact-key admission attaches revocation cleanup before sending `admitted`; the client exposes its session only after that receipt. Async request/stream callbacks compare their concrete owner before deleting or publishing. Session cleanup only removes its own cache entry. Replacement-stream and cancelled-request regressions pass. Existing Connect failure tests prohibit route fallback/replay. | + +The replacement-stream regression fails without the ownership guard: a late `closed` event arrives in place of the new stream's `opened` event. With the guard the replacement sends/receives normally. The guard also covers late open, message and error callbacks. + +## Verification boundary + +- Targeted tests cover actual gateway admission, private RPC, revocation, tamper, same-session replay, reconnect replay, substituted device key, concurrent sends, slow upstream overload, cancellation and logical-stream replacement. +- The integration changes no pairing protocol, key derivation or native UI layout. Existing platform screenshots are historical evidence of those unchanged implementations, not proof of a new full native run. +- Physical iPhone, Android native interaction, deployed account enrollment, assistive technology, Chinese native rendering, independent cryptographic audit and the complete native DM/image/tool/private-input release matrix remain unqualified. Merge is not deployment or production qualification. diff --git a/.github/pr-screenshots/2542/mobile-web-dark.png b/.github/pr-screenshots/2542/mobile-web-dark.png new file mode 100644 index 0000000000..1a003c7690 Binary files /dev/null and b/.github/pr-screenshots/2542/mobile-web-dark.png differ diff --git a/.github/pr-screenshots/2542/mobile-web-light.png b/.github/pr-screenshots/2542/mobile-web-light.png new file mode 100644 index 0000000000..93ab8550dc Binary files /dev/null and b/.github/pr-screenshots/2542/mobile-web-light.png differ diff --git a/.github/pr-screenshots/2542/mobile-web-saved.png b/.github/pr-screenshots/2542/mobile-web-saved.png new file mode 100644 index 0000000000..279ab591fc Binary files /dev/null and b/.github/pr-screenshots/2542/mobile-web-saved.png differ diff --git a/.github/pr-screenshots/2542/native-pairing-results.json b/.github/pr-screenshots/2542/native-pairing-results.json new file mode 100644 index 0000000000..5b905936a4 --- /dev/null +++ b/.github/pr-screenshots/2542/native-pairing-results.json @@ -0,0 +1,95 @@ +{ + "baseline": "606b1b75e7cbef13168a4e94f56f6d4c1af33d01", + "runtime": { + "ios": "27.0", + "expo": "58.0.17", + "reactNative": "0.88.0", + "build": "existing normally signed Debug development client", + "reusedSimulator": true, + "newSimulator": false, + "erased": false, + "nativeRebuild": false + }, + "sourceSha256": { + "apps/mobile/app/_layout.tsx": "ba943da4c18cd407fe404f5a6da1be5fb50a85baf9c78840cae28f20bfad8767", + "apps/mobile/src/ui/chat/sheet.tsx": "eded90cc21a9a400e7ecd2a0e379e593fe6a1a912ade8a7eacc633fd3730a0af", + "apps/mobile/src/ui/shell/statistics-notice-view.tsx": "8a435fd9c3a4f599b64d96fe20e28ead281d317553017dd86a3908632b4377dc" + }, + "cases": [ + { + "theme": "light", + "textSize": "large", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + }, + { + "theme": "dark", + "textSize": "large", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + }, + { + "theme": "light", + "textSize": "accessibility-medium", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + }, + { + "theme": "dark", + "textSize": "accessibility-medium", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + }, + { + "theme": "light", + "textSize": "accessibility-extra-extra-extra-large", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + }, + { + "theme": "dark", + "textSize": "accessibility-extra-extra-extra-large", + "firstUseNoticeInitiallyUnread": true, + "noSheetError": true, + "settingsOpenedAndClosed": true, + "activityOpened": true, + "returnedToChats": true, + "sameAppProcess": true, + "coldRestartAfterPairing": false + } + ], + "maestroPostApprovalPassed": true, + "wire": { + "outerEntries": 2385, + "upgrades": 46, + "channelPathOnly": true, + "noCredentialHeaders": true, + "fallbackRequests": 0, + "scope": "Pairing and navigation only; no new password submission in this follow-up." + } +} diff --git a/.github/pr-screenshots/2542/native-pairing.md b/.github/pr-screenshots/2542/native-pairing.md new file mode 100644 index 0000000000..c3fd5733d2 --- /dev/null +++ b/.github/pr-screenshots/2542/native-pairing.md @@ -0,0 +1,36 @@ +# Native first-pairing follow-up — PR #2547 + +This follow-up builds on `606b1b75e7cb` and stays stacked on #2546. It changes JavaScript only and reuses the same normally signed Expo 58.0.17 / React Native 0.88.0 Debug app on the existing QA iOS 27 Simulator. No new Simulator, erase, native rebuild, store-app change or physical-phone use. + +## Reproduction and diagnosis + +The red-capable native driver opened a fresh invitation over real public Cloudflare DNS/TLS, independently compared the Host fingerprint, compared the pending device fingerprint, then approved that exact key on the trusted Host. It asserted on the exact `sheetPresentationController is null when attempting to set allowed detents` error and checked actual post-pairing interactions. + +- Baseline reproduced the native error immediately after successful pairing. Marking the first-use notice already read did not remove it. +- The pairing route contained a nested native stack inside a `formSheet`. During dismissal, React Native Screens received content layout after its sheet presentation controller was unavailable. Using the existing Settings-style iOS `modal` presentation removed that detent-controller path. Android's form sheet is unchanged. +- A separate boundary remained: closing the first-use React Native notice and immediately opening the pairing link could leave taps blocked even without the red error. The same maximum-text case worked when the notice was already read. Removing only this notice's iOS fade made the immediate close → pair → use case pass. Other sheets retain their default fade; Android's notice is unchanged. +- An early presentation probe used Metro's stale CI-mode bundle and is not evidence against the fix. Metro was restarted with watching enabled before the red/green comparison. Initial driver preparation failures (deep-link startup, duplicated native labels, fingerprint parsing and scrolling) are excluded from passing results. + +No handshake, key admission, credential persistence, gateway, secret submission or fallback code changed in this follow-up. The earlier real password success/failure evidence remains in [real-tunnel.md](real-tunnel.md); it is not claimed as a new password run here. + +## Native regression method + +Only test setup may terminate the development app to reset its task-owned first-use-notice fixture. After the displayed device key is approved, no process restart, Metro reload or overlay dismissal is allowed as a recovery step. The driver records the same app PID before approval, after pairing and after Settings/Activity use. The result must show usable Chats, Settings opening and closing, Activity, and return to Chats, with no native error. The QA conversation is separately opened from the returned Chats list. + +`apps/mobile/.maestro/sim/pairing-return.yaml` preserves the post-approval regression: no launch, reload or clear-state command, actual Settings/Activity actions, reopening Settings and explicit absence of the error. It must be run immediately after the owning runner's trusted fingerprint comparison and device-key approval. JavaScript DOM tests cannot reproduce UIKit's presentation lifecycle; native evidence is the regression seam. + +## Results + +- Six cases passed: light/dark × default (`large`), intermediate (`accessibility-medium`) and maximum (`accessibility-extra-extra-extra-large`) system text. Every case started with the first-use notice unread, closed it, paired, then opened/closed Settings and used Activity and Chats in the same app process. No cold restart, reload or red-overlay dismissal after pairing. +- The committed Maestro post-approval flow passed on the final source. Native child Back, root Close and downward swipe returned to the existing Chats list; the QA conversation opened afterward in the same process. +- The repeated native maximum-text failure with the old notice fade is retained locally; the earlier baseline red overlay is `pair-before.png`. `pair-light.png`, `pair-dark.png`, `pair-max.png`, `pair-max-dark.png`, `pair-activity.png` and `pair-chat.png` are inspected native after states at 402 × 874. The Handled cards in the conversation are historical fixtures, not new password submissions. +- Six focused test files / 23 tests, mobile typecheck, lint, format and both changelog checks passed. Full GitHub CI is tracked separately by the PR head. +- This follow-up observed 2,385 outer entries / 46 upgrades, only the channel path, no credential headers and zero fallback-trap requests. It exercises pairing/navigation; the earlier password failure matrix is preserved separately. + +See [the redacted receipt](native-pairing-results.json) for per-case checks and the exact runtime source-file hashes. Raw native snapshots and process IDs stay local. The automated driver required scrolling to reach the pending fingerprint at maximum text; no layout or font scaling was weakened. + +## Scope + +Public screenshots contain only the task's synthetic QA content. Raw pairing links, credentials, gateway captures, infrastructure identifiers and native snapshots stay machine-local. Existing app data and other pairings are preserved. Human QA, physical iPhone, Android, deployed account enrollment and external cryptographic audit remain separate. No merge, release or product relay deployment is authorized. + +Cleanup verified: the temporary named tunnel and DNS record were deleted, test device keys revoked, Connect disabled, and the task Host/Metro/recorder stopped. The task's Tunnel QA pairing was removed; the other saved pairing remained. The notice fixture was restored, appearance/text returned to light/default, and the device lane released. Existing Simulator, physical phone, store app, native build products and source/evidence were preserved. diff --git a/.github/pr-screenshots/2542/pair-activity.png b/.github/pr-screenshots/2542/pair-activity.png new file mode 100644 index 0000000000..a8d81fe746 Binary files /dev/null and b/.github/pr-screenshots/2542/pair-activity.png differ diff --git a/.github/pr-screenshots/2542/pair-before.png b/.github/pr-screenshots/2542/pair-before.png new file mode 100644 index 0000000000..ff1771d4e6 Binary files /dev/null and b/.github/pr-screenshots/2542/pair-before.png differ diff --git a/.github/pr-screenshots/2542/pair-chat.png b/.github/pr-screenshots/2542/pair-chat.png new file mode 100644 index 0000000000..be5e114d00 Binary files /dev/null and b/.github/pr-screenshots/2542/pair-chat.png differ diff --git a/.github/pr-screenshots/2542/pair-dark.png b/.github/pr-screenshots/2542/pair-dark.png new file mode 100644 index 0000000000..69013177ad Binary files /dev/null and b/.github/pr-screenshots/2542/pair-dark.png differ diff --git a/.github/pr-screenshots/2542/pair-light.png b/.github/pr-screenshots/2542/pair-light.png new file mode 100644 index 0000000000..8fb8eaca5e Binary files /dev/null and b/.github/pr-screenshots/2542/pair-light.png differ diff --git a/.github/pr-screenshots/2542/pair-max-dark.png b/.github/pr-screenshots/2542/pair-max-dark.png new file mode 100644 index 0000000000..25d0aad5e5 Binary files /dev/null and b/.github/pr-screenshots/2542/pair-max-dark.png differ diff --git a/.github/pr-screenshots/2542/pair-max.png b/.github/pr-screenshots/2542/pair-max.png new file mode 100644 index 0000000000..5a73e87c0c Binary files /dev/null and b/.github/pr-screenshots/2542/pair-max.png differ diff --git a/.github/pr-screenshots/2542/private-after-dark.png b/.github/pr-screenshots/2542/private-after-dark.png new file mode 100644 index 0000000000..dc90c630cb Binary files /dev/null and b/.github/pr-screenshots/2542/private-after-dark.png differ diff --git a/.github/pr-screenshots/2542/private-after-light.png b/.github/pr-screenshots/2542/private-after-light.png new file mode 100644 index 0000000000..184e0f8c4a Binary files /dev/null and b/.github/pr-screenshots/2542/private-after-light.png differ diff --git a/.github/pr-screenshots/2542/private-before-dark.png b/.github/pr-screenshots/2542/private-before-dark.png new file mode 100644 index 0000000000..f6b06b2717 Binary files /dev/null and b/.github/pr-screenshots/2542/private-before-dark.png differ diff --git a/.github/pr-screenshots/2542/private-before-light.png b/.github/pr-screenshots/2542/private-before-light.png new file mode 100644 index 0000000000..4c8952f754 Binary files /dev/null and b/.github/pr-screenshots/2542/private-before-light.png differ diff --git a/.github/pr-screenshots/2542/real-ios-result.json b/.github/pr-screenshots/2542/real-ios-result.json new file mode 100644 index 0000000000..8bd92978d3 --- /dev/null +++ b/.github/pr-screenshots/2542/real-ios-result.json @@ -0,0 +1,25 @@ +{ + "head": "683b0cbe9c0f158e80929601f59a312ff5f9c4bf", + "device": "existing QA iOS 27", + "nativeBuild": "Debug, Xcode simulator signing enabled", + "realCloudflare": true, + "hostFingerprintCompared": true, + "deviceFingerprintCompared": true, + "keychainPairingPersisted": true, + "coldRestartReconnected": true, + "requiredFieldDisabled": true, + "secureTextField": true, + "keyboardAndSaveReachable": true, + "failureVisible": true, + "failureCanonical": "pending", + "explicitRetry": true, + "successVisible": "Handled", + "successCanonical": "configured", + "inputRemoved": true, + "noPlaintextObserved": true, + "fallbackRequests": 0, + "limitations": [ + "First-pairing RNScreens sheetPresentationController error required cold restart; not full native acceptance", + "Account enrollment, physical iPhone, Android and external audit unrun" + ] +} diff --git a/.github/pr-screenshots/2542/real-outer-result.json b/.github/pr-screenshots/2542/real-outer-result.json new file mode 100644 index 0000000000..2bb80971be --- /dev/null +++ b/.github/pr-screenshots/2542/real-outer-result.json @@ -0,0 +1,13 @@ +{ + "head": "683b0cbe9c0f158e80929601f59a312ff5f9c4bf", + "captureSha256": "09be7416f261dc4afefc5f438cd7802281ea8d386c1f966081c52c5896c86c06", + "entries": 1466, + "upgrades": 39, + "credentialHeadersAbsent": true, + "fixedChannelPath": true, + "syntheticSecretAbsent": true, + "base64SecretAbsent": true, + "methodNamesAbsent": true, + "fallbackRequests": 0, + "throughRealCloudflare": true +} diff --git a/.github/pr-screenshots/2542/real-revocation-result.json b/.github/pr-screenshots/2542/real-revocation-result.json new file mode 100644 index 0000000000..cd4d69c34e --- /dev/null +++ b/.github/pr-screenshots/2542/real-revocation-result.json @@ -0,0 +1,9 @@ +{ + "head": "683b0cbe9c0f158e80929601f59a312ff5f9c4bf", + "realCloudflare": true, + "revokedClientRefused": true, + "notificationCallbackRefused": true, + "canonicalPending": true, + "noRawClientRequests": true, + "noFallbackRequests": true +} diff --git a/.github/pr-screenshots/2542/real-tunnel-result.json b/.github/pr-screenshots/2542/real-tunnel-result.json new file mode 100644 index 0000000000..108c6a88ff --- /dev/null +++ b/.github/pr-screenshots/2542/real-tunnel-result.json @@ -0,0 +1,52 @@ +{ + "head": "683b0cbe9c0f158e80929601f59a312ff5f9c4bf", + "realCloudflare": true, + "dnsOverride": false, + "edgeRayObserved": true, + "pairing": "fingerprint and exact device key approval passed", + "privateInput": "configured through real tunnel", + "settings": true, + "failures": [ + { + "case": "reject", + "refused": true, + "unchanged": true, + "noFallback": true, + "durationMs": 434 + }, + { + "case": "blackhole", + "refused": true, + "unchanged": true, + "noFallback": true, + "durationMs": 10006 + }, + { + "case": "tamper", + "refused": true, + "unchanged": true, + "noFallback": true, + "durationMs": 1025 + }, + { + "case": "wrong-host-key", + "refused": true, + "unchanged": true, + "noFallback": true, + "durationMs": 614 + }, + { + "case": "missing-host-key", + "refused": true, + "unchanged": true, + "noFallback": true, + "durationMs": 3 + } + ], + "recovery": "explicit retry succeeded", + "captureEntries": 91, + "rawClientRequests": 0, + "fallbackRequests": 0, + "secretsAbsent": true, + "native": false +} diff --git a/.github/pr-screenshots/2542/real-tunnel.md b/.github/pr-screenshots/2542/real-tunnel.md new file mode 100644 index 0000000000..9f8976143c --- /dev/null +++ b/.github/pr-screenshots/2542/real-tunnel.md @@ -0,0 +1,36 @@ +# Real Cloudflare verification — PR #2547 + +Candidate: `683b0cbe9c0f158e80929601f59a312ff5f9c4bf`, stacked on #2546 `b842d3ce0750b5085106defef21c4d4eb0112f81`. No merge or deployment of the product relay. + +Later follow-up: [the native pairing fix](native-pairing.md) resolves the first-pairing error and verifies immediate use without cold restart. The observations below remain the historical password-path run at the candidate above. + +## Environment and observation + +A dedicated temporary named Cloudflare tunnel and proxied DNS record were created using the repository's Cloudflare API adapter. Normal public DNS and TLS were used, with a Cloudflare edge response confirmed. There was no hosts-file, DNS or client URL mapping. The real cloudflared executable was started by the owning Connect module on the isolated DSH 0.2.1-rc.2 Host. Ingress remained limited to `/botharness-device/*`; other paths returned404. + +An instrumented local forwarding hop between cloudflared and the owning device gateway captured outer request headers/URLs and both directions of WebSocket frames, before application decryption. It did not receive the Noise keys. Controlled rejection, blackhole and tampering modes exercised failures. A second saved loopback route terminated at a counting trap. Private captures and infrastructure identities remain machine-local; only aggregate results are publishable. + +This verifies the tunnel and application encryption path. It does not verify deployed account enrollment/WorkOS discovery, Android, physical-device acceptance, Human QA or external cryptographic audit. + +## Passed + +- Real tunnel pairing compared the pinned Host fingerprint and explicitly approved the exact device public key. +- Synthetic private input reached canonical `configured` state through the actual private-input RPC. Notification settings also traversed the encrypted gateway. +- Connection rejection, handshake timeout, tampered traffic, wrong Host key and missing Host key all refused. The private request remained pending; no raw carrier request or fallback-trap request occurred. Explicit recovery succeeded. +- Revoking a paired device prevented further private submission and prevented the outer notification callback from switching routes. +- Web UI loaded from a trusted local Host: actual masked password form, failed submission displayed the error, canonical state remained pending, explicit state check/retry succeeded, visible Submitted matched canonical configured, and the field was removed. Zero page errors. Before/error/success images were inspected. +- Outer captures contained no synthetic password, base64 password, device credential, private-input method name, authorization header, cookie or WebSocket subprotocol credential. Channel upgrades used only the fixed channel path. No fallback-trap requests. + +## iOS + +Current source `683b0cbe9c0f158e80929601f59a312ff5f9c4bf` was rebuilt as a Debug development app (Expo58.0.17 / RN0.88.0) and driven on the existing QA iOS27. No new Simulator or erase; existing store app and physical XS Max were untouched. + +Host and device fingerprints were independently compared before exact-key approval. Pairing persisted in native Keychain and reconnected after cold restart. The required secure text field disabled submission while empty; the real keyboard showed masked input and a reachable save control. Tunnel rejection produced a visible error and canonical pending state, with zero fallback requests. After explicit refresh/retry, Save privately reached canonical configured and visible Handled; the input was removed. The final capture contains1466 entries /39 upgrades, no synthetic secret or credential headers, and zero fallback-trap requests. + +**Native acceptance remains open:** first pairing triggered `[RNScreens] sheetPresentationController is null when attempting to set allowed detents`, followed by blocked taps. A cold restart restored interaction and retained the pairing. The cause and regression scope are unresolved; the password-path success does not close this UI issue. `cf-ios-sheet.png` records it. This is not a clean full native QA pass. + +Build correction: the initial `CODE_SIGNING_ALLOWED=NO` binary lacked Keychain entitlements and refused to save pairing. Xcode's normal simulator signing build fixed this local build configuration. No source workaround or weaker credential storage was introduced. Manual ad-hoc signing was unsuccessful and was replaced by the successful Xcode build. Download-cache cleanup was authorized; source, installed dependencies, evidence, build products and existing devices were preserved. + +## Driver corrections + +Initial evidence scripts encountered a leading-dash one-time receipt requiring the CLI `--` separator, a superseded old private account, a non-existent pending-key fingerprint property, and the UI's exact Retry check accessible label. They were corrected against current contracts. These attempts were not counted as passing feature verification. Browser failure records and Host observations remain local. diff --git a/.github/pr-screenshots/2542/real-web-result.json b/.github/pr-screenshots/2542/real-web-result.json new file mode 100644 index 0000000000..b4a61052c0 --- /dev/null +++ b/.github/pr-screenshots/2542/real-web-result.json @@ -0,0 +1,16 @@ +{ + "head": "683b0cbe9c0f158e80929601f59a312ff5f9c4bf", + "realCloudflare": true, + "clientCode": "trusted local Host", + "fingerprintCompared": true, + "keyApproved": true, + "maskedInput": true, + "failureVisible": true, + "failureCanonical": "pending", + "fallbackRequests": 0, + "successVisible": "Submitted", + "successCanonical": "configured", + "inputCleared": true, + "outerSecretAbsent": true, + "consoleErrors": [] +} diff --git a/AGENTS.md b/AGENTS.md index 45a3432171..793a65fb18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,8 @@ Single-context layout: root `CONTEXT.md` + `docs/adr/` (extended lazily by the d ### UI guidelines +Every new or changed Web, desktop or mobile interface follows [UI design](docs/agents/ui-design.md) ([中文](docs/agents/ui-design.zh.md)): communicate through layout, icons and interaction; keep descriptive prose secondary. Apply its review gate alongside the platform-specific rules. + Building or changing in-harness UI follows the `dsh-ui` skill: DSH tokens (central alias table, no inline hex), `ui-primitives`, the native inset contract, and the measure-the-shell workflow (ADR-0028). For net-new UI, the skill's `references/design-references.md` names the external precedents (coss.com/ui, Beautiful UI, shadcn/ui, Lucide) — patterns to adapt rather than dependencies, except vendored Lucide glyphs (ADR-0032). ### UI copy diff --git a/apps/mobile/.maestro/sim/README.md b/apps/mobile/.maestro/sim/README.md index 86f1829e31..0cf8f6b5c8 100644 --- a/apps/mobile/.maestro/sim/README.md +++ b/apps/mobile/.maestro/sim/README.md @@ -1,3 +1,5 @@ # Simulator flows with a live Host These flows need values from a running Host, so `maestro test .maestro` (which only runs the top-level flows) skips them. `node scripts/e2e-mobile-sim.mjs` runs them in order against two isolated Hosts. Each flow names the `-e` variables it reads. + +`pairing-return.yaml` is a separate post-approval regression flow. On the assigned existing Simulator, pair through a fresh Connect invitation, independently compare the Host fingerprint and approve the exact device key on the trusted Host, then run this flow immediately. Do not restart or reload the app between approval and the flow. It checks that Settings and Activity remain operable after dismissal, including reopening Settings; the old native form-sheet error blocked these taps. The caller owns the real tunnel, approval and redacted evidence. Repeat in both themes and supported text sizes; this flow never erases app state or starts a device. diff --git a/apps/mobile/.maestro/sim/pairing-return.yaml b/apps/mobile/.maestro/sim/pairing-return.yaml new file mode 100644 index 0000000000..2060840539 --- /dev/null +++ b/apps/mobile/.maestro/sim/pairing-return.yaml @@ -0,0 +1,27 @@ +# Run immediately after approving a new pairing on the assigned iOS Simulator. +# The caller must compare both fingerprints and approve the exact device key. +# Deliberately no launchApp, reload or clearState: a restart hid this regression. +appId: ai.botharness.deepseekbot.dev +--- +- tapOn: + text: 'Got it' + optional: true +- extendedWaitUntil: + visible: 'Workspaces' + timeout: 30000 +- assertNotVisible: '.*sheetPresentationController.*' +- tapOn: 'Settings' +- extendedWaitUntil: + visible: 'Close' + timeout: 10000 +- assertNotVisible: '.*sheetPresentationController.*' +- tapOn: 'Close' +- assertVisible: 'Workspaces' +- tapOn: 'Activity' +- assertVisible: 'Search Activity' +- tapOn: 'Chats' +- tapOn: 'Settings' +- assertVisible: 'Close' +- tapOn: 'Close' +- assertVisible: 'Workspaces' +- assertNotVisible: '.*sheetPresentationController.*' diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index fb6cbf46ac..1120664354 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -19,7 +19,7 @@ Read docs for SDK 58 (`https://docs.expo.dev/versions/v58.0.0/...`), not `latest ## Design rules -Screens follow [DESIGN.md](./DESIGN.md): the Host-as-workspace structure, the screen anatomy, and the code rules. No literal colours, type, spacing, radii or edge widths outside `src/ui/design/` (`test/style-guard.test.ts`); text through `src/ui/primitives/text`; new visual elements land in `src/ui/primitives`, `chat` or `shell` before a screen uses them. +Screens follow the shared [UI design standard](../../docs/agents/ui-design.md) and [DESIGN.md](./DESIGN.md): the Host-as-workspace structure, the screen anatomy, and the code rules. No literal colours, type, spacing, radii or edge widths outside `src/ui/design/` (`test/style-guard.test.ts`); text through `src/ui/primitives/text`; new visual elements land in `src/ui/primitives`, `chat` or `shell` before a screen uses them. **Immediate interaction feedback is required.** For navigation, drawers/sheets, search and async actions, follow [Responsive interactions](./DESIGN.md#responsive-interactions): commit the shell/selection before uncached data or heavy content, retain scoped cached content during refresh, and measure tap-to-feedback separately from data-ready time. Include long-list and streaming regressions when those paths are affected; 120 FPS requires native Release evidence, not a display refresh-rate setting. diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 39dd5917d4..9fab70b548 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -11,6 +11,10 @@ References: | Meta Muse | A Bot profile whose identity and memory are visible and editable. | | Slack | Workspace switching, a row of shortcut cards on Chats, an Activity feed with filters, and agent steps with a Stop button. | +## Shared design standard + +All new or changed screens follow the cross-platform [UI design standard](../../docs/agents/ui-design.md) ([中文](../../docs/agents/ui-design.zh.md)) and its review gate. Layout, icons and interaction communicate the task; descriptive prose supplies only necessary context. The native component and accessibility rules below implement that standard on each platform. + ## Overlay contract [Mobile overlay standard](./OVERLAYS.md) ([中文](./OVERLAYS.zh.md)) defines presentation choice, handle/header/body anatomy, immediate versus transactional changes, dismissal, platform differences and acceptance gates. It is the target contract for new or changed overlays; its audit records current gaps, including the provisional iOS Settings fix in #2112. @@ -86,6 +90,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - It takes screen-reader focus when it opens and returns focus to the Host chip when it closes. With Reduce Motion on it appears without sliding. - Android Back closes it. Android draws it as a Material 3 modal navigation drawer: up to 360dp wide while leaving at least 56dp of scrim (`materialDrawer`), 16dp end corners, and each row a full-pill item inset 12dp, with the current Host or workspace on the `sheet.chosen` active indicator and pressed rows on the press tint. A row's connection address stays on one line and shrinks to at most `materialDrawer.addressMinScale` at large text instead of breaking inside the port (127.0.0.1:3 / 020). - Normal-size rows use a name and a smaller muted address, with one line each. Accessibility sizes may wrap. Status dots and accessible labels retain online/permission context; channel-only guests keep the invited-channel scope in their secondary label. + - On iOS, the pairing stack uses native modal presentation, like Settings: child Back stays within pairing, and successful pairing dismisses to usable Chats without a restart. A form-sheet detent controller can disappear before the nested stack finishes layout during dismissal. Android retains its native form sheet. The first-use statistics notice has no iOS fade, so closing it immediately before pairing does not leave a native presentation blocking touches; other sheets and Android keep their existing animation. + - Encrypted pairing replaces the scan/link form with one focused step at a time: verify the computer, then approve the phone on the computer. Show the complete, selectable fingerprint in a monospaced content card, with a short instruction and optional explanation. Use native SwiftUI / Compose actions, semantic system text and scrolling; do not stack a fingerprint alert over the pairing modal. Back/cancel settles the pending confirmation without granting trust; only an explicit match continues, and Host approval still binds the exact phone key. Reset scroll position when the step changes. - Pairing accepts an optional local name using the existing computer appearance store; direct-link pairing can keep the original name, then rename in Customize computer. Cloud import accepts a display name; Manage cloud services can rename it later. These names affect only this phone and never change Host identity, organization names, trusted endpoints or sessions. - **Tabs** remain **Chats** and **Activity**, using the same Expo Router JS tab navigator on all platforms, with the existing route stacks above it. Search arrives with its working N3 slice. - A chat or creation form is pushed above the tabs, hiding the bar. Each list reserves the measured bar height plus safe-area gap; scroll hide/reveal stays on the UI thread. diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index eb17ad27cd..64e8afd943 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -72,12 +72,24 @@ function RootNavigator() { /> + ); let body; - if (params.kind === 'tool-approval' && approvalPort !== undefined) + if (params.kind === 'private-input' && client && ref) + body = ( + void inbox?.refresh()} + /> + ); + else if (params.kind === 'tool-approval' && approvalPort !== undefined) body = ; else if (params.kind === 'user-question' && questionPort !== undefined) body = ; diff --git a/apps/mobile/app/pair/account.tsx b/apps/mobile/app/pair/account.tsx index c79047a7d0..278b57d3fa 100644 --- a/apps/mobile/app/pair/account.tsx +++ b/apps/mobile/app/pair/account.tsx @@ -11,6 +11,7 @@ import { Button } from '../../src/ui/primitives/button'; import { ConnectionNameField } from '../../src/ui/primitives/connection-name-field'; import { KeyboardFormScroll } from '../../src/ui/primitives/keyboard-form'; import { Text } from '../../src/ui/primitives/text'; +import { PairingVerification } from '../../src/ui/shell/pairing-verification'; import { usePairing } from '../../src/ui/shell/use-pairing'; import { t } from '../../src/ui/strings'; @@ -30,6 +31,8 @@ export default function PairAccountScreen() { }, [params.account]), ); + if (pairing.busy) return ; + return ( ; + return ( (); + return ( + + {client && saved && hostId === saved.hostId && channelId && messageId && botSlug ? ( + void inbox?.refresh()} + /> + ) : ( + {t.privateInputEmpty} + )} + + ); +} diff --git a/apps/mobile/src/host/carrier.ts b/apps/mobile/src/host/carrier.ts index 66a6213b77..b4121b441f 100644 --- a/apps/mobile/src/host/carrier.ts +++ b/apps/mobile/src/host/carrier.ts @@ -1,6 +1,8 @@ export interface HostResponse { status: number; text(): Promise; + bytes?(): Promise; + contentType?: string; } export interface HostRequest { @@ -17,11 +19,14 @@ export interface HostSocketEvents { } export interface HostSocket { + readonly bufferedAmount?: number; send(text: string): void; close(): void; } export interface HostCarrier { + randomBytes?(): Uint8Array; + close?(): void; request(url: string, init: HostRequest): Promise; openSocket(url: string, events: HostSocketEvents, headers?: Record): HostSocket; } diff --git a/apps/mobile/src/host/client.ts b/apps/mobile/src/host/client.ts index 317600bae3..d2446df800 100644 --- a/apps/mobile/src/host/client.ts +++ b/apps/mobile/src/host/client.ts @@ -1,9 +1,11 @@ +import { encryptedCarrier, encodeBase64, ConnectRefused } from './encrypted-carrier'; +import type { HostEncryption } from './hosts'; import type { ConnectionTelemetry } from '../telemetry/connection'; import { ActivitySmoother } from '../../../../packages/client/src/client/activity-smoothing'; import type { BotPrimaryColor } from '../../../../packages/core/src/bots/primary-color'; import type { HostCarrier, HostSocket } from './carrier'; import { DEVICE_PREFIX } from './pairing'; -import { isConnectRoute, socketOrigin } from './route'; +import { checkRoute, isConnectRoute, socketOrigin } from './route'; import { secretRouteRefusal } from '../../../../packages/core/src/bridge/secret-route'; import { REMOTE_STREAM_MUX_PATH, @@ -101,6 +103,7 @@ export interface HostSnapshot { } export interface RemoteSource { + read?: (signal: AbortSignal) => Promise<{ bytes: Uint8Array; mime: string }>; uri: string; headers: Record; } @@ -122,6 +125,7 @@ export interface HostClient { upload(name: string, data: Blob): Promise; api(path: string, init?: HostApiInit): Promise; resource(path: string): RemoteSource; + secretInputAllowed?(): boolean; onLive(listener: (source: 'roster' | 'activity') => void): () => void; follow(endpoint: string, args: Record, handlers: StreamHandlers): () => void; } @@ -138,6 +142,8 @@ export interface HostApiAnswer { } export interface HostClientOptions { + hostId?: string; + encryption?: HostEncryption; telemetry?: ConnectionTelemetry; routes: string[]; credential: string; @@ -260,11 +266,19 @@ export function arrangementOf(value: unknown): RosterArrangement | undefined { } export function createHostClient(options: HostClientOptions): HostClient { + const carrier = encryptedCarrier({ + raw: options.carrier, + hostId: options.hostId ?? '', + credential: options.credential, + ...(options.encryption === undefined ? {} : { encryption: options.encryption }), + }); const routes = options.routes; const authorization = { authorization: `Device ${options.credential}` }; let route = routes[0] ?? ''; const backoff = options.backoff ?? defaultBackoff; const timeoutMs = options.timeoutMs ?? 10_000; + const avatars = new Map(); + const avatarLoading = new Set(); const listeners = new Set<() => void>(); const liveListeners = new Set<(source: 'roster' | 'activity') => void>(); let snapshot: HostSnapshot = { connection: { status: 'stopped', route } }; @@ -335,16 +349,14 @@ export function createHostClient(options: HostClientOptions): HostClient { return { slug: bot.slug, displayName: bot.displayName, - ...(bot.avatar === undefined - ? {} - : { avatarUrl: absolute(bot.avatar), avatarHeaders: authorization }), + ...(bot.avatar === undefined ? {} : avatar(bot.avatar)), ...(bot.appearance === undefined ? {} : { appearance: bot.appearance }), ...(bot.avatarSeed === undefined ? {} : { avatarSeed: bot.avatarSeed }), ...(bot.primaryColor === undefined ? {} : { primaryColor: bot.primaryColor }), roles: bot.roles ?? [], ...(bot.description === undefined ? {} : { description: bot.description }), - computerAccess: bot.computerAccess === true, - browserAccess: bot.browserAccess === true, + computerAccess: bot.computerAccess === true && !isConnectRoute(route), + browserAccess: bot.browserAccess === true && !isConnectRoute(route), state: live?.state ?? bot.aggregateState, ...(live?.activity === undefined ? {} : { activity: live.activity }), ...(live?.sessions === undefined ? {} : { sessions: live.sessions }), @@ -362,6 +374,60 @@ export function createHostClient(options: HostClientOptions): HostClient { const absolute = (path: string): string => path.startsWith('/') ? `${route}${DEVICE_PREFIX}${path}` : path; + const resource = (path: string): RemoteSource => { + if (checkRoute(route).ok && !isConnectRoute(route)) + return { uri: absolute(path), headers: authorization }; + const origin = route; + return { + uri: `connect-resource:${options.hostId ?? ''}:${path}`, + headers: {}, + read: async (signal) => { + if (!path.startsWith('/') || path.startsWith('//')) + throw new Error('resource-path-invalid'); + const response = await carrier.request(`${origin}${DEVICE_PREFIX}${path}`, { + method: 'GET', + signal, + }); + if (response.status !== 200 || !response.bytes) throw new Error('resource-unavailable'); + return { + bytes: await response.bytes(), + mime: response.contentType ?? 'application/octet-stream', + }; + }, + }; + }; + const avatar = (path: string): { avatarUrl?: string; avatarHeaders?: Record } => { + if (!checkRoute(route).ok) return {}; + if (!isConnectRoute(route)) return { avatarUrl: absolute(path), avatarHeaders: authorization }; + const key = route + path; + const cached = avatars.get(key); + if (cached) return { avatarUrl: cached, avatarHeaders: {} }; + if (running && avatars.size < 64 && avatarLoading.size < 8 && !avatarLoading.has(key)) { + avatarLoading.add(key); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + void resource(path).read!(controller.signal) + .then( + ({ bytes, mime }) => { + if ( + running && + bytes.length <= 512 * 1024 && + /^image\/(?:png|jpeg|webp|gif)$/u.test(mime.split(';')[0]!) + ) { + avatars.set(key, `data:${mime};base64,${encodeBase64(bytes)}`); + publish(); + } + }, + () => {}, + ) + .finally(() => { + clearTimeout(timer); + avatarLoading.delete(key); + }); + } + return {}; + }; + const reloadInBackground = (): void => { reload().catch(() => {}); }; @@ -375,6 +441,13 @@ export function createHostClient(options: HostClientOptions): HostClient { try { return await work(controller.signal); } catch (error) { + if (error instanceof ConnectRefused) { + if (error.code in REFUSALS) throw new CredentialRefused(error.code as DeviceRefusal); + throw new MethodRefused( + error.code, + 'Encrypted connection refused. Check the Host fingerprint and pair again.', + ); + } if ( error instanceof CredentialRefused || error instanceof Unreachable || @@ -398,7 +471,10 @@ export function createHostClient(options: HostClientOptions): HostClient { ): Promise => withDeadline(async (signal) => { const name = method.includes('/') ? method : `botharness/${method}`; - const refusal = isConnectRoute(via) ? secretRouteRefusal(name) : undefined; + const refusal = + isConnectRoute(via) && options.encryption === undefined + ? secretRouteRefusal(name) + : undefined; if (refusal !== undefined) throw new MethodRefused(refusal.code, refusal.message); const request: ClientRequest = { type: 'client-request', @@ -406,7 +482,7 @@ export function createHostClient(options: HostClientOptions): HostClient { method: name, payload: { args }, }; - const response = await options.carrier.request(`${via}${DEVICE_PREFIX}/api/${name}`, { + const response = await carrier.request(`${via}${DEVICE_PREFIX}/api/${name}`, { method: 'POST', body: JSON.stringify(request), headers: authorization, @@ -436,7 +512,7 @@ export function createHostClient(options: HostClientOptions): HostClient { const upload = (name: string, data: Blob): Promise => withDeadline(async (signal) => { - const response = await options.carrier.request( + const response = await carrier.request( `${route}${DEVICE_PREFIX}/api/botharness/attachment/upload?${new URLSearchParams({ name })}`, { method: 'POST', @@ -465,6 +541,11 @@ export function createHostClient(options: HostClientOptions): HostClient { const api = (path: string, init: HostApiInit = {}): Promise => withDeadline(async (signal) => { + if (isConnectRoute(route) && /^\/api\/(?:browser|computer)\//u.test(path)) + throw new MethodRefused( + 'connect-viewer-unavailable', + 'Use the Host or Tailscale for Browser and Computer viewers.', + ); const outer = init.signal; if (outer?.aborted === true) throw new Unreachable('The request was cancelled.'); const controller = new AbortController(); @@ -472,7 +553,7 @@ export function createHostClient(options: HostClientOptions): HostClient { signal.addEventListener('abort', cancel); outer?.addEventListener('abort', cancel); try { - const response = await options.carrier.request(`${route}${DEVICE_PREFIX}${path}`, { + const response = await carrier.request(`${route}${DEVICE_PREFIX}${path}`, { method: init.body === undefined ? 'GET' : 'POST', ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), headers: authorization, @@ -586,7 +667,12 @@ export function createHostClient(options: HostClientOptions): HostClient { route = candidate; return; } catch (error) { - if (error instanceof CredentialRefused) throw error; + if ( + isConnectRoute(candidate) || + error instanceof CredentialRefused || + error instanceof MethodRefused + ) + throw error; failure = error; } } @@ -661,7 +747,7 @@ export function createHostClient(options: HostClientOptions): HostClient { const frame: MuxClientFrame = { type: 'open', streamId, endpoint, payload: { args: {} } }; socket?.send(JSON.stringify(frame)); }; - socket = options.carrier.openSocket( + socket = carrier.openSocket( socketOrigin(route) + DEVICE_PREFIX + REMOTE_STREAM_MUX_PATH, { open: () => { @@ -797,6 +883,9 @@ export function createHostClient(options: HostClientOptions): HostClient { wake?.(); socket?.close(); socket = undefined; + carrier.close?.(); + avatars.clear(); + avatarLoading.clear(); publish({ status: 'stopped', route }); options.telemetry?.dispose(); }, @@ -836,7 +925,9 @@ export function createHostClient(options: HostClientOptions): HostClient { } }, upload, - resource: (path) => ({ uri: absolute(path), headers: authorization }), + resource, + secretInputAllowed: () => + checkRoute(route).ok && (!isConnectRoute(route) || options.encryption !== undefined), onLive(listener) { liveListeners.add(listener); return () => liveListeners.delete(listener); diff --git a/apps/mobile/src/host/connect-account.ts b/apps/mobile/src/host/connect-account.ts index c68d3cbdb1..64a0a69ecb 100644 --- a/apps/mobile/src/host/connect-account.ts +++ b/apps/mobile/src/host/connect-account.ts @@ -1,3 +1,4 @@ +import { checkRoute, isConnectRoute } from './route'; import type { PairingPayload } from './pairing'; export const CONNECT_RELAY = 'https://connect.botharness.ai'; @@ -244,18 +245,32 @@ export function createConnectAccount(options: { { verifierHash, name: deviceName.slice(0, 64) }, ); const host = record(body['host']); + const encryption = record(body['encryption']); + const enrollment = record(body['enrollment']); + const route = + typeof body['hostname'] === 'string' + ? checkRoute(`https://${body['hostname']}`) + : undefined; if ( - typeof body['offer'] !== 'string' || + encryption['protocol'] !== 'connect-noise-xk-v1' || + typeof encryption['publicKey'] !== 'string' || + !/^[0-9a-f]{64}$/u.test(encryption['publicKey']) || + typeof enrollment['body'] !== 'string' || + typeof enrollment['signature'] !== 'string' || typeof body['expiresAt'] !== 'string' || - typeof body['hostname'] !== 'string' || + route?.ok !== true || + !isConnectRoute(route.origin) || + new URL(route.origin).hostname !== body['hostname'] || typeof host['id'] !== 'string' || typeof host['name'] !== 'string' ) throw new Refused('relay-unavailable'); return { host: { id: host['id'], name: host['name'] }, - offer: body['offer'], - routes: [`https://${body['hostname']}`], + offer: '', + encryption: { protocol: 'connect-noise-xk-v1', publicKey: encryption['publicKey'] }, + enrollment: { body: enrollment['body'], signature: enrollment['signature'] }, + routes: [route.origin], expiresAt: body['expiresAt'], verifier, }; diff --git a/apps/mobile/src/host/encrypted-carrier.ts b/apps/mobile/src/host/encrypted-carrier.ts new file mode 100644 index 0000000000..a6f41950c7 --- /dev/null +++ b/apps/mobile/src/host/encrypted-carrier.ts @@ -0,0 +1,373 @@ +import { + ConnectChannel, + CONNECT_CHANNEL_PATH, +} from '../../../../packages/core/src/devices/noise/channel'; +import type { HostCarrier, HostResponse, HostSocket, HostSocketEvents } from './carrier'; +import type { HostEncryption } from './hosts'; +import { checkRoute, isConnectRoute, socketOrigin } from './route'; + +export class ConnectRefused extends Error { + constructor(readonly code: string) { + super(code); + } +} + +function requestUrl(input: string): URL { + let url: URL; + try { + url = new URL(input); + } catch { + throw new ConnectRefused('route-invalid'); + } + if (!checkRoute(url.origin).ok || url.username || url.password || url.hash) + throw new ConnectRefused('route-invalid'); + return url; +} + +export function keyBytes(hex: string): Uint8Array { + if (!/^[0-9a-f]{64}$/u.test(hex)) throw new Error('host-key-invalid'); + return Uint8Array.from(hex.match(/../gu)!, (value) => Number.parseInt(value, 16)); +} +export function keyHex(bytes: Uint8Array): string { + return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); +} +export function secureRandom(carrier: HostCarrier): Uint8Array { + const value = carrier.randomBytes?.() ?? globalThis.crypto?.getRandomValues(new Uint8Array(32)); + if (!value || value.length !== 32) throw new Error('secure-random-unavailable'); + return value; +} +export function base64Bytes(text: string): Uint8Array { + return Uint8Array.from(atob(text), (value) => value.charCodeAt(0)); +} +export function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (let start = 0; start < bytes.length; start += 8192) + binary += String.fromCharCode(...bytes.subarray(start, start + 8192)); + return btoa(binary); +} + +export function openNoiseSocket(options: { + origin: string; + hostId: string; + encryption: HostEncryption; + raw: HostCarrier; + message(value: Record): void; + closed(reason: string): void; +}) { + if (!checkRoute(options.origin).ok) throw new ConnectRefused('route-invalid'); + const channel = new ConnectChannel({ + role: 'initiator', + hostId: options.hostId, + hostKey: keyBytes(options.encryption.hostKey), + staticKey: keyBytes(options.encryption.deviceKey), + ephemeralKey: secureRandom(options.raw), + }); + let done = false; + let nextCredit = 0, + outstandingBytes = 0; + const credits = new Map(); + let resolve!: () => void, reject!: (error: Error) => void; + const ready = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const fail = (reason: string) => { + if (done) return; + done = true; + clearTimeout(timer); + channel.close(); + credits.clear(); + outstandingBytes = 0; + socket.close(); + reject(new Error(reason)); + options.closed(reason); + }; + const timer = setTimeout(() => fail('channel-timeout'), 10_000); + const socket = options.raw.openSocket(`${socketOrigin(options.origin)}${CONNECT_CHANNEL_PATH}`, { + open: () => { + try { + socket.send(channel.start()); + } catch { + fail('channel-handshake-failed'); + } + }, + message: (wire) => { + try { + const result = channel.receive(wire); + if (result.reply) socket.send(result.reply); + if (result.ready) { + clearTimeout(timer); + resolve(); + } + if (result.message !== undefined) { + const value: unknown = JSON.parse(result.message); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('channel-message-invalid'); + const message = value as Record; + if (message['type'] === 'ack') { + const credit = message['credit']; + const bytes = typeof credit === 'number' ? credits.get(credit) : undefined; + if (bytes === undefined) throw new Error('channel-credit-invalid'); + credits.delete(credit as number); + outstandingBytes -= bytes; + } else options.message(message); + } + } catch { + fail('channel-authentication-failed'); + } + }, + closed: (reason) => fail(reason || 'channel-disconnected'), + }); + return { + ready, + send(value: unknown): void { + if (done) throw new Error('channel-disconnected'); + try { + if (credits.size >= 256 || ++nextCredit >= 2 ** 32) + throw new Error('channel-credit-exhausted'); + const frames = channel.send( + JSON.stringify({ ...(value as Record), credit: nextCredit }), + ); + const size = frames.reduce((total, wire) => total + wire.length, 0); + if (outstandingBytes + size > 20 * 1024 * 1024) throw new Error('channel-busy'); + outstandingBytes += size; + credits.set(nextCredit, size); + for (const wire of frames) { + if ((socket.bufferedAmount ?? 0) + wire.length > 20 * 1024 * 1024) + throw new Error('channel-busy'); + socket.send(wire); + } + } catch (error) { + fail('channel-send-failed'); + throw error; + } + }, + close: () => fail('channel-disconnected'), + }; +} + +interface Session { + ready: Promise; + send(value: unknown): void; + close(): void; + requests: Map; + streams: Map; +} +export function encryptedCarrier(options: { + raw: HostCarrier; + hostId: string; + credential: string; + encryption?: HostEncryption; +}): HostCarrier { + const sessions = new Map(); + let next = 0; + const session = (origin: string): Session => { + if (!options.encryption || options.encryption.protocol !== 'connect-noise-xk-v1') + throw new ConnectRefused('host-key-required'); + const existing = sessions.get(origin); + if (existing) return existing; + const requests: Session['requests'] = new Map(), + streams: Session['streams'] = new Map(); + let resolve!: () => void, reject!: (error: Error) => void; + const ready = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + let settled = false; + const socket = openNoiseSocket({ + ...options, + origin, + encryption: options.encryption, + closed: (reason) => { + if (sessions.get(origin) === current) sessions.delete(origin); + clearTimeout(deadline); + clearInterval(heartbeat); + const error = + reason === 'channel-authentication-failed' + ? new ConnectRefused(reason) + : new Error(reason); + reject(error); + for (const item of requests.values()) item.reject(error); + for (const stream of streams.values()) stream.closed(reason); + requests.clear(); + streams.clear(); + }, + message: (value) => { + if (value['type'] === 'refused') { + reject(new ConnectRefused(String(value['code']))); + socket.close(); + return; + } + if (!settled) { + if (value['type'] !== 'admitted') throw new Error('channel-admission-invalid'); + settled = true; + clearTimeout(deadline); + resolve(); + return; + } + if (value['type'] === 'pong') return; + const id = String(value['id']); + if (value['type'] === 'response') { + const pending = requests.get(id); + if (!pending) return; + if (typeof value['status'] !== 'number' || typeof value['body'] !== 'string') + throw new Error('channel-response-invalid'); + const bytes = base64Bytes(value['body']); + requests.delete(id); + pending.resolve({ + status: value['status'], + contentType: + typeof value['contentType'] === 'string' + ? value['contentType'] + : 'application/octet-stream', + bytes: async () => bytes, + text: async () => new TextDecoder().decode(bytes), + }); + } else if (value['type'] === 'opened') streams.get(id)?.open(); + else if (value['type'] === 'message' && typeof value['text'] === 'string') + streams.get(id)?.message(value['text']); + else if (value['type'] === 'closed') { + streams.get(id)?.closed('channel-stream-closed'); + streams.delete(id); + } else throw new Error('channel-response-invalid'); + }, + }); + const deadline = setTimeout(() => { + reject(new Error('channel-admission-timeout')); + socket.close(); + }, 15_000); + void socket.ready.then( + () => { + try { + socket.send({ type: 'admit', credential: options.credential }); + } catch { + socket.close(); + } + }, + (error) => { + reject(error); + socket.close(); + }, + ); + const heartbeat = setInterval(() => { + if (settled) { + try { + socket.send({ type: 'ping' }); + } catch {} + } + }, 20_000); + const current: Session = { + ready, + requests, + streams, + send: (value) => socket.send(value), + close: socket.close, + }; + sessions.set(origin, current); + return current; + }; + return { + randomBytes: () => secureRandom(options.raw), + close: () => { + for (const current of sessions.values()) current.close(); + sessions.clear(); + }, + async request(url, init) { + const parsed = requestUrl(url); + if (!isConnectRoute(parsed.origin)) return options.raw.request(url, init); + if (init.signal.aborted) throw new Error('request-cancelled'); + const current = session(parsed.origin); + await current.ready; + if (init.signal.aborted) throw new Error('request-cancelled'); + if ( + typeof init.body !== 'string' && + init.body !== undefined && + init.body.size > 4 * 1024 * 1024 + ) + throw new Error('channel-body-too-large'); + if ( + typeof init.body === 'string' && + new TextEncoder().encode(init.body).length > 4 * 1024 * 1024 + ) + throw new Error('channel-body-too-large'); + const body = + typeof init.body === 'string' + ? init.body + : init.body === undefined + ? undefined + : encodeBase64(new Uint8Array(await init.body.arrayBuffer())); + if (init.signal.aborted) throw new Error('request-cancelled'); + if (current.requests.size >= 32) throw new Error('channel-busy'); + const id = String(++next); + return new Promise((resolve, reject) => { + const abort = () => { + current.requests.delete(id); + try { + current.send({ type: 'cancel', id }); + } catch {} + reject(new Error('request-cancelled')); + }; + const cleanup = () => init.signal.removeEventListener('abort', abort); + init.signal.addEventListener('abort', abort, { once: true }); + current.requests.set(id, { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + }); + try { + current.send({ + type: 'request', + id, + path: parsed.pathname + parsed.search, + method: init.method, + ...(body === undefined ? {} : { body }), + encoding: typeof init.body === 'string' ? 'text' : 'base64', + contentType: init.headers?.['content-type'], + }); + } catch (error) { + cleanup(); + current.requests.delete(id); + reject(error); + } + }); + }, + openSocket(url, events, headers): HostSocket { + const parsed = requestUrl(url.replace(/^ws/u, 'http')); + if (!isConnectRoute(parsed.origin)) return options.raw.openSocket(url, events, headers); + let current: Session | undefined, + done = false; + const id = String(++next); + void (async () => { + try { + current = session(parsed.origin); + await current.ready; + if (done) return; + if (current.streams.size >= 4) throw new Error('channel-busy'); + current.streams.set(id, events); + current.send({ type: 'open', id, path: parsed.pathname }); + } catch (error) { + if (!done) events.closed(error instanceof Error ? error.message : 'channel-refused'); + } + })(); + return { + send: (text) => { + if (!current || done) throw new Error('channel-disconnected'); + current.send({ type: 'send', id, text }); + }, + close: () => { + if (done) return; + done = true; + current?.streams.delete(id); + try { + current?.send({ type: 'close', id }); + } catch {} + }, + }; + }, + }; +} diff --git a/apps/mobile/src/host/host-session.ts b/apps/mobile/src/host/host-session.ts index 2e1e4b64d9..546e4a54a1 100644 --- a/apps/mobile/src/host/host-session.ts +++ b/apps/mobile/src/host/host-session.ts @@ -88,6 +88,7 @@ function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { saved === null ? undefined : createHostClient({ + ...saved, routes: saved.routes, credential: saved.credential, carrier: nativeCarrier(), diff --git a/apps/mobile/src/host/hosts.ts b/apps/mobile/src/host/hosts.ts index 5ffc0b6d03..f6d02cc3c4 100644 --- a/apps/mobile/src/host/hosts.ts +++ b/apps/mobile/src/host/hosts.ts @@ -1,4 +1,11 @@ +export interface HostEncryption { + protocol: 'connect-noise-xk-v1'; + hostKey: string; + deviceKey: string; +} + export interface SavedHost { + encryption?: HostEncryption; hostId: string; hostName: string; routes: string[]; @@ -24,6 +31,9 @@ export function parseSavedHost(input: unknown): SavedHost | undefined { typeof value['deviceId'] === 'string' && typeof value['credential'] === 'string' ? { + ...(parseEncryption(value['encryption']) === undefined + ? {} + : { encryption: parseEncryption(value['encryption'])! }), hostId: value['hostId'], hostName: value['hostName'], routes: value['routes'] as string[], @@ -66,6 +76,10 @@ export function currentHost(state: SavedHosts): SavedHost | undefined { } export function withHost(state: SavedHosts, host: SavedHost): SavedHosts { + const previous = state.hosts.find((entry) => entry.hostId === host.hostId); + if (previous?.encryption && previous.encryption.hostKey !== host.encryption?.hostKey) + throw new Error('host-key-mismatch'); + return { hosts: [...state.hosts.filter((entry) => entry.hostId !== host.hostId), host], current: host.hostId, @@ -86,3 +100,14 @@ export function selectHost(state: SavedHosts, hostId: string): SavedHosts | unde ? { hosts: state.hosts, current: hostId } : undefined; } + +export function parseEncryption(input: unknown): HostEncryption | undefined { + const value = record(input); + return value['protocol'] === 'connect-noise-xk-v1' && + typeof value['hostKey'] === 'string' && + /^[0-9a-f]{64}$/u.test(value['hostKey']) && + typeof value['deviceKey'] === 'string' && + /^[0-9a-f]{64}$/u.test(value['deviceKey']) + ? { protocol: 'connect-noise-xk-v1', hostKey: value['hostKey'], deviceKey: value['deviceKey'] } + : undefined; +} diff --git a/apps/mobile/src/host/native-carrier.ts b/apps/mobile/src/host/native-carrier.ts index c0eabd5ea3..548abd24fa 100644 --- a/apps/mobile/src/host/native-carrier.ts +++ b/apps/mobile/src/host/native-carrier.ts @@ -1,3 +1,4 @@ +import { getRandomBytes } from 'expo-crypto'; import type { HostCarrier } from './carrier'; type NativeWebSocket = new ( @@ -8,6 +9,7 @@ type NativeWebSocket = new ( export function nativeCarrier(): HostCarrier { return { + randomBytes: () => getRandomBytes(32), async request(url, init) { const response = await fetch(url, { method: init.method, @@ -19,7 +21,12 @@ export function nativeCarrier(): HostCarrier { }, ...(init.body === undefined ? {} : { body: init.body }), }); - return { status: response.status, text: () => response.text() }; + return { + status: response.status, + contentType: response.headers.get('content-type') ?? 'application/octet-stream', + text: () => response.text(), + bytes: async () => new Uint8Array(await response.arrayBuffer()), + }; }, openSocket(url, events, headers = {}) { const socket = new (WebSocket as unknown as NativeWebSocket)(url, undefined, { headers }); @@ -33,7 +40,13 @@ export function nativeCarrier(): HostCarrier { socket.onmessage = (event) => events.message(String(event.data)); socket.onerror = (event) => closed((event as { message?: string }).message ?? 'socket error'); socket.onclose = (event) => closed(event.reason); - return { send: (text) => socket.send(text), close: () => socket.close() }; + return { + get bufferedAmount() { + return socket.bufferedAmount; + }, + send: (text) => socket.send(text), + close: () => socket.close(), + }; }, }; } diff --git a/apps/mobile/src/host/native-memory-preview.ts b/apps/mobile/src/host/native-memory-preview.ts index a7ebacd788..8941238009 100644 --- a/apps/mobile/src/host/native-memory-preview.ts +++ b/apps/mobile/src/host/native-memory-preview.ts @@ -6,6 +6,12 @@ import type { MemoryPreviewIO } from './memory-preview'; export const nativeMemoryPreview: MemoryPreviewIO = { async fetch(source, signal) { + if (source.read) { + const result = await source.read(signal); + return new Response(Uint8Array.from(result.bytes), { + headers: { 'content-type': result.mime }, + }); + } return fetch(source.uri, { headers: source.headers, signal, diff --git a/apps/mobile/src/host/pairing.ts b/apps/mobile/src/host/pairing.ts index ac4a6cfb90..4c90046809 100644 --- a/apps/mobile/src/host/pairing.ts +++ b/apps/mobile/src/host/pairing.ts @@ -1,3 +1,14 @@ +import { noiseFingerprint, noisePublicKey } from '../../../../packages/core/src/devices/noise/xk'; +import { + ConnectRefused, + encryptedCarrier, + keyBytes, + keyHex, + openNoiseSocket, + secureRandom, +} from './encrypted-carrier'; +import { isConnectRoute } from './route'; +import type { HostEncryption } from './hosts'; import type { HostCarrier } from './carrier'; import { checkRoute } from './route'; import type { ConnectRefusal } from './connect-account'; @@ -6,6 +17,8 @@ import type { SavedHost } from './hosts'; export const DEVICE_PREFIX = '/botharness-device'; export interface PairingPayload { + enrollment?: { body: string; signature: string }; + encryption?: { protocol: 'connect-noise-xk-v1'; publicKey: string }; host: { id: string; name: string }; offer: string; routes: string[]; @@ -14,6 +27,9 @@ export interface PairingPayload { } export type PairingRefusal = + | 'host-key-required' + | 'host-key-confirmation-required' + | 'channel-authentication-failed' | 'link-invalid' | 'link-expired' | 'no-route' @@ -78,6 +94,16 @@ export function parsePairingLink(text: string, now = Date.now()): ParsedLink { offer: value['offer'], routes, expiresAt: value['expiresAt'], + ...(record(value['encryption'])['protocol'] === 'connect-noise-xk-v1' && + typeof record(value['encryption'])['publicKey'] === 'string' && + /^[0-9a-f]{64}$/u.test(String(record(value['encryption'])['publicKey'])) + ? { + encryption: { + protocol: 'connect-noise-xk-v1' as const, + publicKey: String(record(value['encryption'])['publicKey']), + }, + } + : {}), }, }; } @@ -89,9 +115,18 @@ export async function redeemPairing(options: { deviceName: string; carrier: HostCarrier; timeoutMs?: number; + onPending?: (deviceFingerprint: string) => void; + confirmFingerprint?: (fingerprint: string, hostName: string) => Promise; + signal?: AbortSignal; }): Promise { const { payload } = options; + if (payload.routes.length === 0 || payload.routes.some((route) => !checkRoute(route).ok)) + return { ok: false, code: 'no-route' }; + if (payload.enrollment && payload.routes.some((route) => !isConnectRoute(route))) + return { ok: false, code: 'no-route' }; + if (payload.encryption !== undefined) return redeemEncrypted(options); for (const route of payload.routes) { + if (isConnectRoute(route)) return { ok: false, code: 'host-key-required' }; let status: number; let body: Record; const controller = new AbortController(); @@ -145,30 +180,50 @@ export async function revokePairing(options: { carrier: HostCarrier; timeoutMs?: number; }): Promise<'revoked' | 'unreachable'> { - for (const route of options.host.routes) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000); - try { - const response = await options.carrier.request(`${route}${DEVICE_PREFIX}/unpair`, { - method: 'POST', - headers: { authorization: `Device ${options.host.credential}` }, - body: '{}', - signal: controller.signal, - }); - const body = record(JSON.parse(await response.text())); - if (response.status === 200 && body['revoked'] === true) return 'revoked'; - if ( - response.status === 401 && - (body['code'] === 'device-revoked' || body['code'] === 'device-unknown') - ) - return 'revoked'; - } catch { - continue; - } finally { - clearTimeout(timer); + const carrier = encryptedCarrier({ raw: options.carrier, ...options.host }); + try { + for (const route of options.host.routes) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000); + try { + const response = await carrier.request(`${route}${DEVICE_PREFIX}/unpair`, { + method: 'POST', + headers: { authorization: `Device ${options.host.credential}` }, + body: '{}', + signal: controller.signal, + }); + const body = record(JSON.parse(await response.text())); + if (response.status === 200 && body['revoked'] === true) return 'revoked'; + if ( + response.status === 401 && + (body['code'] === 'device-revoked' || body['code'] === 'device-unknown') + ) + return 'revoked'; + if (isConnectRoute(route)) return 'unreachable'; + } catch (error) { + if (isConnectRoute(route)) { + if (error instanceof ConnectRefused && error.code === 'device-revoked') return 'revoked'; + try { + await carrier.request(`${route}${DEVICE_PREFIX}/api/botharness/list`, { + method: 'POST', + body: '{}', + signal: AbortSignal.timeout(options.timeoutMs ?? 8000), + }); + } catch (probe) { + if (probe instanceof ConnectRefused && probe.code === 'device-revoked') + return 'revoked'; + } + return 'unreachable'; + } + continue; + } finally { + clearTimeout(timer); + } } + return 'unreachable'; + } finally { + carrier.close?.(); } - return 'unreachable'; } export async function removeDevice(options: { @@ -177,24 +232,33 @@ export async function removeDevice(options: { carrier: HostCarrier; timeoutMs?: number; }): Promise<'removed' | 'unreachable'> { - for (const route of options.host.routes) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000); - try { - const response = await options.carrier.request(`${route}${DEVICE_PREFIX}/devices/remove`, { - method: 'POST', - headers: { authorization: `Device ${options.host.credential}` }, - body: JSON.stringify({ deviceId: options.deviceId }), - signal: controller.signal, - }); - if (response.status === 200 || response.status === 404) return 'removed'; - } catch { - continue; - } finally { - clearTimeout(timer); + if (options.deviceId === options.host.deviceId) + return (await revokePairing(options)) === 'revoked' ? 'removed' : 'unreachable'; + const carrier = encryptedCarrier({ raw: options.carrier, ...options.host }); + try { + for (const route of options.host.routes) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000); + try { + const response = await carrier.request(`${route}${DEVICE_PREFIX}/devices/remove`, { + method: 'POST', + headers: { authorization: `Device ${options.host.credential}` }, + body: JSON.stringify({ deviceId: options.deviceId }), + signal: controller.signal, + }); + if (response.status === 200 || response.status === 404) return 'removed'; + if (isConnectRoute(route)) return 'unreachable'; + } catch { + if (isConnectRoute(route)) return 'unreachable'; + continue; + } finally { + clearTimeout(timer); + } } + return 'unreachable'; + } finally { + carrier.close?.(); } - return 'unreachable'; } export type PairingFailure = PairingRefusal | 'storage-failure' | 'cancelled' | 'unknown'; @@ -205,6 +269,8 @@ export async function pairHost(options: { deviceName: string; carrier: HostCarrier; save(host: SavedHost): Promise; + onPending?: (deviceFingerprint: string) => void; + confirmFingerprint?: (fingerprint: string, hostName: string) => Promise; telemetry: Pick; signal?: AbortSignal; }): Promise<{ ok: true } | { ok: false; code: PairingFailure }> { @@ -227,6 +293,11 @@ export async function pairHost(options: { payload: parsed.payload, deviceName: options.deviceName, carrier: options.carrier, + ...(options.onPending === undefined ? {} : { onPending: options.onPending }), + ...(options.confirmFingerprint === undefined + ? {} + : { confirmFingerprint: options.confirmFingerprint }), + ...(options.signal === undefined ? {} : { signal: options.signal }), }); if (options.signal?.aborted) return { ok: false, code: 'cancelled' }; if (!redeemed.ok) { @@ -248,3 +319,101 @@ export async function pairHost(options: { options.signal?.removeEventListener('abort', cancelled); } } + +async function redeemEncrypted(options: { + payload: PairingPayload; + deviceName: string; + carrier: HostCarrier; + signal?: AbortSignal; + onPending?: (deviceFingerprint: string) => void; + confirmFingerprint?: (fingerprint: string, hostName: string) => Promise; +}): Promise { + const { payload } = options; + const advertised = payload.encryption!; + if ( + !(await options.confirmFingerprint?.( + noiseFingerprint(keyBytes(advertised.publicKey)), + payload.host.name, + )) + ) + return { ok: false, code: 'host-key-confirmation-required' }; + if (options.signal?.aborted) return { ok: false, code: 'unreachable' }; + const encryption: HostEncryption = { + protocol: advertised.protocol, + hostKey: advertised.publicKey, + deviceKey: keyHex(secureRandom(options.carrier)), + }; + const route = payload.routes.find(isConnectRoute) ?? payload.routes[0]!; + return new Promise((resolve) => { + let done = false, + poll: ReturnType | undefined; + const finish = (result: Redemption) => { + if (done) return; + done = true; + clearTimeout(deadline); + clearTimeout(poll); + options.signal?.removeEventListener('abort', abort); + socket.close(); + resolve(result); + }; + const abort = () => finish({ ok: false, code: 'unreachable' }); + const socket = openNoiseSocket({ + origin: route, + hostId: payload.host.id, + encryption, + raw: options.carrier, + closed: () => finish({ ok: false, code: 'channel-authentication-failed' }), + message: (value) => { + if (value['type'] === 'pair-pending') { + options.onPending?.(noiseFingerprint(noisePublicKey(keyBytes(encryption.deviceKey)))); + poll = setTimeout(submit, 2000); + return; + } + if ( + value['type'] === 'paired' && + typeof value['credential'] === 'string' && + typeof value['deviceId'] === 'string' && + record(value['host'])['id'] === payload.host.id + ) + finish({ + ok: true, + host: { + hostId: payload.host.id, + hostName: payload.host.name, + routes: [route, ...payload.routes.filter((other) => other !== route)], + deviceId: value['deviceId'], + credential: value['credential'], + encryption, + }, + }); + else + finish({ + ok: false, + code: value['code'] === 'offer-expired' ? 'offer-expired' : 'offer-invalid', + }); + }, + }); + const submit = () => { + if (done) return; + try { + socket.send({ + type: 'pair', + offer: payload.offer, + name: options.deviceName, + verifier: payload.verifier, + enrollment: payload.enrollment, + }); + } catch { + finish({ ok: false, code: 'channel-authentication-failed' }); + } + }; + const deadline = setTimeout( + abort, + Math.max(1, Math.min(600_000, Date.parse(payload.expiresAt) - Date.now())), + ); + options.signal?.addEventListener('abort', abort, { once: true }); + void socket.ready.then(submit, () => + finish({ ok: false, code: 'channel-authentication-failed' }), + ); + }); +} diff --git a/apps/mobile/src/host/private-input.ts b/apps/mobile/src/host/private-input.ts new file mode 100644 index 0000000000..cfec793bd2 --- /dev/null +++ b/apps/mobile/src/host/private-input.ts @@ -0,0 +1,35 @@ +import { + privateRequestSchema, + type PrivateInputDeclaration, + type PrivateInputStatus, +} from '../../../../packages/core/src/credentials/contracts'; +import type { HostClient } from './client'; +import type { RequestRef } from './requests'; + +export async function loadPrivateInput( + client: HostClient, + ref: RequestRef, +): Promise { + const [{ page }, { status }] = await Promise.all([ + client.call<{ + page: { entries: { id: string; privateInputRequest?: PrivateInputDeclaration }[] }; + }>('channelTimeline', { + channelId: ref.channelId, + direction: 'around', + around: ref.messageId, + olderLimit: 0, + newerLimit: 0, + }), + client.call<{ status: PrivateInputStatus }>('privateInputStatus', { + channelId: ref.channelId, + messageId: ref.messageId, + }), + ]); + const request = page.entries.find((entry) => entry.id === ref.messageId)?.privateInputRequest; + const declaration = privateRequestSchema.parse({ + account: request?.account, + purpose: request?.purpose, + fields: request?.fields, + }); + return { ...declaration, status }; +} diff --git a/apps/mobile/src/host/push-actions.ts b/apps/mobile/src/host/push-actions.ts index 1e589a101d..13af6c5e43 100644 --- a/apps/mobile/src/host/push-actions.ts +++ b/apps/mobile/src/host/push-actions.ts @@ -1,3 +1,4 @@ +import { checkRoute, isConnectRoute } from './route'; import type { SavedHost } from './hosts'; import type { PushTarget } from './push'; import type { ApprovalChoice, DecisionResult, RequestRef } from './requests'; @@ -68,7 +69,9 @@ export async function decideFromPush( for (const route of host.routes) { try { return await decide(host, route, ref, outcome); - } catch {} + } catch { + if (!checkRoute(route).ok || isConnectRoute(route)) return 'failed'; + } } return 'failed'; } diff --git a/apps/mobile/src/host/push-routing.ts b/apps/mobile/src/host/push-routing.ts index 429f91dbba..5651d0aba5 100644 --- a/apps/mobile/src/host/push-routing.ts +++ b/apps/mobile/src/host/push-routing.ts @@ -37,16 +37,14 @@ export function usePushRouting(): RefCallback { hostSession.getState().hosts, target, action, - (host, route, ref, outcome) => - decideApproval( - createHostClient({ - routes: [route], - credential: host.credential, - carrier: nativeCarrier(), - }), - ref, - outcome, - ), + async (host, route, ref, outcome) => { + const client = createHostClient({ ...host, routes: [route], carrier: nativeCarrier() }); + try { + return await decideApproval(client, ref, outcome); + } finally { + client.stop(); + } + }, ); if (!(await hostSession.switchTo(target.hostId))) { router.navigate('/'); diff --git a/apps/mobile/src/host/push.ts b/apps/mobile/src/host/push.ts index fcd2baa0d7..84d92f1f3a 100644 --- a/apps/mobile/src/host/push.ts +++ b/apps/mobile/src/host/push.ts @@ -1,3 +1,5 @@ +import { isConnectRoute } from './route'; +import { encryptedCarrier } from './encrypted-carrier'; import type { HostCarrier } from './carrier'; import type { SavedHost } from './hosts'; import { DEVICE_PREFIX } from './pairing'; @@ -181,38 +183,46 @@ export function createHostPush(carrier: HostCarrier, timeoutMs = 10_000): HostPu path: string, body?: Record, ): Promise => { - for (const route of host.routes) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - let status: number; - let text: string; - try { - const response = await carrier.request(`${route}${DEVICE_PREFIX}/push${path}`, { - method: body === undefined ? 'GET' : 'POST', - headers: { authorization: `Device ${host.credential}` }, - signal: controller.signal, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - status = response.status; - text = await response.text(); - } catch { - continue; - } finally { - clearTimeout(timer); + const transport = encryptedCarrier({ raw: carrier, ...host }); + try { + for (const route of host.routes) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let status: number; + let text: string; + try { + const response = await transport.request(`${route}${DEVICE_PREFIX}/push${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: { authorization: `Device ${host.credential}` }, + signal: controller.signal, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + status = response.status; + text = await response.text(); + } catch (error) { + if (isConnectRoute(route)) throw error; + continue; + } finally { + clearTimeout(timer); + } + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + if (isConnectRoute(route)) throw error; + continue; + } + if (status === 200) return value; + const code = record(value)['code']; + if (typeof code === 'string' && status >= 400 && status < 500) + throw new PushRequestFailed(code as PushRefusal); + if (status === 502) throw new PushRequestFailed('push-send-failed'); + if (isConnectRoute(route)) throw new PushRequestFailed('unreachable'); } - let value: unknown; - try { - value = JSON.parse(text); - } catch { - continue; - } - if (status === 200) return value; - const code = record(value)['code']; - if (typeof code === 'string' && status >= 400 && status < 500) - throw new PushRequestFailed(code as PushRefusal); - if (status === 502) throw new PushRequestFailed('push-send-failed'); + throw new PushRequestFailed('unreachable'); + } finally { + transport.close?.(); } - throw new PushRequestFailed('unreachable'); }; return { diff --git a/apps/mobile/src/host/saved-host.web.ts b/apps/mobile/src/host/saved-host.web.ts index 78172f8f45..5049ed7c18 100644 --- a/apps/mobile/src/host/saved-host.web.ts +++ b/apps/mobile/src/host/saved-host.web.ts @@ -18,7 +18,9 @@ export async function loadSavedHosts(): Promise { } export async function saveHosts(state: SavedHosts): Promise { - if (state.hosts.length === 0) store()?.removeItem(KEY); - else store()?.setItem(KEY, JSON.stringify(state)); - store()?.removeItem(SINGLE_KEY); + const storage = store(); + if (!storage) throw new Error('storage-unavailable'); + if (state.hosts.length === 0) storage.removeItem(KEY); + else storage.setItem(KEY, JSON.stringify(state)); + storage.removeItem(SINGLE_KEY); } diff --git a/apps/mobile/src/host/share-attachment.ts b/apps/mobile/src/host/share-attachment.ts index 3485b6a183..855f272333 100644 --- a/apps/mobile/src/host/share-attachment.ts +++ b/apps/mobile/src/host/share-attachment.ts @@ -19,12 +19,19 @@ export async function shareAttachment(attachment: MessageAttachment): Promise b outcome: member('success', 'refused', 'failed', 'cancelled'), reason: member( 'none', + 'host-key-required', + 'host-key-confirmation-required', + 'channel-authentication-failed', 'link-invalid', 'link-expired', 'no-route', diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 811539ad9b..aeaab356d3 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -237,6 +237,7 @@ const RequestCard = memo(function RequestCard({ if (card.kind === 'private-input') return ( - {t.privateInputNote} + {t.privateInputSecureNote} + {target ? ( + + ); + return ( + + + {primary ? action(primary, 'borderedProminent') : null} + {action(secondary, 'bordered')} + {details ? action(details, 'plain') : null} + + + ); +} diff --git a/apps/mobile/src/ui/primitives/pairing-actions.tsx b/apps/mobile/src/ui/primitives/pairing-actions.tsx new file mode 100644 index 0000000000..7067da21e9 --- /dev/null +++ b/apps/mobile/src/ui/primitives/pairing-actions.tsx @@ -0,0 +1,33 @@ +import { Button as NativeButton, Column, Host } from '@expo/ui'; +import { useState } from 'react'; + +import { useDesignTheme } from '../design/theme'; +import { space } from '../design/tokens'; + +export type PairingAction = { label: string; testID: string; onPress(): void }; +export type PairingActionsProps = { + primary?: PairingAction | undefined; + secondary: PairingAction; + details?: PairingAction | undefined; +}; + +export function PairingActions({ primary, secondary, details }: PairingActionsProps) { + const theme = useDesignTheme(); + const [width, setWidth] = useState(); + return ( + setWidth(event.nativeEvent.layout.width)} + > + + {primary ? : null} + + {details ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/ui/primitives/resource-image.tsx b/apps/mobile/src/ui/primitives/resource-image.tsx new file mode 100644 index 0000000000..f5c349af61 --- /dev/null +++ b/apps/mobile/src/ui/primitives/resource-image.tsx @@ -0,0 +1,28 @@ +import { useState, type ComponentProps } from 'react'; +import { Image } from 'expo-image'; +import { useMounted } from '../mounted'; +import type { RemoteSource } from '../../host/client'; +import { encodeBase64 } from '../../host/encrypted-carrier'; + +export function ResourceImage({ + source, + ...props +}: Omit, 'source'> & { source: RemoteSource }) { + const [resolved, setResolved] = useState<{ uri: string; headers: Record }>(); + const mount = useMounted(() => { + if (!source.read) return; + const controller = new AbortController(); + setResolved(undefined); + void source.read(controller.signal).then( + ({ bytes, mime }) => { + if (!controller.signal.aborted) + setResolved({ uri: `data:${mime};base64,${encodeBase64(bytes)}`, headers: {} }); + }, + () => { + if (!controller.signal.aborted) props.onError?.({ error: 'resource-unavailable' }); + }, + ); + return () => controller.abort(); + }, [source.uri]); + return ; +} diff --git a/apps/mobile/src/ui/private-input-form.tsx b/apps/mobile/src/ui/private-input-form.tsx new file mode 100644 index 0000000000..9dfe33c5b1 --- /dev/null +++ b/apps/mobile/src/ui/private-input-form.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react'; +import { View, type ViewInstance } from 'react-native'; +import type { HostClient } from '../host/client'; +import { loadPrivateInput } from '../host/private-input'; +import type { RequestRef } from '../host/requests'; +import { useMounted } from './mounted'; +import { space } from './design/tokens'; +import { Button } from './primitives/button'; +import { KeyboardFormScroll } from './primitives/keyboard-form'; +import { TextField } from './primitives/text-field'; +import { Text } from './primitives/text'; +import { t } from './strings'; + +type Request = Awaited>; +export function PrivateInputForm({ + client, + target, + after, +}: { + client: HostClient; + target: RequestRef; + after?(): void; +}) { + const [request, setRequest] = useState(); + const [values, setValues] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<'refresh' | 'save'>(); + const terminal = useRef(false); + const [retry, setRetry] = useState(0); + const active = useRef(false), + submitting = useRef(false); + const mount = useMounted(() => { + active.current = true; + terminal.current = false; + let cancelled = false; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const next = await loadPrivateInput(client, target); + if (cancelled || terminal.current) return; + setError(undefined); + setRequest(next); + if (next.status !== 'pending') setValues({}); + else timer = setTimeout(() => void poll(), 2500); + } catch { + if (!cancelled && !terminal.current) setError('refresh'); + } + }; + void poll(); + return () => { + cancelled = true; + active.current = false; + clearTimeout(timer); + }; + }, [client, target.channelId, target.messageId, retry]); + const submit = async (cancel: boolean) => { + if (submitting.current || request?.status !== 'pending') return; + submitting.current = true; + setBusy(true); + setError(undefined); + try { + const result = await client.call<{ accepted: boolean }>( + cancel ? 'privateInputCancel' : 'privateInputSubmit', + { + channelId: target.channelId, + messageId: target.messageId, + ...(cancel ? {} : { values }), + }, + ); + if (!active.current) return; + if (result.accepted) { + terminal.current = true; + setValues({}); + setRequest({ ...request, status: cancel ? 'cancelled' : 'configured' }); + after?.(); + return; + } + const next = await loadPrivateInput(client, target); + if (!active.current) return; + setRequest(next); + if (result.accepted || next.status !== 'pending') { + setValues({}); + after?.(); + } else setError('save'); + } catch { + if (active.current) setError('save'); + } finally { + submitting.current = false; + if (active.current) setBusy(false); + } + }; + const allowed = client.secretInputAllowed?.() !== false; + return ( + + + {request === undefined ? ( + {error ? t.privateInputEmpty : t.loading} + ) : request.status !== 'pending' ? ( + {t.privateInputDone} + ) : ( + <> + {request.account} + {request.purpose} + + {allowed ? t.privateInputSecureNote : t.pairRefused['host-key-required']} + + {allowed + ? request.fields.map((field) => ( + + + {field.label} + {field.required ? ' *' : ''} + + + setValues((previous) => ({ ...previous, [field.id]: value })) + } + editable={!busy} + /> + + )) + : null} + + ), +})); +const mocks = vi.hoisted(() => ({ pairHost: vi.fn(), dismissTo: vi.fn() })); +vi.mock('expo-router', () => ({ useRouter: () => ({ dismissTo: mocks.dismissTo }) })); +vi.mock('../src/host/host-session', () => ({ hostSession: { pair: vi.fn() } })); +vi.mock('../src/host/native-carrier', () => ({ nativeCarrier: () => ({}) })); +vi.mock('../src/host/native-connect-account', () => ({ connectAccount: {} })); +vi.mock('../src/host/native-host-look', () => ({ hostLooks: {} })); +vi.mock('../src/telemetry/native', () => ({ mobileTelemetry: {} })); +vi.mock('../src/host/pairing', () => ({ pairHost: mocks.pairHost })); + +import type { pairHost } from '../src/host/pairing'; +import { PairingVerification } from '../src/ui/shell/pairing-verification'; +import { usePairing } from '../src/ui/shell/use-pairing'; +import { t } from '../src/ui/strings'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const fingerprint = + 'abcd 0123 4567 89ab cdef 0123 4567 89ab cdef 0123 4567 89ab cdef 0123 4567 89ab'; +let pairing: ReturnType; +let root: Root; +let view: HTMLElement; +function Harness() { + pairing = usePairing(); + return pairing.busy ? : null; +} +afterEach(() => { + act(() => { + pairing?.abort(); + root?.unmount(); + }); + view?.remove(); + vi.clearAllMocks(); +}); +async function setup() { + view = document.createElement('div'); + document.body.append(view); + root = createRoot(view); + await act(async () => root.render()); + let resolvePair: (value: { ok: true }) => void; + const approved = new Promise<{ ok: true }>((resolve) => { + resolvePair = resolve; + }); + const confirmed = vi.fn(); + mocks.pairHost.mockImplementation(async (options: Parameters[0]) => { + const matches = await options.confirmFingerprint!(fingerprint, 'Studio'); + if (!matches) return { ok: false, code: 'host-key-confirmation-required' }; + confirmed(); + options.onPending!(fingerprint); + return Promise.race([ + approved, + new Promise((resolve) => { + options.signal!.addEventListener('abort', () => resolve({ ok: false, code: 'cancelled' }), { + once: true, + }); + }), + ]); + }); + await act(async () => { + void pairing.pair('link', 'paste'); + }); + return { confirmed, approve: async () => act(async () => resolvePair!({ ok: true })) }; +} +async function click(id: string) { + await act(async () => view.querySelector(`[data-testid="${id}"]`)!.click()); +} +it('shows the complete fingerprint and optional explanation without granting trust', async () => { + const { confirmed, approve } = await setup(); + expect(view.querySelector('[data-testid="pairing-fingerprint"]')!.textContent).toBe(fingerprint); + expect(view.textContent).toContain('Studio'); + expect(view.textContent).not.toContain(t.pairingVerification.explanation); + await click('pairing-fingerprint-details'); + expect(view.textContent).toContain(t.pairingVerification.explanation); + expect(confirmed).not.toHaveBeenCalled(); + await click('pairing-fingerprint-match'); + expect(confirmed).toHaveBeenCalledTimes(1); + expect(view.textContent).toContain(t.pairingVerification.waiting); + expect(view.querySelector('[data-testid="pairing-fingerprint-match"]')).toBeNull(); + expect(mocks.dismissTo).not.toHaveBeenCalled(); + await approve(); + expect(mocks.dismissTo).toHaveBeenCalledWith('/'); +}); +it('refuses a mismatch without proceeding to device approval', async () => { + const { confirmed } = await setup(); + await click('pairing-fingerprint-cancel'); + expect(confirmed).not.toHaveBeenCalled(); + expect(pairing.error).toBe('host-key-confirmation-required'); + expect(pairing.busy).toBe(false); + expect(mocks.dismissTo).not.toHaveBeenCalled(); +}); +it('settles a dismissed screen and ignores its stale confirmation', async () => { + const { confirmed } = await setup(); + const stale = pairing.verification!.confirm; + await act(async () => pairing.abort()); + expect(pairing.busy).toBe(false); + expect(pairing.verification).toBeUndefined(); + await act(async () => stale(true)); + expect(confirmed).not.toHaveBeenCalled(); + expect(mocks.dismissTo).not.toHaveBeenCalled(); + await act(async () => { + void pairing.pair('next link', 'paste'); + }); + expect(pairing.verification).toBeDefined(); +}); + +it('cancels the pending phone approval without saving or navigating', async () => { + await setup(); + await click('pairing-fingerprint-match'); + expect(pairing.fingerprint).toBe(fingerprint); + await click('pairing-fingerprint-cancel'); + expect(pairing.busy).toBe(false); + expect(pairing.fingerprint).toBeUndefined(); + expect(pairing.error).toBeUndefined(); + expect(mocks.dismissTo).not.toHaveBeenCalled(); +}); diff --git a/changes/2542-connect-noise-xk.md b/changes/2542-connect-noise-xk.md new file mode 100644 index 0000000000..b4832f00df --- /dev/null +++ b/changes/2542-connect-noise-xk.md @@ -0,0 +1,11 @@ +--- +section: Security +--- + +## en + +- Connect encrypts messages, attachments, approvals and private inputs with Noise XK after independent Host fingerprint comparison and explicit device-key approval. Untrusted enrollment addresses are refused, notification approvals stop after Connect errors, and notification settings use the encrypted channel. Source-Host attachment reads stay on the encrypted path, and late cleanup callbacks cannot close replacement requests or streams. Pairing shows concise native verification steps with complete grouped fingerprints and optional explanations. The phone gains a native private-input form, and iOS pairing returns to usable Chats without a cold restart, including immediately after dismissing the first-use notice; older Connect pairings require pairing again, and Browser/Computer viewers remain unavailable on Connect pending qualification ([#2542](https://github.com/BotHarness/DeepSeekBot/issues/2542)). + +## zh + +- Connect 在独立核对 Host 指纹并明确批准设备公钥后,通过 Noise XK 加密消息、附件、审批与私密输入。不可信的配对地址会被拒绝,通知审批遇到 Connect 错误会停止,通知设置通过加密通道读取。来源 Host 的附件读取保持在加密通道,旧请求或流的延迟清理不会影响替代实例。配对改用简洁的原生分步界面,完整指纹分组显示,说明按需展开。手机新增原生私密输入表单,iOS 配对后可直接使用聊天,无需冷启动,即使刚关闭首次使用说明也不阻塞点击;旧 Connect 配对需要重新配对,Browser/Computer 查看器在 Connect 上暂不可用,等待单独验收([#2542](https://github.com/BotHarness/DeepSeekBot/issues/2542))。 diff --git a/changes/2542-ui-design-standard.md b/changes/2542-ui-design-standard.md new file mode 100644 index 0000000000..04ebaf9a4c --- /dev/null +++ b/changes/2542-ui-design-standard.md @@ -0,0 +1,11 @@ +--- +section: Documentation +--- + +## en + +- Established a shared Web, desktop and native-mobile [UI design standard](../docs/agents/ui-design.md): communicate through layout, icons and interaction, with concise optional explanations while preserving accessibility and informed security decisions ([#2542](https://github.com/BotHarness/DeepSeekBot/issues/2542)). + +## zh + +- 明确 Web、桌面和原生手机端共同遵循的 [UI 设计规范](../docs/agents/ui-design.md):优先用布局、图标和交互表达,说明简短并按需展示,同时保留无障碍支持与知情安全决策所需信息([#2542](https://github.com/BotHarness/DeepSeekBot/issues/2542))。 diff --git a/docs/adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md b/docs/adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md index 91cc348823..7fea464af0 100644 --- a/docs/adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md +++ b/docs/adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md @@ -56,3 +56,7 @@ One isolated Host and the iOS Simulator, against a test Cloudflare account and a - We publish and update one pinned `cloudflared` npm package per platform. - `deepseekbot connect …` CLI commands ship with the Devices UI (CLI parity). - Store builds keep `EXPO_PUBLIC_DEEPSEEKBOT_CLOUD` separate from Connect, so Connect can ship in test builds without exposing TOB cloud Channels. + +## Amendment — ADR-0244 (2026-10-22) + +[ADR-0244](0244-connect-encrypts-device-traffic-against-pinned-host-keys.md) supersedes account-only automatic enrollment and plaintext Connect application transport. The relay returns signed account enrollment metadata and a public Host-key hint; the client independently checks the fingerprint. The Host creates/redeems the offer inside Noise only after exact device-key approval. The browser uses its trusted serving Host's origin-local key store. The full phone WebView remains removed on every route; private input now uses the native form. Viewer signaling/media on Connect are unavailable until separately qualified. See the [wire contract](../architecture/connect-channel-v1.md); source implementation does not establish native or deployed-network qualification. diff --git a/docs/adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md b/docs/adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md index 20b07a11e3..15cd765d1b 100644 --- a/docs/adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md +++ b/docs/adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md @@ -26,3 +26,7 @@ We decided that **the browser pairs a Remote Host exactly like a phone does, and - The Host look (icon and colour) is still stored per phone, so the Web Client uses a generic icon. - Terminology follows ADR-0243: This Host and Remote Host, 本机 and 远程 in Chinese; "Computer" stays reserved for the PersonaBots' shared desktop. - DSH Desktop is not qualified by this slice; its storage and origin rules are verified separately (#2180). + +## Amendment — ADR-0244 (2026-10-22) + +[ADR-0244](0244-connect-encrypts-device-traffic-against-pinned-host-keys.md) supersedes account-only automatic enrollment and plaintext Connect application transport. The relay returns signed account enrollment metadata and a public Host-key hint; the client independently checks the fingerprint. The Host creates/redeems the offer inside Noise only after exact device-key approval. The browser uses its trusted serving Host's origin-local key store. The full phone WebView remains removed on every route; private input now uses the native form. Viewer signaling/media on Connect are unavailable until separately qualified. See the [wire contract](../architecture/connect-channel-v1.md); source implementation does not establish native or deployed-network qualification. diff --git a/docs/adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md b/docs/adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md index 6123ed61b3..0be22fe446 100644 --- a/docs/adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md +++ b/docs/adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md @@ -1,13 +1,13 @@ --- -Status: Proposed — core choices confirmed; final ADR approval pending +Status: Accepted — approved by Vain on 2026-10-22; Track B implementation in progress Date: 2026-10-22 Issues: [#2542](https://github.com/BotHarness/DeepSeekBot/issues/2542), [#2541](https://github.com/BotHarness/DeepSeekBot/issues/2541) -Amends: ADR-0241 and ADR-0242 if accepted +Amends: ADR-0241 and ADR-0242 --- # Connect encrypts device traffic against pinned Host keys -Reported by Vain. On 2026-10-21 Vain rejected Cloudflare's ability to read Connect traffic. Connect has not shipped, so its protocol and pairing experience can change. This document proposes a replacement for ADR-0241's plaintext application channel; it does not approve or implement encryption. ADR-0199 remains the device-authority owner, ADR-0242 remains the browser pairing owner, and ADR-0243 remains the Host Feed contract. +Reported by Vain. On 2026-10-21 Vain rejected Cloudflare's ability to read Connect traffic. Connect has not shipped, so its protocol and pairing experience can change. Vain approved this ADR and its stacked implementation above Track A PR #2546 on 2026-10-22. It replaces ADR-0241's plaintext application channel; acceptance of this design is not evidence of runtime or native qualification. ADR-0199 remains the device-authority owner, ADR-0242 remains the browser pairing owner, and ADR-0243 remains the Host Feed contract. ## Decisions confirmed by Vain on 2026-10-22 @@ -19,9 +19,9 @@ Reported by Vain. On 2026-10-21 Vain rejected Cloudflare's ability to read Conne | Full client in the phone WebView | Remove the full WebView client and its entry points on every route; do not bundle it or tunnel it | A native bridge works only if it also authenticates every executable page/resource and excludes remote code. A bridge under a page still loaded through Cloudflare is insufficient. Alternatively, disable the Connect WebView and explicitly list unavailable screens. | | Capability and downgrade | Pin the encryption requirement with the Host key; fail closed on missing/unsupported capability | No plaintext retry, including after timeout, reinstall, route changes or key mismatch. Old test clients must upgrade or use a separately trusted route. | -Vain confirmed Noise XK, first-pair fingerprint comparison, explicit approval of new devices, and removal of the full WebView client. The no-downgrade requirement comes from the original handoff. The complete protocol specification and threat-model details below still await final ADR approval; no encryption implementation is authorized by these individual choices. Vain also confirmed that DSH upstream does not accept external PRs: use BotHarness-owned adapters over supported native APIs, not an upstream patch. +Vain confirmed Noise XK, first-pair fingerprint comparison, explicit approval of new devices, and removal of the full WebView client. The no-downgrade requirement comes from the original handoff. Vain subsequently approved the complete Noise XK implementation through the annotated handoff and invoked the implement workflow. The native, adversarial, independent-review and Human-QA gates below remain separate from that authorization. Vain also confirmed that DSH upstream does not accept external PRs: use BotHarness-owned adapters over supported native APIs, not an upstream patch. -## Proposed trust boundary +## Trust boundary Cloudflare Tunnel remains an outbound connectivity carrier. WorkOS and the relay discover Hosts and deliver pairing requests; neither can grant device authority or select a trusted Host key. The application-defined Connect Channel terminates inside the phone/browser and the Host process, before the existing Device Gateway admits a request. The Host's Device Pairing module remains the single durable authority for device membership and revocation. @@ -76,7 +76,7 @@ The removal decision is confirmed. The temporary loss of the following operation | Other Host-only management reached through the full client | Full-client escape hatch removed | Host client remains the management surface; inventory and approve any additional phone workflow before claiming mobile parity. | | PersonaBot Browser/Computer HTML viewers under `/botharness-device/web/` | HTML, cookie session, mux and viewer socket registrations removed | Native viewers are separate. Carry signaling and all fallback resources over E2EE, or explicitly disable unsupported Connect viewers. Do not claim viewer confidentiality from chat encryption. | -ADR-0242's browser pairing and per-device authority remain. If this proposal is accepted, its plaintext credential redemption and stream-ticket transport on Connect are replaced by the authenticated encrypted channel. Tailscale and loopback retain their current contracts. Browser code must come from a trusted serving Host, as described above. +ADR-0242's browser pairing and per-device authority remain. Its plaintext credential redemption and stream-ticket transport on Connect are replaced by the authenticated encrypted channel. Tailscale and loopback retain their current contracts. Browser code must come from a trusted serving Host, as described above. ## Threat model diff --git a/docs/agents/ui-copy.md b/docs/agents/ui-copy.md index 656510e289..e4b5487437 100644 --- a/docs/agents/ui-copy.md +++ b/docs/agents/ui-copy.md @@ -2,6 +2,10 @@ Use this guide when you write or change text a person reads in the product: the web client, the desktop app and the mobile native app. It covers labels, buttons, placeholders, empty states, errors, notifications, permission prompts and the small dimmed hint, footer and caption lines under controls. English and Chinese follow the same rules. +## Design before copy + +Apply the shared [UI design standard](ui-design.md) ([中文](ui-design.zh.md)) before adding explanatory text. Layout, icons, controls and interaction carry the task; descriptions supplement necessary facts. Keep meaningful labels, accessibility names and decision-critical consequences visible. + ## Names - Call a PersonaBot **Bot** in the UI: "New Bot", "Search Bots and Channels", "搜索 Bot 和频道". `PersonaBot` stays the domain term in [CONTEXT.md](../../CONTEXT.md), code, ADRs and developer docs; only the visible text says Bot. diff --git a/docs/agents/ui-design.md b/docs/agents/ui-design.md new file mode 100644 index 0000000000..acac70e5ae --- /dev/null +++ b/docs/agents/ui-design.md @@ -0,0 +1,37 @@ +# UI design standard + +[中文](ui-design.zh.md) + +This is the shared design standard for every new or changed product interface: Web, desktop and native mobile. It also governs design reviews. Platform guides specify the components; [UI copy](ui-copy.md) specifies wording. Existing screens adopt this standard when touched; this document does not claim a completed product-wide redesign. + +**Use layout, icons and interaction to explain the interface. Keep it simple.** A person should recognize the object, current state and next action from the screen's structure and controls. Labels name things and actions; descriptive prose supplies only information the interface cannot communicate clearly and safely. + +## Design rules + +1. **Structure before explanation.** Use hierarchy, proximity, spacing and grouping to show what belongs together and what matters now. If a paragraph is needed to explain where to look or which control to use, revise the layout or control first. A supporting description is optional, not a required row decoration. +2. **Recognizable controls and icons.** Use the owning platform or shell's components and familiar symbols. On mobile, follow the native component rules in [Mobile design](../../apps/mobile/DESIGN.md); Web and desktop follow [DSH UI](../../.agents/skills/dsh-ui/SKILL.md). Icons help identify objects, actions and states. Pair unfamiliar or consequential actions with short visible labels; every icon-only action needs an accessible name. Colour alone must not carry meaning. +3. **Let the flow explain the task.** Show the current decision and its relevant information together. Give the primary action clear emphasis, acknowledge input immediately, and show progress, success or a recoverable failure where the action happens. Use focused steps for genuinely sequential decisions; keep a simple task in one place. +4. **Reveal detail when it is useful.** Keep secondary explanations, background and diagnostics behind a clearly labelled disclosure or help entry. Keep information needed for the current decision visible beside its action. Do not add a disclosure just to hide essential instructions or consequences. +5. **Retain essential text and facts.** Short labels, units, current values, permission scope, privacy limits, irreversible consequences and actionable errors remain explicit. Security comparisons must expose the complete required values before confirmation. Simplicity never means truncating a fingerprint, silently choosing for the user or weakening consent. +6. **Keep the design usable across modes.** Preserve reading order, keyboard/focus access, screen-reader names and reachable actions. Allow wrapping and scrolling at large text sizes. Apply the same hierarchy in both themes and maintained languages; use each platform's controls rather than copying another platform's appearance. + +## Examples + +| Situation | What communicates it | Text that remains | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Pair a computer | Computer identity, grouped fingerprint card, one verification step, then a separate phone-approval step | Full fingerprints, a short comparison instruction, explicit match/cancel actions; explanation available on demand | +| Choose a setting | Grouped row, current value and the appropriate switch, picker or segmented control | Name and value; a hint only for a consequence the control cannot convey | +| Save or retry | Immediate pending state, visible success, or an error next to the affected action | Specific action label and a brief recovery instruction | +| Grant access or remove something | Named target and scope beside the confirmation, clear primary/secondary actions | Permission scope, irreversible consequences and meaningful cancel/confirm labels | + +## Review gate + +For each changed flow, reviewers verify: + +- The object, current state and next action are recognizable without reading a block of explanatory prose. +- Every description adds a needed fact; layout, controls and feedback carry the interaction itself. +- Details are optional, while the facts required for an informed decision remain visible. +- Icons, labels and accessibility names convey the same meaning; no meaning depends on colour alone. +- The flow works with the platform's input methods, large text, both themes and maintained languages. Record untested platforms and accessibility modes separately. + +Use the relevant platform's runtime verification and PR evidence requirements. A shorter paragraph or an attractive screenshot alone does not demonstrate a usable interaction. diff --git a/docs/agents/ui-design.zh.md b/docs/agents/ui-design.zh.md new file mode 100644 index 0000000000..04bf61866b --- /dev/null +++ b/docs/agents/ui-design.zh.md @@ -0,0 +1,37 @@ +# UI 设计规范 + +[English](ui-design.md) + +这是 Web、桌面和原生 App 的共同设计规范,适用于所有新增、修改的界面及设计审查。平台指引规定组件选择,[UI 文案规范](ui-copy.md)规定措辞。既有页面在修改时遵循本规范;本文不代表已完成全产品改造。 + +**用布局、图标和交互表达,让界面本身告诉用户怎么用。Keep it simple。** 用户应能从结构和控件看出当前对象、状态和下一步。标签说明名称与动作,描述文字只补充界面无法清晰、安全表达的必要信息。 + +## 设计规则 + +1. **先设计结构,再补充说明。** 用层级、距离、留白和分组表达关系与重点。如果需要一段话解释该看哪里、该用哪个控件,先调整布局或控件。辅助描述按需出现,每行不必固定配一句说明。 +2. **使用熟悉的组件和图标。** 遵循所在平台或应用外壳的组件规范。手机遵循[移动端设计](../../apps/mobile/DESIGN.md)的原生组件规则;Web 和桌面遵循 [DSH UI](../../.agents/skills/dsh-ui/SKILL.md)。图标帮助识别对象、动作和状态。不熟悉或影响重要的操作保留简短可见标签;纯图标操作必须有无障碍名称。含义不能仅靠颜色表达。 +3. **让流程解释任务。** 将当前需要决定的事及相关信息放在一起,突出主要操作,立即回应输入,并就地显示进度、成功或可恢复的失败。确实需要先后决策时才分步,简单任务在同一处完成。 +4. **按需展示细节。** 次要说明、背景和诊断信息放在名称明确的展开项或帮助入口中。当前决策需要的信息直接放在操作旁边。不能为了简洁而把必要指引或后果藏起来。 +5. **保留必要文字与事实。** 简短标签、单位、当前值、权限范围、隐私边界、不可逆后果和可操作的错误信息仍需明确。安全核对必须在确认前展示完整必要值。简洁不意味着截断指纹、替用户决定或削弱知情同意。 +6. **不同使用方式下都可操作。** 保留清晰阅读顺序、键盘与焦点操作、屏幕阅读器名称和可达按钮。大字号允许换行与滚动。明暗主题及维护的语言遵循同一信息层级,各平台使用自己的组件和外观。 + +## 示例 + +| 场景 | 由界面表达 | 保留的文字 | +| ---------- | ------------------------------------------------------------ | -------------------------------------------------------------- | +| 配对电脑 | 电脑身份、分组指纹卡片、聚焦的核对步骤,随后进入手机批准步骤 | 完整指纹、简短核对指引、明确的一致/取消操作;详细说明按需展开 | +| 选择设置 | 分组行、当前值及合适的开关、选择器或分段控件 | 名称和值;只有控件无法表达的后果才加提示 | +| 保存或重试 | 立即显示处理中,成功可见,失败在相关操作旁呈现 | 具体动作名称和简短恢复指引 | +| 授权或移除 | 确认位置展示对象与范围,主次操作明确 | 权限范围、不可逆后果,以及含义明确的取消/确认标签 | + +## 审查标准 + +每个改动流程都要检查: + +- 不读大段说明,也能识别对象、当前状态与下一步。 +- 每句描述都提供必要事实,交互本身由布局、控件和反馈表达。 +- 次要细节可以按需查看,知情决策所需事实直接可见。 +- 图标、标签与无障碍名称含义一致,信息不只依赖颜色。 +- 在平台输入方式、大字号、明暗主题及维护的语言下可以完成任务。未验证的平台和无障碍模式单独记录。 + +按相应平台要求做运行验证,并提供 PR 证据。缩短一段说明或截一张漂亮图片,本身不能证明交互可用。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 5672be9e32..758e5590dd 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -494,7 +494,7 @@ Orchestrator 的应用定义 channel_list 工具从当前 PersonaBot 的 Session [ADR-0199](../adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md) 已由 [#1652](https://github.com/BotHarness/DeepSeekBot/issues/1652) 实现:配对的手机不持有任何 DSH 凭据。应用定义的 Device Pairing 模块(`packages/core/src/devices/`)经 DSH 公开的 `webServer.register`/`registerUpgrade` 在同一 `dsh web` 监听器上提供设备网关:设备在 BotHarness 前缀下以 `Authorization: Device .` 调用与 Web Client 相同的 unary 信封和 Remote stream mux 帧;模块每次按运维数据库中的设备记录(只存哈希)校验并更新最近使用时间,再用 Host 内存中经 `connection.authenticatedUrl()` 自行换取的 loopback cookie 转发至原生 `/api` 与 `/api/remote.mux`。DSH 签名 cookie 的密钥本就跨 Host 重启保留,但 cookie 不标识设备,且 stream 升级不经过 `connection/request`,故不能把 cookie 交给设备。撤销立即拒绝后续请求(编码 401:`device-revoked`/`device-unknown`/`device-credential-missing`),并关闭该设备已打开的 stream socket。配对邀请、列表与撤销只在已认证的 `/api` carrier 上提供给 Human:Web Client「设置 → 设备」显示一次性、10 分钟有效的二维码(即 `deepseekbot://pair?p=…` 深链接,携带 Host 标识与按序路由),列出设备的配对与最近使用时间,并可撤销。见 [#1644](https://github.com/BotHarness/DeepSeekBot/issues/1644)。 -托管 Connect(目标状态,尚未实现):[ADR-0241](../adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md)([#2387](https://github.com/BotHarness/DeepSeekBot/issues/2387))在路由列表里增加一条 Connect 路由,顺序为 局域网 → Tailscale HTTPS → Connect。账号复用 TOB 的 WorkOS 环境,只用于发现 Host 和经纪一次性配对凭据;本仓库新建的中继 Worker 为每台开启 Connect 的 Host 代管一条 Cloudflare Tunnel,ingress 只放行 `/botharness-device/*`,应用流量不经过 Worker。手机登录后无需在电脑上确认即可配对,新设备会在电脑和其他手机上提示并可一键吊销。先在中国大陆以外实现并验收,大陆可达性之后另找测试者验证,不阻塞实现。 +托管 Connect 在既有路由列表增加 Cloudflare Tunnel。WorkOS 与中继只负责发现 Host、签发绑定账号和 verifier 的配对请求;它们不能授予设备权限。依照 [ADR-0244](../adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md),手机或可信本机浏览器先独立核对 Host 指纹,再由 Host 批准准确的设备公钥。Connect origin 只开放公开身份元数据及加密通道,不接受明文应用调用。大陆可达性、原生设备和实际云端发布分别验收。 ### 手机推送 @@ -1219,8 +1219,12 @@ window zoom. It keeps the native Menu renderer and keyboard owner inside the own converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport and returns focus after dismissal. No second shortcut router or Panel state is introduced. -### Connect secret admission and full WebView removal (#2541) +### Connect encryption and full WebView removal (#2541, #2542) -The application-defined Device Gateway owns Connect admission before native DSH Typert/API Gateway dispatch. Its cloudflared-only origin overwrites caller route headers with a process-local capability for HTTP and upgrade. The gateway strips untrusted tags on other routes and uses one shared secret-method policy for unary and mux opens. A bounded WebSocket adapter forwards allowed streams to native dispatch and closes both sides on device revocation. The shared browser/phone Host client refuses known secret methods before serialization; remote private input hides fields and explains the trusted-route requirement. This is an interim restriction, not confidentiality for general traffic or protection against a modified client sending plaintext to Cloudflare. +The application-defined Connect Channel uses the fixed Noise XK profile and authenticated, bounded frames defined in [Connect Channel v1](connect-channel-v1.md). The Host identity lives in the native Credentials Provider; the existing Device Pairing authority persists approved device public keys and closes their channels on revocation. A successful handshake alone grants no application access. Account relay requests and QR offers are redeemed only inside the channel after independent Host fingerprint comparison and exact device-key approval. -The phone's full Host WebView, navigation entry points and gateway HTML/cookie/socket proxy are removed on every route. Native viewers remain separate. Local Host and Tailscale credential entry retain the same native owner checks. [The audit](../research/2026-10-22-connect-secret-safety-audit.md) records coverage and limitations. [ADR-0244](../adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md) proposes Noise XK, pinned Host keys and explicit new-device confirmation; its complete protocol still awaits approval and no encryption is implemented. +The cloudflared-only origin accepts `/botharness-device/channel` and refuses legacy application HTTP, stream tickets and WebView paths. The shared phone/browser Host client carries unary RPCs, logical `remote.mux`, uploads, downloads, avatars, private input and push registration inside ciphertext. The encrypted adapter forwards to the existing Device Gateway on loopback; native DSH Typert/API Gateway remains the sole dispatcher. Loopback/Tailscale retain their contract. Legacy Connect pairings without pins fail closed and must be paired again; no failure clears a pin or retries plaintext through Connect. + +Native private input uses masked fields and the existing credential owner; values stay outside chat drafts, messages and telemetry. The phone's full Host WebView remains removed on every route. Browser/Computer viewers are explicitly unavailable on Connect pending their own media/signaling qualification; the Host and Tailscale remain available. The browser requires independently trusted serving code and origin-local key storage; it does not claim hardware protection. + +[ADR-0244](../adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md) is approved. Its implementation, protocol vectors, runtime evidence, native acceptance, independent security review and deployment are separate gates; a green source test is not proof of a real phone through Cloudflare. The [Track A audit](../research/2026-10-22-connect-secret-safety-audit.md) remains historical evidence for the interim refusal. diff --git a/docs/architecture/connect-channel-v1.md b/docs/architecture/connect-channel-v1.md new file mode 100644 index 0000000000..05040cb264 --- /dev/null +++ b/docs/architecture/connect-channel-v1.md @@ -0,0 +1,45 @@ +# Connect Channel v1 + +Implementation contract for [ADR-0244](../adr/0244-connect-encrypts-device-traffic-against-pinned-host-keys.md). This is an application-defined adapter into the existing Device Gateway and DSH API Gateway, not another native API dispatcher. Qualification remains in progress in #2542. + +## Identity and handshake + +The only outer application endpoint is `/botharness-device/channel`. HTTP GET returns public Host identity and protocol metadata; it grants no authority and never replaces an existing pin. WebSocket uses no bearer header, cookie, query, subprotocol, or compression. An outer request to a legacy application endpoint is refused. + +The profile is exactly `Noise_XK_25519_ChaChaPoly_SHA256`, Noise revision 34. The three XK handshake payloads are empty. The UTF-8 prologue is the JSON array `["connect-noise-xk-v1","Noise_XK_25519_ChaChaPoly_SHA256",hostId,"connect"]`, without whitespace. The responder static key is the Noise pre-message; both static keys are bound into the completed transcript. An `h` followed by lowercase hexadecimal encodes each handshake message. Their decoded lengths are exactly 48, 48 and 64 bytes. Every connection generates a fresh ephemeral key from a cryptographically secure random source; no insecure fallback exists. + +The initiator pins the full 32-byte Host key after comparing SHA-256 of that key with an independently trusted Host display. Device keys are generated on the device. The existing Device Pairing authority binds the offer, verifier/account binding where present, expiry, requesting key and trusted owner's approval. A pairing session can only propose and redeem that exact key. A normal session then supplies its device credential inside ciphertext and must match the approved key in the durable `paired_devices.public_key` field. There is no authority based solely on successful key exchange or relay account membership. + +Host private keys reside in the DSH Credentials Provider. Phone keys and pins are saved atomically with credentials through the existing SecureStore adapter; browser keys use the trusted serving Host's origin-local storage and do not claim hardware protection. A malformed stored Host key fails closed rather than rotating itself. + +## Authenticated frames + +After Split, `d` plus lowercase hex carries a 15-byte header followed by ChaCha20-Poly1305 ciphertext. The header is: + +| Offset | Bytes | Value | +| ------ | ----- | ------------------------------------------------------- | +| 0 | 1 | version = 1 | +| 1 | 1 | frame kind = 1 (application fragment) | +| 2 | 1 | direction: 0 initiator→responder; 1 responder→initiator | +| 3 | 8 | frame counter, unsigned little-endian | +| 11 | 4 | ciphertext length, unsigned big-endian | + +AEAD associated data is the concatenation of UTF-8 `connect-noise-xk-v1/frame`, the 32-byte completed Noise handshake hash, and the exact header. Each direction has its own Split key and starts its counter at zero. Noise's nonce is four zero bytes followed by its 64-bit little-endian cipher counter. Header and cipher counters advance together. Reject any counter other than the next expected one and close at 2³² frames, well before Noise nonce exhaustion. Any malformed header, authentication error, direction mismatch, replay, gap or cross-session frame closes and erases the session. No decoder tries plaintext afterward. + +Plaintext begins with one fragment byte: 0 complete, 1 first, 2 middle, 3 last; at most 32 KiB follows. First and middle fragments must carry exactly 32 KiB; last fragments must be nonempty, bounding the fragment count as well as byte size. Fragments cannot interleave; middle/last require an active message, first/complete require none. Reassembly is limited to 8 MiB and validated UTF-8 JSON. HTTP bodies and logical stream messages are further limited to 4 MiB; binary bodies use base64 within encrypted JSON. Length checks precede large allocations. The enclosing WebSocket is ordered and reliable; a reconnect creates fresh keys and does not replay application mutations. + +## Application messages and lifecycle + +Only after key-bound admission does the channel accept request/cancel and logical socket open/send/close messages. All paths, arguments, attachment names and content, inner authorization, responses and mux frames are encrypted. HTTP forwarding remains loopback-only through the existing Device Gateway with its ordinary credential checks. Logical sockets are restricted to `remote.mux`; viewer signaling is unavailable on Connect until separately qualified. + +Each session allows at most 32 concurrent HTTP requests and four mux sockets; Host admission is bounded to 32 channels. Handshake and upstream connection deadlines are 10 seconds. Client output carries a monotonically increasing `credit` id inside encrypted JSON; the Host returns an encrypted `ack` after processing that message. The client retains the encoded byte count until its exact acknowledgement, refuses unknown credits and closes above 20 MiB or 256 messages outstanding regardless of native WebSocket buffering support. Host socket output also has a 20 MiB bound. Compression is disabled, cancellation aborts upstream work, and revocation or Plugin/Fiber disposal closes every associated channel. JavaScript memory erasure is best-effort and cannot guarantee removal of strings or garbage-collected copies. + +## Implementation evidence + +Pinned primitives: `@noble/curves`, `@noble/ciphers`, and `@noble/hashes` 2.4.0. The fixed XK state machine is BotHarness-owned; these dependencies are primitives, not a third-party Noise state machine or an audit of this integration. + +The independent fixture is Cacophony `vectors/cacophony.txt` at commit `8ee9d41e34a1a596cfa3ab12aa4069ff87dc1247`, selected by exact protocol name. Tests compare all three handshake flights, the final transcript hash, and both transport directions byte-for-byte. The generic primitive seam accepts the vector's nonempty payloads; the production Connect adapter rejects all nonempty handshake payloads. This evidence does not replace adversarial integration tests, independent review, real runtime capture or native/Human acceptance. + +## Account enrollment + +The account relay fetches only public channel metadata. Its signed enrollment body binds relay Host id, account, verifier hash, name and issue time. The phone keeps the verifier locally and sends the signed body, signature and verifier only inside Noise. The Host verifies the current account link and freshness, creates a local offer, binds the exact requesting static key, and waits for owner confirmation. The relay never receives the redeemable offer or resulting device bearer. Unknown outcomes are not automatically replayed as new mutations. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 53809abcf5..2c3725aec1 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -1249,4 +1249,6 @@ Clear removes all functional Panel overrides only in the explicit mode, includin Full Access now retains native `ask` so recognized permission changes can reach the Human, while ordinary requests in its explicitly selected scope are automatically answered without routine cards. The selected mode/scope and existing Assignment permissions are not widened. A native `never` policy still means deterministic rejection; it is not bypassed. Directory grants still use `grant-request-approve --confirm`. -Connect currently refuses private input and known credential-setting RPCs with `secret-route-refused`. Enter secrets with the existing `ask-secret-*` commands against the local Host or a trusted Tailscale route. No full Host WebView is available on the phone. This restriction is not encryption; ADR-0244 is proposed and channel state/fingerprint output is not implemented. +Connect uses a Noise XK encrypted channel. `deepseekbot connect-status` includes `connect.encryption` with `protocol`, `required`, `state`, `publicKey` and the Host fingerprint. State is `negotiating`, `encrypted` or `disconnected`; enabling a tunnel alone does not imply an encrypted session. `devices` also lists `pendingKeys`. Compare the full new-device fingerprint with the device display, then approve that exact key with `deepseekbot device-approve-key --confirm`. Missing confirmation or an invalid key refuses. These status commands expose no pairing offer, private key or bearer. + +The phone's native private form and browser remote chat reach the existing credential owner through the same channel. Legacy Connect pairings require a new fingerprint ceremony; failures never retry plaintext. The full phone WebView remains removed, and Connect Browser/Computer viewers are unavailable pending separate qualification. CLI channel state is not native acceptance or proof of a real Cloudflare path. diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 712541325b..b15727232e 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -1217,4 +1217,6 @@ deepseekbot appearance-reset --client-id --expected-revision --confirm` 批准准确的公钥;缺少确认或公钥无效会拒绝。配对链接、设备私钥和 bearer 不出现在这些状态输出中。 + +手机原生私密表单和浏览器远程聊天通过同一个加密通道调用既有凭据 owner。旧 Connect 配对必须重新核对指纹;失败不会重试明文。手机完整 Host WebView 仍已移除,Connect 的 Browser/Computer 查看器暂不可用。加密 CLI 状态不替代原生验收或实际 Cloudflare 链路验证。 diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index c0ce7cc55e..46787452b8 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -535,13 +535,39 @@ export function AddRemoteHostDialog({ const thisHost = useThisHost(); const [link, setLink] = useState(''); const [busy, setBusy] = useState(false); + const [verification, setVerification] = useState<{ + fingerprint: string; + resolve(value: boolean): void; + }>(); + const [deviceFingerprint, setDeviceFingerprint] = useState(); + const pending = useRef(); + const close = () => { + pending.current?.abort(); + verification?.resolve(false); + onClose(); + }; const [error, setError] = useState(undefined); const submit = (): void => { + if (verification) { + verification.resolve(true); + setVerification(undefined); + setBusy(true); + return; + } + pending.current = new AbortController(); setBusy(true); setError(undefined); const name = thisHost.name ?? t('remoteHosts.this'); void remoteHosts() - .pair(link, t('remoteHosts.deviceName', { name }), thisHost.id) + .pair(link, t('remoteHosts.deviceName', { name }), thisHost.id, { + signal: pending.current.signal, + confirmFingerprint: (fingerprint) => + new Promise((resolve) => { + setVerification({ fingerprint, resolve }); + setBusy(false); + }), + onPending: setDeviceFingerprint, + }) .then((result) => { setBusy(false); if (!result.ok) { @@ -565,14 +591,26 @@ export function AddRemoteHostDialog({ title={t('remoteHosts.add')} description={t('remoteHosts.add.description')} labels={{ close: t('common.close'), cancel: t('common.cancel') }} - submitLabel={t('remoteHosts.add.submit')} + submitLabel={t(verification ? 'connect.matches' : 'remoteHosts.add.submit')} submitDisabled={link.trim().length === 0} busy={busy} error={error} - onCancel={onClose} + onCancel={close} onSubmit={submit} submitData={{ 'data-remote-host-add-submit': '' }} > + {verification ? ( +
+

{t('connect.verifyHint')}

+ {verification.fingerprint} +
+ ) : null} + {deviceFingerprint ? ( +
+

{t('connect.pending')}

+ {deviceFingerprint} +
+ ) : null} (undefined); export const useMessageHost = (): MessageHost | undefined => useContext(MessageHostContext); -export function fetchMessageResource( +export async function fetchMessageResource( path: string, signal: AbortSignal, host?: MessageHost, ): Promise { if (host === undefined) return fetch(`.${path}`, { signal }); const source = host.client.resource(path); + if (source.read) { + const result = await source.read(signal); + return new Response(Uint8Array.from(result.bytes), { + headers: { 'content-type': result.mime }, + }); + } return fetch(source.uri, { headers: source.headers, credentials: 'omit', diff --git a/packages/client/src/client/paired-devices.tsx b/packages/client/src/client/paired-devices.tsx index d689a314c2..1f19239ef8 100644 --- a/packages/client/src/client/paired-devices.tsx +++ b/packages/client/src/client/paired-devices.tsx @@ -1,3 +1,5 @@ +import { noiseFingerprint } from '../../../core/src/devices/noise/xk.js'; +import { keyBytes } from '../../../../apps/mobile/src/host/encrypted-carrier.js'; import { useRef, useState, type ReactElement } from 'react'; import * as stylex from '@stylexjs/stylex'; import { Button } from '@deepseek-ai/dsh-client-ui-primitives'; @@ -40,6 +42,7 @@ interface ConnectAccountStatus { } interface ConnectStatus { + encryption?: { fingerprint: string; state: string }; state: 'off' | 'starting' | 'connected' | 'error'; hostname?: string; issue?: string; @@ -49,6 +52,7 @@ interface ConnectStatus { interface DevicesSnapshot { host: { id: string; name: string }; devices: PairedDevice[]; + pendingKeys?: { receipt: string; publicKey: string; name: string; approved: boolean }[]; routes: RouteStatus; connect?: ConnectStatus; } @@ -290,6 +294,53 @@ export function PairedDevicesSettings({ t }: PropsLocale<'botharness'>): ReactEl signIn?.state === 'signing-in' || (signIn?.state === 'signed-in' && connect?.state !== 'connected'); + const verification = ( + <> + {snapshot?.connect?.encryption ? ( + + {t('connect.fingerprint')}: {snapshot.connect.encryption.fingerprint} + + ) : null} + {snapshot?.pendingKeys + ?.filter((item) => !item.approved) + .map((item) => ( + { + setBusy(item.receipt); + setError(false); + void fetch(`${PATH}/approve-key`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + receipt: item.receipt, + publicKey: item.publicKey, + confirm: true, + }), + }) + .then(json) + .then( + () => load(), + () => setError(true), + ) + .finally(() => setBusy(undefined)); + }} + > + {t('connect.approve')} + + } + /> + ))} + + ); + return (
@@ -468,6 +519,7 @@ export function PairedDevicesSettings({ t }: PropsLocale<'botharness'>): ReactEl /> )) )} + {pairing ? null : verification} {error ? {t('devices.error')} : null} {pairing ? ( @@ -480,6 +532,8 @@ export function PairedDevicesSettings({ t }: PropsLocale<'botharness'>): ReactEl data={{ 'data-pair-dialog': '' }} >
+ {verification} + {error ? {t('devices.error')} : null} {paired !== undefined ? ( {t('devices.pair.done', { name: paired.name })} ) : offer === undefined ? ( diff --git a/packages/client/src/client/private-input-card.tsx b/packages/client/src/client/private-input-card.tsx index 6afbc4f932..d2ae9422cd 100644 --- a/packages/client/src/client/private-input-card.tsx +++ b/packages/client/src/client/private-input-card.tsx @@ -56,7 +56,11 @@ export function PrivateInputCard({ const [error, setError] = useState(false); const [routeRefused, setRouteRefused] = useState(false); const connectBlocked = - (host !== undefined && isConnectRoute(host.client.resource('/').uri)) || routeRefused; + (host !== undefined && + (host.client.secretInputAllowed?.() === false || + (host.client.secretInputAllowed === undefined && + isConnectRoute(host.client.resource('/').uri)))) || + routeRefused; const [retry, setRetry] = useState(0); const mount = useMountedResource( (node) => { diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index f53c9fd876..8cee5525d4 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -59,13 +59,24 @@ export interface RemoteHostsSnapshot { export type HostPairResult = | { ok: true; host: SavedHost } - | { ok: false; code: PairingRefusal | 'already-paired' | 'this-host' }; + | { ok: false; code: PairingRefusal | 'already-paired' | 'this-host' | 'storage-failure' }; + +export interface RemotePairTrust { + confirmFingerprint?: (fingerprint: string, hostName: string) => Promise; + onPending?: (fingerprint: string) => void; + signal?: AbortSignal; +} export interface RemoteHosts { notifications: RemoteNotifications; getSnapshot(): RemoteHostsSnapshot; subscribe(listener: () => void): () => void; - pair(link: string, deviceName: string, localHostId?: string): Promise; + pair( + link: string, + deviceName: string, + localHostId?: string, + trust?: RemotePairTrust, + ): Promise; remove(hostId: string): Promise; select(tab: string): void; open(target: RemoteConversationTarget): void; @@ -260,7 +271,12 @@ export function createRemoteHosts(options: { }; const attach = (host: SavedHost): void => { - const client = createHostClient({ routes: host.routes, credential: host.credential, carrier }); + const client = createHostClient({ + ...host, + routes: host.routes, + credential: host.credential, + carrier, + }); const projectActivity = createRemoteActivityProjection(); const entry: HostEntry = { host, @@ -331,15 +347,28 @@ export function createRemoteHosts(options: { notifications.stop(); }; }, - async pair(link, deviceName, localHostId) { + async pair(link, deviceName, localHostId, trust) { const parsed = parsePairingLink(link); if (!parsed.ok) return parsed; if (parsed.payload.host.id === localHostId) return { ok: false, code: 'this-host' }; if (clients.has(parsed.payload.host.id)) return { ok: false, code: 'already-paired' }; - const redeemed = await redeemPairing({ payload: parsed.payload, deviceName, carrier }); + const redeemed = await redeemPairing({ + payload: parsed.payload, + deviceName, + carrier, + ...trust, + }); if (!redeemed.ok) return redeemed; + try { + if (!storage) throw new Error('storage-unavailable'); + storage.setItem( + REMOTE_HOSTS_STORAGE_KEY, + JSON.stringify([...[...clients.values()].map(({ host }) => host), redeemed.host]), + ); + } catch { + return { ok: false, code: 'storage-failure' }; + } attach(redeemed.host); - save(); tab = redeemed.host.hostId; try { storage?.setItem(HOST_TAB_STORAGE_KEY, tab); @@ -422,6 +451,21 @@ export function browserCarrier(): HostCarrier { return { status: response.status, text: () => response.text() }; }, openSocket(url, events, headers = {}) { + if (new URL(url).pathname === '/botharness-device/channel') { + const socket = new WebSocket(url); + socket.onopen = () => events.open(); + socket.onmessage = (event) => events.message(String(event.data)); + socket.onerror = () => events.closed('channel-unreachable'); + socket.onclose = (event) => events.closed(event.reason); + return { + get bufferedAmount() { + return socket.bufferedAmount; + }, + send: (text) => socket.send(text), + close: () => socket.close(), + }; + } + const controller = new AbortController(); let socket: WebSocket | undefined; let done = false; diff --git a/packages/client/test/channel-user-question.test.ts b/packages/client/test/channel-user-question.test.ts index 272b1720b2..5ef0ff7faf 100644 --- a/packages/client/test/channel-user-question.test.ts +++ b/packages/client/test/channel-user-question.test.ts @@ -131,37 +131,47 @@ describe('private input card', () => { ], }, }; - it('shows the Connect notice without inputs while keeping cancellation available', async () => { - const actions = { - privateInputStatus: vi.fn().mockResolvedValue('pending'), - privateInputSubmit: vi.fn(), - privateInputCancel: vi.fn().mockResolvedValue(undefined), - } as unknown as BridgeActions; - const remote = { - current: () => true, - client: { - resource: () => ({ - uri: 'https://qa.deepseekbot.dev/botharness-device', - headers: {}, - }), - } as unknown as MessageHost['client'], - } satisfies MessageHost; - await act(async () => - root.render( - createElement( - MessageHostContext.Provider, - { value: remote }, - createElement(ChannelMessageBody, { message: card, actions, t: zhTranslate }), + it.each([false, true])( + 'uses the owning client encryption capability for private input (encrypted=%s)', + async (encrypted) => { + const actions = { + privateInputStatus: vi.fn().mockResolvedValue('pending'), + privateInputSubmit: vi.fn(), + privateInputCancel: vi.fn().mockResolvedValue(undefined), + } as unknown as BridgeActions; + const remote = { + current: () => true, + client: { + secretInputAllowed: () => encrypted, + resource: () => ({ + uri: 'https://qa.deepseekbot.dev/botharness-device', + headers: {}, + }), + } as unknown as MessageHost['client'], + } satisfies MessageHost; + await act(async () => + root.render( + createElement( + MessageHostContext.Provider, + { value: remote }, + createElement(ChannelMessageBody, { message: card, actions, t: zhTranslate }), + ), ), - ), - ); - expect(container.textContent).toContain('Connect 尚未端到端加密'); - expect(container.querySelector('input')).toBeNull(); - expect(button('提交')?.disabled).toBe(true); - await act(async () => button('取消')?.click()); - expect(actions.privateInputCancel).toHaveBeenCalledOnce(); - expect(actions.privateInputSubmit).not.toHaveBeenCalled(); - }); + ); + if (encrypted) { + expect(container.textContent).not.toContain('Connect 尚未端到端加密'); + expect(container.querySelectorAll('input')).toHaveLength(2); + expect(button('提交')?.disabled).toBe(false); + } else { + expect(container.textContent).toContain('Connect 尚未端到端加密'); + expect(container.querySelector('input')).toBeNull(); + expect(button('提交')?.disabled).toBe(true); + } + await act(async () => button('取消')?.click()); + expect(actions.privateInputCancel).toHaveBeenCalledOnce(); + expect(actions.privateInputSubmit).not.toHaveBeenCalled(); + }, + ); it('offers replacement and revocation without restoring inputs and ignores a stale configured poll after revocation', async () => { vi.useFakeTimers(); diff --git a/packages/client/test/model-preset-profile.test.ts b/packages/client/test/model-preset-profile.test.ts index eab033a2f2..cb73e3ad47 100644 --- a/packages/client/test/model-preset-profile.test.ts +++ b/packages/client/test/model-preset-profile.test.ts @@ -209,7 +209,7 @@ function remoteOwner() { ['modelPresetCreate', bridge.modelPresetCreate], ]); const client = createHostClient({ - routes: ['https://source.example'], + routes: ['https://source.example.ts.net'], credential: 'test-only', carrier: { async request(url, init) { diff --git a/packages/client/test/remote-conversation-source.test.tsx b/packages/client/test/remote-conversation-source.test.tsx index ec17941ed1..15b04fc6a1 100644 --- a/packages/client/test/remote-conversation-source.test.tsx +++ b/packages/client/test/remote-conversation-source.test.tsx @@ -79,7 +79,7 @@ async function mount(surface: 'activity' | 'dock', configure?: (host: FakeHost) host.channel('other'); configure?.(host); } - const routed = (url: string) => (new URL(url).hostname === 'b.test' ? hosts.b : hosts.c); + const routed = (url: string) => (new URL(url).hostname === 'b.test.ts.net' ? hosts.b : hosts.c); const sources = createRemoteHosts({ carrier: { request: (url, init) => routed(url).carrier.request(url, init), @@ -92,7 +92,7 @@ async function mount(surface: 'activity' | 'dock', configure?: (host: FakeHost) Object.entries(hosts).map(([id, host]) => ({ hostId: id, hostName: id, - routes: [`https://${id}.test`], + routes: [`https://${id}.test.ts.net`], deviceId: 'device-1', credential: host.credential, })), @@ -219,7 +219,9 @@ it.each(['activity', 'dock'] as const)( expect(view.element.textContent).toContain('Full source body'); expect(view.element.querySelector('img[src="blob:source-1"]')).not.toBeNull(); expect(fetcher).toHaveBeenLastCalledWith( - expect.stringContaining('https://b.test/botharness-device/api/botharness/attachment'), + expect.stringContaining( + 'https://b.test.ts.net/botharness-device/api/botharness/attachment', + ), expect.objectContaining({ credentials: 'omit', headers: { authorization: `Device ${view.hosts.b.credential}` }, @@ -231,7 +233,7 @@ it.each(['activity', 'dock'] as const)( expect(view.element.querySelector('[data-remote-timeline]')).toBeNull(); expect(release).toHaveBeenCalledWith('blob:source-1'); expect(viewerRequests.length).toBeGreaterThan(0); - expect(viewerRequests.every((url) => url.startsWith('https://b.test/'))).toBe(true); + expect(viewerRequests.every((url) => url.startsWith('https://b.test.ts.net/'))).toBe(true); await clickViewer(); await act(async () => { await vi.waitFor(() => expect(make).toHaveBeenCalledTimes(2)); @@ -247,7 +249,9 @@ it.each(['activity', 'dock'] as const)( expect(view.element.querySelector('[data-remote-viewer]')).toBeNull(); expect(view.input().value).toBe(''); expect(fetcher).toHaveBeenLastCalledWith( - expect.stringContaining('https://c.test/botharness-device/api/botharness/attachment'), + expect.stringContaining( + 'https://c.test.ts.net/botharness-device/api/botharness/attachment', + ), expect.anything(), ); } finally { @@ -403,7 +407,9 @@ it.each(['activity', 'dock'] as const)( await act(async () => resource.response.resolve(new Response('file'))); expect(download).toHaveBeenCalledTimes(1); expect(resource.fetcher).toHaveBeenLastCalledWith( - expect.stringContaining('https://b.test/botharness-device/api/botharness/attachment'), + expect.stringContaining( + 'https://b.test.ts.net/botharness-device/api/botharness/attachment', + ), expect.objectContaining({ credentials: 'omit', headers: { authorization: `Device ${view.hosts.b.credential}` }, @@ -651,7 +657,7 @@ it.each(['activity', 'dock'] as const)( expect(download).not.toHaveBeenCalled(); expect( resource.fetcher.mock.calls.every(([url]) => - url.startsWith('https://b.test/botharness-device/api/botharness/attachment'), + url.startsWith('https://b.test.ts.net/botharness-device/api/botharness/attachment'), ), ).toBe(true); } finally { diff --git a/packages/client/test/remote-message.test.ts b/packages/client/test/remote-message.test.ts index d92762eb66..f84d19a339 100644 --- a/packages/client/test/remote-message.test.ts +++ b/packages/client/test/remote-message.test.ts @@ -227,3 +227,23 @@ it('routes approval and question decisions to their source Host and refuses stal }); expect(calls).toHaveLength(2); }); + +it.each([false, true])( + 'uses the encrypted source resource without direct-fetch fallback (failure=%s)', + async (failure) => { + const { host } = fixture(); + const read = vi.fn(async () => { + if (failure) throw new Error('channel-authentication-failed'); + return { bytes: new TextEncoder().encode('encrypted attachment'), mime: 'text/plain' }; + }); + vi.spyOn(host.client, 'resource').mockReturnValue({ uri: '', headers: {}, read }); + const direct = vi.spyOn(globalThis, 'fetch'); + const { fetchMessageResource } = await import('../src/client/message-host.js'); + const signal = new AbortController().signal; + const result = fetchMessageResource('/api/botharness/attachment', signal, host); + if (failure) await expect(result).rejects.toThrow('channel-authentication-failed'); + else expect(await (await result).text()).toBe('encrypted attachment'); + expect(read).toHaveBeenCalledWith(signal); + expect(direct).not.toHaveBeenCalled(); + }, +); diff --git a/packages/connect-relay/src/relay.ts b/packages/connect-relay/src/relay.ts index 3acf15034f..a8b9b6675a 100644 --- a/packages/connect-relay/src/relay.ts +++ b/packages/connect-relay/src/relay.ts @@ -36,7 +36,7 @@ const ONLINE = new Set(['healthy', 'degraded']); const RECLAIM_BATCH = 15; const VERIFIER_HASH = /^[A-Za-z0-9_-]{43}$/u; const OFFER_TIMEOUT_MS = 10_000; -export const OFFER_PATH = '/botharness-device/connect/offer'; +export const OFFER_PATH = '/botharness-device/channel'; export const SIGNATURE_HEADER = 'x-botharness-relay-signature'; function base64Url(bytes: ArrayBuffer): string { @@ -221,9 +221,7 @@ export function createRelay(options: RelayOptions) { let reply: Response; try { reply = await (options.fetchHost ?? fetch)(`https://${hostname(row.id)}${OFFER_PATH}`, { - method: 'POST', - headers: { 'content-type': 'application/json', [SIGNATURE_HEADER]: signature }, - body: payload, + method: 'GET', signal: AbortSignal.timeout(OFFER_TIMEOUT_MS), }); } catch { @@ -231,19 +229,24 @@ export function createRelay(options: RelayOptions) { } if (reply.status >= 500) return failure(409, 'host-offline'); const answer = (await reply.json().catch(() => undefined)) as - | { offer?: unknown; expiresAt?: unknown; host?: { id?: unknown; name?: unknown } } + | { + encryption?: { protocol?: unknown; publicKey?: unknown }; + host?: { id?: unknown; name?: unknown }; + } | undefined; if ( reply.status !== 200 || - typeof answer?.offer !== 'string' || - typeof answer.expiresAt !== 'string' || + answer?.encryption?.protocol !== 'connect-noise-xk-v1' || + typeof answer.encryption.publicKey !== 'string' || + !/^[0-9a-f]{64}$/u.test(answer.encryption.publicKey) || typeof answer.host?.id !== 'string' || typeof answer.host.name !== 'string' ) return failure(502, 'host-refused'); return json(200, { - offer: answer.offer, - expiresAt: answer.expiresAt, + enrollment: { body: payload, signature }, + encryption: answer.encryption, + expiresAt: new Date(now().getTime() + 5 * 60 * 1000).toISOString(), hostname: hostname(row.id), host: { id: answer.host.id, name: answer.host.name }, }); diff --git a/packages/connect-relay/test/connect-relay.test.ts b/packages/connect-relay/test/connect-relay.test.ts index 086f5e4b68..e2be8f32c3 100644 --- a/packages/connect-relay/test/connect-relay.test.ts +++ b/packages/connect-relay/test/connect-relay.test.ts @@ -546,54 +546,40 @@ describe('Connect relay phone offers', () => { return relay; } - it('asks the Host over its tunnel for an offer signed with the Host key', async () => { - const seen: { url: string; signature: string | null; body: string }[] = []; + it('returns signed enrollment metadata without asking the Host for an outer bearer offer', async () => { + const seen: { url: string; method: string; body: unknown }[] = []; + const encryption = { protocol: 'connect-noise-xk-v1', publicKey: 'ab'.repeat(32) }; const { call, at } = await linked(async (input, init) => { - seen.push({ - url: String(input), - signature: new Headers(init?.headers).get('x-botharness-relay-signature'), - body: String(init?.body), - }); - return Response.json({ - offer: 'offer-1', - expiresAt: '2026-10-20T12:10:00.000Z', - host: { id: 'pairing-host-1', name: 'Studio Mac' }, - }); + seen.push({ url: String(input), method: init?.method ?? 'GET', body: init?.body }); + return Response.json({ host: { id: 'pairing-host-1', name: 'Studio Mac' }, encryption }); }); const tunnel = await call('POST', '/v1/hosts/haaaaaaaaaaab/tunnel', { as: 'user_alice', body: { originPort: 41001 }, }); - expect(tunnel.body).toMatchObject({ - account: 'user_alice', - relayKey: await hostKey('haaaaaaaaaaab'), - }); - expect( - await call('POST', '/v1/hosts/haaaaaaaaaaab/offer', { - as: 'user_alice', - body: { verifierHash: VERIFIER_HASH, name: ' iPhone ' }, - }), - ).toEqual({ - status: 200, - body: { - offer: 'offer-1', - expiresAt: '2026-10-20T12:10:00.000Z', - hostname: 'haaaaaaaaaaab.deepseekbot.dev', - host: { id: 'pairing-host-1', name: 'Studio Mac' }, + const result = await call('POST', '/v1/hosts/haaaaaaaaaaab/offer', { + as: 'user_alice', + body: { verifierHash: VERIFIER_HASH, name: ' iPhone ' }, + }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ host: { id: 'pairing-host-1' }, encryption }); + expect(result.body['offer']).toBeUndefined(); + expect(seen).toEqual([ + { + url: 'https://haaaaaaaaaaab.deepseekbot.dev/botharness-device/channel', + method: 'GET', + body: undefined, }, - }); - expect(seen).toHaveLength(1); - expect(seen[0]!.url).toBe( - 'https://haaaaaaaaaaab.deepseekbot.dev/botharness-device/connect/offer', - ); - expect(JSON.parse(seen[0]!.body)).toEqual({ + ]); + const enrollment = result.body['enrollment'] as { body: string; signature: string }; + expect(JSON.parse(enrollment.body)).toEqual({ hostId: 'haaaaaaaaaaab', account: 'user_alice', verifierHash: VERIFIER_HASH, name: 'iPhone', issuedAt: at(), }); - expect(seen[0]!.signature).toBe(await hmac(tunnel.body['relayKey'], seen[0]!.body)); + expect(enrollment.signature).toBe(await hmac(tunnel.body['relayKey'], enrollment.body)); }); it('refuses offers for other accounts, bad verifiers and unreachable Hosts', async () => { diff --git a/packages/core/package.json b/packages/core/package.json index db3148b43c..ac2f630fe1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,9 @@ "dependencies": { "@botharness/pixel-avatar": "0.10.1", "@botharness/pixel-banner": "0.1.0", + "@noble/ciphers": "2.4.0", + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", "@resvg/resvg-js": "2.6.2", "css-tree": "3.2.1", "saxes": "6.0.0", diff --git a/packages/core/src/bots/cli-devices.ts b/packages/core/src/bots/cli-devices.ts index b18d52ce0a..d12bebdd8f 100644 --- a/packages/core/src/bots/cli-devices.ts +++ b/packages/core/src/bots/cli-devices.ts @@ -3,6 +3,11 @@ import { existsSync, writeFileSync } from 'node:fs'; import { CliLiveError } from './cli-live-error.js'; export const DEVICES_COMMANDS = [ + { + command: 'device-approve-key', + description: + 'approve after fingerprint comparison (needs --confirm)', + }, { command: 'devices', description: 'list paired devices with this Host identity and its phone routes', @@ -42,13 +47,20 @@ export async function runDevicesCommand( writeFileSync(path, `${text}\n`, { flag: 'wx', mode: 0o600 }), exists: (path: string) => boolean = existsSync, ): Promise { - const takes = command === 'device-offer-status' || command === 'device-revoke' ? 1 : 0; + const takes = + command === 'device-approve-key' + ? 2 + : command === 'device-offer-status' || command === 'device-revoke' + ? 1 + : 0; if (rest.length !== takes) throw new CliLiveError( 'usage', takes === 0 ? `${command} takes no arguments.` - : `${command} takes exactly one ${command === 'device-revoke' ? '' : ''}.`, + : command === 'device-approve-key' + ? 'device-approve-key takes --confirm.' + : `${command} takes exactly one ${command === 'device-revoke' ? '' : ''}.`, ); if (values.out !== undefined && command !== 'device-offer') throw new CliLiveError('usage', '--out requires device-offer.'); @@ -71,6 +83,14 @@ export async function runDevicesCommand( 'confirm-required', 'device-revoke signs the device out at once and it must pair again. Pass --confirm.', ); + if ( + command === 'device-approve-key' && + (values.confirm !== true || !/^[0-9a-f]{64}$/u.test(rest[1] ?? '')) + ) + throw new CliLiveError( + 'key-confirmation-required', + 'Compare the device fingerprint, then pass --confirm.', + ); const host = await connect(); const decode = (bytes: Uint8Array): Record => { try { @@ -85,9 +105,20 @@ export async function runDevicesCommand( return { host: snapshot['host'], devices: snapshot['devices'], + ...(snapshot['pendingKeys'] === undefined ? {} : { pendingKeys: snapshot['pendingKeys'] }), routes: snapshot['routes'], }; } + if (command === 'device-approve-key') + return decode( + ( + await host.http(`${DEVICES}/approve-key`, { + receipt: rest[0], + publicKey: rest[1], + confirm: true, + }) + ).bytes, + ); if (command === 'device-offer-status') return decode((await host.http(`${DEVICES}/offer-status`, { receipt: rest[0] })).bytes); if (command === 'device-revoke') diff --git a/packages/core/src/devices/connect.ts b/packages/core/src/devices/connect.ts index beccfd760c..eea2978d06 100644 --- a/packages/core/src/devices/connect.ts +++ b/packages/core/src/devices/connect.ts @@ -1,3 +1,4 @@ +import { CONNECT_CHANNEL_PATH } from './noise/channel.js'; import { spawn as spawnProcess } from 'node:child_process'; import { createServer, @@ -146,7 +147,11 @@ export function createGatewayOrigin(port: () => number): GatewayOrigin { const upgraded = new Set(); const server = createServer((request, response) => { stampConnectRoute(request); - if (!admitsGatewayPath(request.url)) { + if ( + request.url !== CONNECT_CHANNEL_PATH || + request.headers.authorization || + request.headers.cookie + ) { request.resume(); notFound(response); return; @@ -178,7 +183,11 @@ export function createGatewayOrigin(port: () => number): GatewayOrigin { }); server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => { stampConnectRoute(request); - if (!admitsGatewayPath(request.url)) { + if ( + request.url !== CONNECT_CHANNEL_PATH || + request.headers.authorization || + request.headers.cookie + ) { socket.end('HTTP/1.1 404 Not Found\r\nconnection: close\r\ncontent-length: 0\r\n\r\n'); return; } diff --git a/packages/core/src/devices/encrypted-gateway.ts b/packages/core/src/devices/encrypted-gateway.ts new file mode 100644 index 0000000000..ab7e413208 --- /dev/null +++ b/packages/core/src/devices/encrypted-gateway.ts @@ -0,0 +1,388 @@ +import type { IncomingMessage } from 'node:http'; +import { request } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import type { Duplex } from 'node:stream'; +import { WebSocket, WebSocketServer } from 'ws'; +import { ConnectChannel, CONNECT_CHANNEL_PATH } from './noise/channel.js'; +import { noisePublicKey } from './noise/xk.js'; +import type { DevicePairing } from './pairing.js'; + +const BODY_LIMIT = 4 * 1024 * 1024; +const QUEUE_LIMIT = 20 * 1024 * 1024; +const DEADLINE = 10_000; +const field = (input: Record, name: string): string | undefined => + typeof input[name] === 'string' ? input[name] : undefined; +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('channel-message-invalid'); + return value as Record; +} +function pathAllowed(path: string): boolean { + if ( + !path.startsWith('/botharness-device/') || + path.includes('\\') || + path.includes('#') || + /%2f|%5c|%2e|\/\.{1,2}(?:\/|\?|$)/iu.test(path) + ) + return false; + const name = path.split('?')[0]!; + return ( + (name.startsWith('/botharness-device/api/') && + !/\/(?:webrtc|web|browser|computer)(?:\/|$)/u.test(name)) || + name === '/botharness-device/push' || + name.startsWith('/botharness-device/push/') || + name === '/botharness-device/unpair' || + name === '/botharness-device/devices/remove' + ); +} + +export function createEncryptedGateway(options: { + pairing: DevicePairing; + key(): Promise; + port(): number; + changed?: (state: { negotiating: number; encrypted: number }) => void; + log?: (phase: string) => void; + verifyEnrollment?: ( + body: string, + signature: string, + ) => Promise<{ ok: true; verifierHash: string; name?: string } | { ok: false }>; +}) { + const server = new WebSocketServer({ + noServer: true, + maxPayload: 70_000, + perMessageDeflate: false, + }); + const active = new Set<() => void>(); + let disposed = false; + let negotiating = 0, + encrypted = 0, + upgrading = 0; + const changed = () => options.changed?.({ negotiating, encrypted }); + const serve = (socket: WebSocket, key: Uint8Array) => { + const channel = new ConnectChannel({ + role: 'responder', + hostId: options.pairing.host().id, + staticKey: key, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(key), + }); + key.fill(0); + let credential: string | undefined, + deviceId: string | undefined, + closed = false, + admitted = false; + let enrollmentOffer: { body: string; offer: string } | undefined; + let detach: (() => void) | undefined; + const streams = new Map(); + const requests = new Map void>(); + const close = () => { + if (closed) return; + closed = true; + clearTimeout(deadline); + clearInterval(keepalive); + detach?.(); + channel.close(); + for (const stream of streams.values()) stream.terminate(); + for (const cancel of requests.values()) cancel(); + streams.clear(); + requests.clear(); + active.delete(close); + if (admitted) encrypted--; + else negotiating--; + changed(); + socket.terminate(); + }; + const deadline = setTimeout(close, DEADLINE); + let lastMessage = Date.now(); + const keepalive = setInterval(() => { + if (Date.now() - lastMessage > 60_000) close(); + }, 15_000); + active.add(close); + negotiating++; + changed(); + const send = (value: unknown) => { + if (closed || socket.readyState !== WebSocket.OPEN) return; + try { + for (const frame of channel.send(JSON.stringify(value))) { + if (socket.bufferedAmount + frame.length > QUEUE_LIMIT) return close(); + socket.send(frame); + } + } catch { + close(); + } + }; + const refuse = (code: string) => { + send({ type: 'refused', code }); + socket.close(1008, 'channel-refused'); + setTimeout(close, 100).unref(); + }; + const forward = (input: Record) => { + const id = field(input, 'id'), + path = field(input, 'path'), + method = field(input, 'method'); + if ( + !id || + id.length > 64 || + requests.has(id) || + requests.size >= 32 || + !path || + path.length > 8192 || + !pathAllowed(path) || + (method !== 'GET' && method !== 'POST') + ) + throw new Error('channel-request-invalid'); + const body = field(input, 'body') ?? ''; + const bytes = Buffer.from(body, input['encoding'] === 'base64' ? 'base64' : 'utf8'); + if (bytes.length > BODY_LIMIT) throw new Error('channel-body-too-large'); + const upstream = request( + { + host: '127.0.0.1', + port: options.port(), + path, + method, + headers: { + authorization: `Device ${credential!}`, + 'content-type': + input['contentType'] === 'application/octet-stream' + ? 'application/octet-stream' + : 'application/json', + }, + timeout: 30_000, + }, + (response) => { + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > BODY_LIMIT) { + upstream.destroy(); + close(); + } else chunks.push(chunk); + }); + response.on('end', () => { + if (requests.get(id) !== cancel) return; + requests.delete(id); + if (!closed) + send({ + type: 'response', + id, + status: response.statusCode ?? 502, + contentType: response.headers['content-type'] ?? 'application/octet-stream', + body: Buffer.concat(chunks).toString('base64'), + }); + }); + response.on('error', close); + }, + ); + const cancel = () => upstream.destroy(); + requests.set(id, cancel); + upstream.on('timeout', () => upstream.destroy()); + upstream.on('error', () => { + if (requests.get(id) !== cancel) return; + requests.delete(id); + if (!closed) send({ type: 'response', id, status: 502, body: '' }); + }); + upstream.end(bytes); + }; + const message = async (input: Record) => { + const type = input['type']; + if (!admitted) { + const publicKey = Buffer.from(channel.peerKey()).toString('hex'); + if (type === 'pair') { + let offer = field(input, 'offer'); + if (input['enrollment'] !== undefined) { + const enrollment = record(input['enrollment']); + const body = field(enrollment, 'body'), + signature = field(enrollment, 'signature'); + if (!body || body.length > 4096 || !signature || signature.length > 256) + throw new Error('enrollment-invalid'); + if (enrollmentOffer === undefined) { + const verified = await options.verifyEnrollment?.(body, signature); + if (closed || !verified?.ok) throw new Error('enrollment-refused'); + enrollmentOffer = { body, offer: options.pairing.createOffer(verified).offer }; + } + if (enrollmentOffer.body !== body) throw new Error('enrollment-mismatch'); + offer = enrollmentOffer.offer; + } + if (!offer || offer.length > 128) throw new Error('channel-pair-invalid'); + const name = field(input, 'name'), + verifier = field(input, 'verifier'); + const proposed = options.pairing.requestKey({ + offer, + publicKey, + ...(name === undefined ? {} : { name }), + ...(verifier === undefined ? {} : { verifier }), + }); + if (!proposed.ok) return refuse(proposed.code); + const paired = options.pairing.redeem({ + offer, + publicKey, + ...(verifier === undefined ? {} : { verifier }), + }); + if (!paired.ok) { + if (paired.code === 'device-approval-required') + return send({ type: 'pair-pending', receipt: proposed.receipt }); + return refuse(paired.code); + } + send({ + type: 'paired', + host: paired.host, + deviceId: paired.device.id, + credential: paired.credential, + }); + return; + } + if (type !== 'admit') throw new Error('channel-admission-required'); + credential = field(input, 'credential'); + const admission = options.pairing.admitKey( + credential === undefined ? undefined : `Device ${credential}`, + publicKey, + ); + if (!admission.ok) return refuse(admission.code); + admitted = true; + negotiating--; + encrypted++; + changed(); + deviceId = admission.device.id; + detach = options.pairing.attachStream(deviceId, close); + send({ type: 'admitted' }); + return; + } + if (!options.pairing.check(deviceId!).ok) return close(); + if (type === 'ping') return send({ type: 'pong' }); + if (type === 'request') return forward(input); + const id = field(input, 'id'); + if (!id || id.length > 64) throw new Error('channel-stream-invalid'); + if (type === 'cancel') { + requests.get(id)?.(); + requests.delete(id); + return; + } + if (type === 'open') { + if ( + streams.has(id) || + streams.size >= 4 || + input['path'] !== '/botharness-device/api/remote.mux' + ) + throw new Error('channel-stream-invalid'); + const stream = new WebSocket( + `ws://127.0.0.1:${options.port()}/botharness-device/api/remote.mux`, + { + headers: { authorization: `Device ${credential!}` }, + maxPayload: BODY_LIMIT, + perMessageDeflate: false, + handshakeTimeout: DEADLINE, + }, + ); + streams.set(id, stream); + stream.on('open', () => { + if (streams.get(id) === stream) send({ type: 'opened', id }); + }); + stream.on('message', (data) => { + if (streams.get(id) === stream) send({ type: 'message', id, text: data.toString() }); + }); + stream.on('close', () => { + if (streams.get(id) !== stream) return; + streams.delete(id); + send({ type: 'closed', id }); + }); + stream.on('error', () => { + if (streams.get(id) === stream) close(); + }); + } else if (type === 'send') { + const stream = streams.get(id), + text = field(input, 'text'); + if ( + !stream || + stream.readyState !== WebSocket.OPEN || + text === undefined || + text.length > BODY_LIMIT || + stream.bufferedAmount + text.length > QUEUE_LIMIT + ) + throw new Error('channel-stream-invalid'); + stream.send(text); + } else if (type === 'close') { + streams.get(id)?.terminate(); + streams.delete(id); + } else throw new Error('channel-message-invalid'); + }; + let operations = Promise.resolve(); + let queued = 0, + queuedBytes = 0; + socket.on('message', (data, binary) => { + try { + if (binary) return close(); + lastMessage = Date.now(); + const result = channel.receive(data.toString()); + if (result.reply) socket.send(result.reply); + if (result.ready) clearTimeout(deadline); + if (result.message !== undefined) { + const size = result.message.length; + queuedBytes += size; + if (++queued > 32 || queuedBytes > 16 * 1024 * 1024) return close(); + const input = record(JSON.parse(result.message)); + operations = operations + .then(async () => { + if (!closed) { + const credit = input['credit']; + if ( + credit !== undefined && + (!Number.isSafeInteger(credit) || Number(credit) < 1 || Number(credit) >= 2 ** 32) + ) + throw new Error('channel-credit-invalid'); + await message(input); + if (!closed && credit !== undefined) send({ type: 'ack', credit }); + } + }) + .catch(() => close()) + .finally(() => { + queued--; + queuedBytes -= size; + }); + } + } catch { + options.log?.('refused'); + close(); + } + }); + socket.on('close', close); + socket.on('error', close); + }; + return { + async upgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise { + if ( + disposed || + request.url !== CONNECT_CHANNEL_PATH || + request.headers.authorization || + request.headers.cookie || + request.headers['sec-websocket-protocol'] || + active.size + upgrading >= 32 + ) { + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + return; + } + upgrading++; + let key: Uint8Array | undefined; + try { + key = await options.key(); + if (disposed || socket.destroyed) { + socket.destroy(); + key.fill(0); + return; + } + const identity = key; + server.handleUpgrade(request, socket, head, (client) => serve(client, identity)); + } catch { + socket.destroy(); + } finally { + key?.fill(0); + upgrading--; + } + }, + close(): void { + disposed = true; + for (const close of [...active]) close(); + server.close(); + }, + }; +} diff --git a/packages/core/src/devices/http.ts b/packages/core/src/devices/http.ts index e632ee4c24..86609a2edc 100644 --- a/packages/core/src/devices/http.ts +++ b/packages/core/src/devices/http.ts @@ -1,3 +1,4 @@ +import type { EncryptionIdentity } from './noise-identity.js'; import { encode } from 'uqr'; import type { AccountStatus, ConnectAccount } from './connect-account.js'; @@ -21,6 +22,7 @@ const headers = { export interface PairingPayload { v: 1; + encryption?: EncryptionIdentity; host: HostIdentity; offer: string; routes: string[]; @@ -30,8 +32,12 @@ export interface PairingPayload { export interface DevicesSnapshot { host: HostIdentity; devices: PairedDevice[]; + pendingKeys?: ReturnType; routes: RouteStatus; - connect: ConnectStatus & { account?: AccountStatus }; + connect: ConnectStatus & { + account?: AccountStatus; + encryption?: EncryptionIdentity & { state: 'negotiating' | 'encrypted' | 'disconnected' }; + }; } export interface PairingOfferView { @@ -93,6 +99,9 @@ async function body(request: Request): Promise> { export function createDevicesHttp(options: { pairing: DevicePairing; + encryption?: () => Promise< + EncryptionIdentity & { state: 'negotiating' | 'encrypted' | 'disconnected' } + >; routes: () => Promise; publish?: () => Promise; connect?: DeviceConnect; @@ -105,9 +114,11 @@ export function createDevicesHttp(options: { const snapshot: DevicesSnapshot = { host: pairing.host(), devices: pairing.list(), + pendingKeys: pairing.pendingKeys(), routes: await options.routes(), connect: { ...(connect?.status() ?? { state: 'off' }), + ...(options.encryption === undefined ? {} : { encryption: await options.encryption() }), ...(account === undefined ? {} : { account: account.status() }), }, }; @@ -120,6 +131,7 @@ export function createDevicesHttp(options: { const offer = pairing.createOffer(); const link = pairingLink({ v: 1, + ...(options.encryption === undefined ? {} : { encryption: await options.encryption() }), host: pairing.host(), offer: offer.offer, routes, @@ -144,6 +156,17 @@ export function createDevicesHttp(options: { return json({ code: 'receipt-required' }, 400); return json(pairing.offerStatus(input['receipt'])); } + if (request.method === 'POST' && path === `${DEVICES_PATH}/approve-key`) { + const input = await body(request); + if ( + input['confirm'] !== true || + typeof input['receipt'] !== 'string' || + typeof input['publicKey'] !== 'string' + ) + return json({ code: 'key-confirmation-required' }, 400); + const approved = pairing.approveKey(input['receipt'], input['publicKey']); + return json({ approved }, approved ? 200 : 409); + } if (request.method === 'POST' && path === `${DEVICES_PATH}/revoke`) { const input = await body(request); if (typeof input['deviceId'] !== 'string') return json({ code: 'device-unknown' }, 400); diff --git a/packages/core/src/devices/mount.ts b/packages/core/src/devices/mount.ts index 5e2bcf1ffc..5d56d0ab2b 100644 --- a/packages/core/src/devices/mount.ts +++ b/packages/core/src/devices/mount.ts @@ -1,3 +1,6 @@ +import { createNoiseIdentity } from './noise-identity.js'; +import { createEncryptedGateway } from './encrypted-gateway.js'; +import { CONNECT_CHANNEL_PATH } from './noise/channel.js'; import type { Context } from '@deepseek-ai/cordis'; import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -155,6 +158,44 @@ export function mountDevicePairing( ctx.inject(['connection', 'webServer'], (webCtx) => { const connection = (webCtx as unknown as { connection: ConnectionLike }).connection; const webServer = (webCtx as unknown as { webServer: WebServerLike }).webServer; + const identity = createNoiseIdentity(options.credentials ?? (() => undefined)); + let channels = { negotiating: 0, encrypted: 0 }; + const encryption = async () => ({ + ...(await identity.publicIdentity()), + state: + channels.encrypted > 0 + ? ('encrypted' as const) + : channels.negotiating > 0 + ? ('negotiating' as const) + : ('disconnected' as const), + }); + const encrypted = createEncryptedGateway({ + pairing, + key: identity.key, + port: () => webServer.port, + changed: (state) => { + channels = state; + }, + verifyEnrollment: (body, signature) => account.verifyOffer(body, signature), + log: (phase) => write('device-encryption', phase, `device-encryption phase=${phase}`), + }); + webCtx.effect( + () => () => { + encrypted.close(); + identity.close(); + }, + 'botharness: encrypted device channels', + ); + webCtx.effect( + () => + webServer.registerUpgrade({ + path: CONNECT_CHANNEL_PATH, + handler: (request, socket, head) => { + void encrypted.upgrade(request, socket, head); + }, + }), + 'botharness: encrypted transport', + ); const connect = createDeviceConnect({ credentials: options.credentials ?? (() => undefined), port: () => webServer.port, @@ -185,6 +226,7 @@ export function mountDevicePairing( }; const http = createDevicesHttp({ pairing, + encryption, routes, connect, account, @@ -194,6 +236,7 @@ export function mountDevicePairing( [DEVICES_PATH, ['GET']], [`${DEVICES_PATH}/offer`, ['POST']], [`${DEVICES_PATH}/offer-status`, ['POST']], + [`${DEVICES_PATH}/approve-key`, ['POST']], [`${DEVICES_PATH}/revoke`, ['POST']], [`${DEVICES_PATH}/connect`, ['POST']], ] as const) @@ -231,7 +274,32 @@ export function mountDevicePairing( webServer.register({ kind: 'prefix', path: DEVICE_GATEWAY_PREFIX, - handler: (request, response) => gateway.handle(request, response), + handler: async (request, response) => { + if (request.url === CONNECT_CHANNEL_PATH) { + if (request.method !== 'GET') { + response.writeHead(405); + response.end(); + return; + } + try { + const identity = await encryption(); + response.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'access-control-allow-origin': '*', + }); + response.end(JSON.stringify({ host: pairing.host(), encryption: identity })); + } catch { + response.writeHead(503, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify({ code: 'host-key-unavailable' })); + } + return; + } + await gateway.handle(request, response); + }, }), 'botharness: device gateway', ); diff --git a/packages/core/src/devices/noise-identity.ts b/packages/core/src/devices/noise-identity.ts new file mode 100644 index 0000000000..6ad7ef336c --- /dev/null +++ b/packages/core/src/devices/noise-identity.ts @@ -0,0 +1,77 @@ +import { randomBytes } from 'node:crypto'; +import { credentialKey, type CredentialProvider } from '@deepseek-ai/dsh-credentials'; +import { noiseFingerprint, noisePublicKey, NOISE_PROTOCOL } from './noise/xk.js'; +import { CONNECT_PROTOCOL } from './noise/channel.js'; + +export interface EncryptionIdentity { + protocol: typeof CONNECT_PROTOCOL; + noise: typeof NOISE_PROTOCOL; + required: true; + publicKey: string; + fingerprint: string; +} + +export function createNoiseIdentity( + credentials: () => Pick | undefined, +) { + let pending: Promise | undefined; + let closed = false; + const key = credentialKey('botharness-core', 'connect-noise-host-key'); + const load = (): Promise => { + if (closed) return Promise.reject(new Error('channel-closed')); + pending ??= (async () => { + const provider = credentials(); + if (!provider) throw new Error('credential-unavailable'); + const stored = await provider.modifyRecord( + key, + async (current) => + current ?? { + kind: 'grant', + payload: { version: 1, privateKey: randomBytes(32).toString('hex') }, + }, + ); + const payload = + stored?.kind === 'grant' + ? (stored.payload as { version?: unknown; privateKey?: unknown }) + : undefined; + if ( + payload?.version !== 1 || + typeof payload.privateKey !== 'string' || + !/^[0-9a-f]{64}$/u.test(payload.privateKey) + ) + throw new Error('host-key-invalid'); + const secret = Uint8Array.from(Buffer.from(payload.privateKey, 'hex')); + if (closed) { + secret.fill(0); + throw new Error('channel-closed'); + } + return secret; + })().catch((error) => { + pending = undefined; + throw error; + }); + return pending; + }; + return { + async key(): Promise { + return (await load()).slice(); + }, + async publicIdentity(): Promise { + const publicKey = noisePublicKey(await load()); + return { + protocol: CONNECT_PROTOCOL, + noise: NOISE_PROTOCOL, + required: true, + publicKey: Buffer.from(publicKey).toString('hex'), + fingerprint: noiseFingerprint(publicKey), + }; + }, + close(): void { + closed = true; + void pending?.then( + (value) => value.fill(0), + () => {}, + ); + }, + }; +} diff --git a/packages/core/src/devices/noise/channel.ts b/packages/core/src/devices/noise/channel.ts new file mode 100644 index 0000000000..48db033bbc --- /dev/null +++ b/packages/core/src/devices/noise/channel.ts @@ -0,0 +1,180 @@ +import { bytesToHex, hexToBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'; +import { NoiseXk, NOISE_PROTOCOL, type NoiseTransport } from './xk.js'; + +export const CONNECT_PROTOCOL = 'connect-noise-xk-v1'; +export const CONNECT_CHANNEL_PATH = '/botharness-device/channel'; +export const CONNECT_MESSAGE_LIMIT = 8 * 1024 * 1024; +const CHUNK = 32 * 1024; +const HEADER = 15; +const DOMAIN = utf8ToBytes(`${CONNECT_PROTOCOL}/frame`); +const MAX_FRAMES = 1n << 32n; + +export interface ChannelResult { + reply?: string; + message?: string; + ready?: true; +} + +export class ConnectChannel { + private readonly noise: NoiseXk; + private readonly role: 'initiator' | 'responder'; + private stage = 0; + private closed = false; + private transport: NoiseTransport | undefined; + private binding: Uint8Array | undefined; + private peer: Uint8Array | undefined; + private outgoing = 0n; + private incoming = 0n; + private chunks: Uint8Array[] = []; + private size = 0; + + constructor(options: { + role: 'initiator' | 'responder'; + hostId: string; + staticKey: Uint8Array; + ephemeralKey: Uint8Array; + hostKey: Uint8Array; + }) { + this.role = options.role; + this.noise = new NoiseXk({ + ...options, + remoteStatic: options.hostKey, + prologue: utf8ToBytes( + JSON.stringify([CONNECT_PROTOCOL, NOISE_PROTOCOL, options.hostId, 'connect']), + ), + }); + } + start(): string { + if (this.closed || this.stage !== 0 || this.role !== 'initiator') + throw new Error('channel-state-invalid'); + this.stage = 1; + return `h${bytesToHex(this.noise.write())}`; + } + peerKey(): Uint8Array { + if (this.closed || !this.peer) throw new Error('channel-not-ready'); + return this.peer.slice(); + } + private finish(): void { + this.binding = this.noise.handshakeHash(); + this.peer = this.noise.peerKey(); + this.transport = this.noise.split(); + } + receive(wire: string): ChannelResult { + try { + if ( + this.closed || + wire.length > 2 * (HEADER + CHUNK + 17) + 1 || + !/^[hd][0-9a-f]+$/u.test(wire) || + wire.length % 2 !== 1 + ) + throw new Error('channel-frame-invalid'); + if (!this.transport) { + const expected = this.role === 'responder' && this.stage === 1 ? 64 : 48; + if ( + wire[0] !== 'h' || + wire.length !== expected * 2 + 1 || + (this.role === 'initiator' && this.stage !== 1) + ) + throw new Error('channel-handshake-invalid'); + if (this.noise.read(hexToBytes(wire.slice(1))).length !== 0) + throw new Error('channel-early-payload'); + if (this.role === 'responder' && this.stage === 0) { + this.stage = 1; + return { reply: `h${bytesToHex(this.noise.write())}` }; + } + const reply = this.role === 'initiator' ? `h${bytesToHex(this.noise.write())}` : undefined; + this.finish(); + return { ...(reply === undefined ? {} : { reply }), ready: true }; + } + if (wire[0] !== 'd') throw new Error('channel-frame-invalid'); + const frame = hexToBytes(wire.slice(1)); + if (frame.length < HEADER + 17) throw new Error('channel-length-invalid'); + const header = frame.subarray(0, HEADER); + const view = new DataView(header.buffer, header.byteOffset, header.byteLength); + if ( + header[0] !== 1 || + header[1] !== 1 || + header[2] !== (this.role === 'initiator' ? 1 : 0) || + this.incoming >= MAX_FRAMES || + view.getBigUint64(3, true) !== this.incoming || + view.getUint32(11, false) !== frame.length - HEADER + ) + throw new Error('channel-sequence-invalid'); + const plaintext = this.transport.decrypt( + frame.subarray(HEADER), + concatBytes(DOMAIN, this.binding!, header), + ); + this.incoming++; + const flag = plaintext[0]; + if ( + (flag !== 0 && flag !== 1 && flag !== 2 && flag !== 3) || + ((flag === 1 || flag === 2) && plaintext.length !== CHUNK + 1) || + (flag === 3 && plaintext.length < 2) || + (flag === 0 || flag === 1) !== (this.chunks.length === 0) + ) + throw new Error('channel-chunk-invalid'); + this.size += plaintext.length - 1; + if (this.size > CONNECT_MESSAGE_LIMIT) throw new Error('channel-message-too-large'); + this.chunks.push(plaintext.subarray(1)); + if (flag === 1 || flag === 2) return {}; + const body = concatBytes(...this.chunks); + for (const chunk of this.chunks) chunk.fill(0); + this.chunks = []; + this.size = 0; + try { + return { message: new TextDecoder('utf-8', { fatal: true }).decode(body) }; + } finally { + body.fill(0); + } + } catch (error) { + this.close(); + throw error; + } + } + send(message: string): string[] { + try { + if (this.closed || !this.transport) throw new Error('channel-not-ready'); + if (message.length > CONNECT_MESSAGE_LIMIT) throw new Error('channel-message-too-large'); + const body = utf8ToBytes(message); + if (body.length > CONNECT_MESSAGE_LIMIT) throw new Error('channel-message-too-large'); + const result: string[] = []; + try { + for ( + let offset = 0; + offset < body.length || (offset === 0 && body.length === 0); + offset += CHUNK + ) { + if (this.outgoing >= MAX_FRAMES) throw new Error('channel-counter-exhausted'); + const part = body.subarray(offset, offset + CHUNK); + const last = offset + CHUNK >= body.length; + const flag = offset === 0 ? (last ? 0 : 1) : last ? 3 : 2; + const header = new Uint8Array(HEADER); + header.set([1, 1, this.role === 'initiator' ? 0 : 1]); + const view = new DataView(header.buffer); + view.setBigUint64(3, this.outgoing, true); + view.setUint32(11, part.length + 17, false); + const encrypted = this.transport.encrypt( + concatBytes(new Uint8Array([flag]), part), + concatBytes(DOMAIN, this.binding!, header), + ); + this.outgoing++; + result.push(`d${bytesToHex(concatBytes(header, encrypted))}`); + } + return result; + } finally { + body.fill(0); + } + } catch (error) { + this.close(); + throw error; + } + } + close(): void { + this.closed = true; + this.noise.close(); + this.transport?.close(); + for (const chunk of this.chunks) chunk.fill(0); + this.chunks = []; + this.size = 0; + } +} diff --git a/packages/core/src/devices/noise/xk.ts b/packages/core/src/devices/noise/xk.ts new file mode 100644 index 0000000000..681ac2982d --- /dev/null +++ b/packages/core/src/devices/noise/xk.ts @@ -0,0 +1,224 @@ +import { x25519 } from '@noble/curves/ed25519.js'; +import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'; + +export const NOISE_PROTOCOL = 'Noise_XK_25519_ChaChaPoly_SHA256'; +export const NOISE_LIMIT = 65_535; +const EMPTY = new Uint8Array(); +const MAX_NONCE = (1n << 64n) - 1n; + +export function noisePublicKey(privateKey: Uint8Array): Uint8Array { + return x25519.getPublicKey(privateKey); +} + +export function noiseFingerprint(publicKey: Uint8Array): string { + if (publicKey.length !== 32) throw new Error('noise-key-invalid'); + return Array.from(sha256(publicKey), (value) => value.toString(16).padStart(2, '0')) + .join('') + .match(/.{4}/gu)! + .join(' '); +} + +function derive(chainingKey: Uint8Array, input: Uint8Array): [Uint8Array, Uint8Array] { + const output = hkdf(sha256, input, chainingKey, EMPTY, 64); + const keys: [Uint8Array, Uint8Array] = [output.slice(0, 32), output.slice(32)]; + output.fill(0); + return keys; +} + +class Cipher { + private key: Uint8Array; + private nonce = 0n; + private closed = false; + constructor(key: Uint8Array) { + this.key = key; + } + run(input: Uint8Array, aad: Uint8Array, decrypt: boolean): Uint8Array { + if (this.closed || this.nonce >= MAX_NONCE) throw new Error('noise-closed'); + const nonce = new Uint8Array(12); + new DataView(nonce.buffer).setBigUint64(4, this.nonce, true); + try { + const cipher = chacha20poly1305(this.key, nonce, aad); + const output = decrypt ? cipher.decrypt(input) : cipher.encrypt(input); + this.nonce++; + return output; + } catch { + this.close(); + throw new Error('noise-authentication-failed'); + } + } + close(): void { + this.key.fill(0); + this.closed = true; + } +} + +export interface NoiseTransport { + encrypt(plaintext: Uint8Array, aad?: Uint8Array): Uint8Array; + decrypt(ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array; + close(): void; +} + +export class NoiseXk { + private readonly role: 'initiator' | 'responder'; + private readonly staticKey: Uint8Array; + private readonly ephemeralKey: Uint8Array; + private remoteStatic: Uint8Array | undefined; + private remoteEphemeral: Uint8Array | undefined; + private hash: Uint8Array; + private chainingKey: Uint8Array; + private cipher: Cipher | undefined; + private flight = 0; + private closed = false; + private splitDone = false; + + constructor(options: { + role: 'initiator' | 'responder'; + staticKey: Uint8Array; + ephemeralKey: Uint8Array; + remoteStatic?: Uint8Array; + prologue: Uint8Array; + }) { + if ( + options.staticKey.length !== 32 || + options.ephemeralKey.length !== 32 || + (options.role === 'initiator' && options.remoteStatic?.length !== 32) + ) + throw new Error('noise-key-invalid'); + this.role = options.role; + this.staticKey = Uint8Array.from(options.staticKey); + this.ephemeralKey = Uint8Array.from(options.ephemeralKey); + this.remoteStatic = + options.remoteStatic === undefined ? undefined : Uint8Array.from(options.remoteStatic); + this.hash = utf8ToBytes(NOISE_PROTOCOL); + this.chainingKey = this.hash.slice(); + this.mixHash(options.prologue); + this.mixHash(this.role === 'initiator' ? this.remoteStatic! : noisePublicKey(this.staticKey)); + } + + private mixHash(value: Uint8Array): void { + this.hash = sha256(concatBytes(this.hash, value)); + } + private mixDh(local: Uint8Array, remote: Uint8Array): void { + const shared = x25519.getSharedSecret(local, remote); + try { + const [chain, key] = derive(this.chainingKey, shared); + this.chainingKey.fill(0); + this.chainingKey = chain; + this.cipher?.close(); + this.cipher = new Cipher(key); + } finally { + shared.fill(0); + } + } + private crypt(value: Uint8Array, decrypt: boolean): Uint8Array { + if (this.cipher === undefined) throw new Error('noise-state-invalid'); + const result = this.cipher.run(value, this.hash, decrypt); + this.mixHash(decrypt ? value : result); + return result; + } + private guard(write: boolean): void { + const initiatorWrites = this.flight !== 1; + if ( + this.closed || + this.flight >= 3 || + (this.role === 'initiator') !== (write === initiatorWrites) + ) + throw new Error('noise-state-invalid'); + } + write(payload: Uint8Array = EMPTY): Uint8Array { + try { + this.guard(true); + const overhead = this.flight === 2 ? 64 : 48; + if (payload.length > NOISE_LIMIT - overhead) throw new Error('noise-length-invalid'); + let prefix: Uint8Array; + if (this.flight < 2) { + prefix = noisePublicKey(this.ephemeralKey); + this.mixHash(prefix); + this.mixDh( + this.ephemeralKey, + this.flight === 0 ? this.remoteStatic! : this.remoteEphemeral!, + ); + } else { + prefix = this.crypt(noisePublicKey(this.staticKey), false); + this.mixDh(this.staticKey, this.remoteEphemeral!); + } + const message = concatBytes(prefix, this.crypt(payload, false)); + this.flight++; + return message; + } catch (error) { + this.close(); + throw error; + } + } + read(message: Uint8Array): Uint8Array { + try { + this.guard(false); + const prefixLength = this.flight === 2 ? 48 : 32; + if (message.length < prefixLength + 16 || message.length > NOISE_LIMIT) + throw new Error('noise-length-invalid'); + const prefix = message.slice(0, prefixLength); + if (this.flight < 2) { + this.remoteEphemeral = prefix; + this.mixHash(prefix); + this.mixDh(this.flight === 0 ? this.staticKey : this.ephemeralKey, prefix); + } else { + this.remoteStatic = this.crypt(prefix, true); + this.mixDh(this.ephemeralKey, this.remoteStatic); + } + const payload = this.crypt(message.subarray(prefixLength), true); + this.flight++; + return payload; + } catch (error) { + this.close(); + throw error; + } + } + handshakeHash(): Uint8Array { + if (this.flight !== 3 || (this.closed && !this.splitDone)) throw new Error('noise-not-ready'); + return this.hash.slice(); + } + peerKey(): Uint8Array { + if (this.flight !== 3 || this.closed || !this.remoteStatic) throw new Error('noise-not-ready'); + return this.remoteStatic.slice(); + } + split(): NoiseTransport { + if (this.flight !== 3 || this.closed) throw new Error('noise-not-ready'); + const [first, second] = derive(this.chainingKey, EMPTY); + const sending = new Cipher(this.role === 'initiator' ? first : second); + const receiving = new Cipher(this.role === 'initiator' ? second : first); + this.splitDone = true; + this.close(); + const close = () => { + sending.close(); + receiving.close(); + }; + const run = (input: Uint8Array, aad: Uint8Array, decrypt: boolean) => { + try { + if ( + input.length > (decrypt ? NOISE_LIMIT : NOISE_LIMIT - 16) || + (decrypt && input.length < 16) + ) + throw new Error('noise-length-invalid'); + return (decrypt ? receiving : sending).run(input, aad, decrypt); + } catch (error) { + close(); + throw error; + } + }; + return { + encrypt: (plaintext, aad = EMPTY) => run(plaintext, aad, false), + decrypt: (ciphertext, aad = EMPTY) => run(ciphertext, aad, true), + close, + }; + } + close(): void { + this.closed = true; + this.staticKey.fill(0); + this.ephemeralKey.fill(0); + this.chainingKey.fill(0); + this.cipher?.close(); + } +} diff --git a/packages/core/src/devices/pairing.ts b/packages/core/src/devices/pairing.ts index aabd30f8ef..ef1f092460 100644 --- a/packages/core/src/devices/pairing.ts +++ b/packages/core/src/devices/pairing.ts @@ -16,6 +16,7 @@ export interface HostIdentity { export interface PairedDevice { id: string; name: string; + publicKey?: string; pairedAt: string; lastSeenAt?: string; revokedAt?: string; @@ -28,12 +29,19 @@ export interface PairingOffer { } export type OfferStatus = - | { receipt: string; state: 'pending'; expiresAt: string } + | { + receipt: string; + state: 'pending'; + expiresAt: string; + publicKey?: string; + name?: string; + approved?: boolean; + } | { receipt: string; state: 'redeemed'; deviceId: string; settledAt: string } | { receipt: string; state: 'expired' | 'refused' | 'superseded'; settledAt: string } | { receipt: string; state: 'unknown' }; -export type OfferRefusal = 'offer-invalid' | 'offer-expired'; +export type OfferRefusal = 'offer-invalid' | 'offer-expired' | 'device-approval-required'; export type DeviceRefusal = 'device-credential-missing' | 'device-unknown' | 'device-revoked'; export type Redemption = @@ -48,11 +56,34 @@ export type DevicePairingEvent = | { phase: 'device-refused'; code: DeviceRefusal; deviceId?: string } | { phase: 'device-revoked'; deviceId: string; closedStreams: number }; +export interface PendingDeviceKey { + receipt: string; + state: 'pending'; + expiresAt: string; + publicKey: string; + name: string; + approved: boolean; +} + export interface DevicePairing { host(): HostIdentity; createOffer(binding?: { verifierHash: string; name?: string }): PairingOffer; offerStatus(receipt: string): OfferStatus; - redeem(input: { offer: string; name?: string; verifier?: string }): Redemption; + redeem(input: { + offer: string; + name?: string; + verifier?: string; + publicKey?: string; + }): Redemption; + requestKey(input: { + offer: string; + publicKey: string; + name?: string; + verifier?: string; + }): { ok: true; receipt: string } | { ok: false; code: OfferRefusal }; + pendingKeys(): PendingDeviceKey[]; + approveKey(receipt: string, publicKey: string): boolean; + admitKey(authorization: string | undefined, publicKey: string): Admission; admit(authorization: string | undefined): Admission; check(deviceId: string): Admission; list(): PairedDevice[]; @@ -73,6 +104,7 @@ interface Row { device_id: string; name: string; secret_hash: string; + public_key: string | null; paired_at: string; last_seen_at: string | null; revoked_at: string | null; @@ -88,6 +120,7 @@ function device(row: Row): PairedDevice { id: row.device_id, name: row.name, pairedAt: row.paired_at, + ...(row.public_key === null ? {} : { publicKey: row.public_key }), ...(row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at }), ...(row.revoked_at === null ? {} : { revokedAt: row.revoked_at }), }; @@ -109,7 +142,14 @@ export function createDevicePairing(options: DevicePairingOptions): DevicePairin const offers = new Map< string, - { expiresAt: number; receipt: string; verifierHash?: string; name?: string } + { + expiresAt: number; + receipt: string; + verifierHash?: string; + name?: string; + publicKey?: string; + approved?: boolean; + } >(); const settled = new Map(); const streams = new Map void>>(); @@ -214,13 +254,86 @@ export function createDevicePairing(options: DevicePairingOptions): DevicePairin sweep(); for (const open of offers.values()) if (open.receipt === receipt) - return { receipt, state: 'pending', expiresAt: iso(open.expiresAt) }; + return { + receipt, + state: 'pending', + expiresAt: iso(open.expiresAt), + ...(open.publicKey === undefined + ? {} + : { + publicKey: open.publicKey, + name: open.name ?? 'Phone', + approved: open.approved === true, + }), + }; return settled.get(receipt) ?? { receipt, state: 'unknown' }; }, + requestKey(input) { + sweep(); + const open = offers.get(digest(input.offer).toString('hex')); + if ( + !open || + !/^[0-9a-f]{64}$/u.test(input.publicKey) || + (open.publicKey !== undefined && open.publicKey !== input.publicKey) || + (open.verifierHash !== undefined && + (input.verifier === undefined || + digest(input.verifier).toString('base64url') !== open.verifierHash)) + ) + return { ok: false, code: 'offer-invalid' }; + if (open.publicKey === undefined) { + open.publicKey = input.publicKey; + open.name = deviceName(input.name ?? open.name); + open.approved = false; + } + return { ok: true, receipt: open.receipt }; + }, + + pendingKeys() { + sweep(); + return [...offers.values()] + .filter((open) => open.publicKey !== undefined) + .map((open) => ({ + receipt: open.receipt, + state: 'pending' as const, + expiresAt: iso(open.expiresAt), + publicKey: open.publicKey!, + name: open.name ?? 'Phone', + approved: open.approved === true, + })); + }, + + approveKey(receipt, publicKey) { + sweep(); + for (const open of offers.values()) { + if ( + open.receipt === receipt && + open.publicKey === publicKey && + /^[0-9a-f]{64}$/u.test(publicKey) + ) { + open.approved = true; + return true; + } + } + return false; + }, + + admitKey(authorization, publicKey) { + const admitted = this.admit(authorization); + if (!admitted.ok) return admitted; + if (admitted.device.publicKey !== publicKey) return { ok: false, code: 'device-unknown' }; + return admitted; + }, + redeem(input) { const key = digest(input.offer).toString('hex'); const open = offers.get(key); + if ( + open !== undefined && + (open.publicKey !== undefined || input.publicKey !== undefined) && + (open.publicKey !== input.publicKey || open.approved !== true) + ) + return { ok: false, code: 'device-approval-required' }; offers.delete(key); if ( open === undefined || @@ -245,9 +358,9 @@ export function createDevicePairing(options: DevicePairingOptions): DevicePairin (connection) => connection .prepare( - 'INSERT INTO paired_devices (device_id, name, secret_hash, paired_at) VALUES (?, ?, ?, ?)', + 'INSERT INTO paired_devices (device_id, name, secret_hash, paired_at, public_key) VALUES (?, ?, ?, ?, ?)', ) - .run(id, name, digest(secret).toString('hex'), pairedAt), + .run(id, name, digest(secret).toString('hex'), pairedAt, input.publicKey ?? null), ['paired-devices'], ); settle(open.receipt, { state: 'redeemed', deviceId: id }); @@ -255,7 +368,12 @@ export function createDevicePairing(options: DevicePairingOptions): DevicePairin options.onPaired?.({ id, name, pairedAt }); return { ok: true, - device: { id, name, pairedAt }, + device: { + id, + name, + pairedAt, + ...(input.publicKey === undefined ? {} : { publicKey: input.publicKey }), + }, credential: `${id}.${secret}`, host: host(), }; diff --git a/packages/core/test/cli-connect.test.ts b/packages/core/test/cli-connect.test.ts index 43bb8b0e93..abfa0e732f 100644 --- a/packages/core/test/cli-connect.test.ts +++ b/packages/core/test/cli-connect.test.ts @@ -28,7 +28,15 @@ describe('connect CLI', () => { host: {}, devices: [{ id: 'd' }], routes: { routes: [] }, - connect: { state: 'off' }, + connect: { + state: 'off', + encryption: { + protocol: 'connect-noise-xk-v1', + required: true, + state: 'disconnected', + fingerprint: 'public-fingerprint', + }, + }, }), ); expect( @@ -36,7 +44,15 @@ describe('connect CLI', () => { http, })), ).toEqual({ - connect: { state: 'off' }, + connect: { + state: 'off', + encryption: { + protocol: 'connect-noise-xk-v1', + required: true, + state: 'disconnected', + fingerprint: 'public-fingerprint', + }, + }, routes: { routes: [] }, }); expect(http.mock.calls[0]).toEqual(['/api/botharness/devices']); diff --git a/packages/core/test/cli-devices.test.ts b/packages/core/test/cli-devices.test.ts index 98171b7a78..ff1ae8c4e0 100644 --- a/packages/core/test/cli-devices.test.ts +++ b/packages/core/test/cli-devices.test.ts @@ -9,6 +9,27 @@ const reply = (value: unknown) => ({ const LINK = 'deepseekbot://pair?p=c2VjcmV0LW9mZmVy'; describe('devices CLI', () => { + it('requires explicit confirmation and sends the exact key with its offer receipt', async () => { + const http = vi.fn(async () => reply({ approved: true })); + const key = 'ab'.repeat(32); + await expect( + runDevicesCommand('device-approve-key', ['receipt', key], {}, async () => ({ http })), + ).rejects.toThrow('confirm'); + expect(http).not.toHaveBeenCalled(); + expect( + await runDevicesCommand( + 'device-approve-key', + ['receipt', key], + { confirm: true }, + async () => ({ http }), + ), + ).toEqual({ approved: true }); + expect(http.mock.calls[0]).toEqual([ + '/api/botharness/devices/approve-key', + { receipt: 'receipt', publicKey: key, confirm: true }, + ]); + }); + it('lists devices with the Host identity and routes, without Connect state', async () => { const http = vi.fn(async () => reply({ diff --git a/packages/core/test/connect-channel.test.ts b/packages/core/test/connect-channel.test.ts new file mode 100644 index 0000000000..9258bb54d9 --- /dev/null +++ b/packages/core/test/connect-channel.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { ConnectChannel } from '../src/devices/noise/channel.js'; +import { noisePublicKey } from '../src/devices/noise/xk.js'; + +function pair() { + const hostKey = randomBytes(32); + const deviceKey = randomBytes(32); + const a = new ConnectChannel({ + role: 'initiator', + hostId: 'host', + staticKey: deviceKey, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + const b = new ConnectChannel({ + role: 'responder', + hostId: 'host', + staticKey: hostKey, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + const one = a.start(); + const two = b.receive(one).reply!; + const three = a.receive(two).reply!; + b.receive(three); + return { a, b }; +} + +describe('Connect encrypted wire', () => { + it('carries a chunked message and closes permanently on a repeated frame', () => { + const { a, b } = pair(); + const message = JSON.stringify({ secret: 'sentinel-secret', image: 'x'.repeat(200_000) }); + const frames = a.send(message); + expect(frames.length).toBeGreaterThan(1); + expect(frames.join('')).not.toContain('sentinel-secret'); + const messages = frames.flatMap((frame) => b.receive(frame).message ?? []); + expect(messages).toEqual([message]); + expect(() => b.receive(frames[0]!)).toThrow(); + expect(() => b.send('anything')).toThrow(); + }); +}); + +it.each(['truncate', 'ciphertext', 'direction', 'counter', 'length', 'oversize'] as const)( + 'closes on %s and cannot resume the session', + (fault) => { + const { a, b } = pair(); + let frame = a.send('sensitive')[0]!; + if (fault === 'truncate') frame = frame.slice(0, -2); + if (fault === 'ciphertext') frame = frame.slice(0, -2) + (frame.endsWith('00') ? '01' : '00'); + if (fault === 'direction') frame = frame.slice(0, 5) + '01' + frame.slice(7); + if (fault === 'counter') frame = frame.slice(0, 7) + '01' + frame.slice(9); + if (fault === 'length') frame = frame.slice(0, 23) + 'ff' + frame.slice(25); + if (fault === 'oversize') frame = 'd' + '00'.repeat(70_000); + expect(() => b.receive(frame)).toThrow(); + expect(() => b.receive(a.send('retry')[0]!)).toThrow(); + a.close(); + b.close(); + }, +); + +it('rejects ciphertext copied from a different session', () => { + const first = pair(), + second = pair(); + expect(() => second.b.receive(first.a.send('private')[0]!)).toThrow(); + first.a.close(); + first.b.close(); + second.a.close(); + second.b.close(); +}); + +it('does not accept application messages before handshake completion or a substituted Host pin', () => { + const hostKey = randomBytes(32); + const a = new ConnectChannel({ + role: 'initiator', + hostId: 'host', + staticKey: randomBytes(32), + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(randomBytes(32)), + }); + const b = new ConnectChannel({ + role: 'responder', + hostId: 'host', + staticKey: hostKey, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + expect(() => b.receive(a.start())).toThrow(); + const early = new ConnectChannel({ + role: 'initiator', + hostId: 'host', + staticKey: randomBytes(32), + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + expect(() => early.send('secret')).toThrow(); + a.close(); + b.close(); + early.close(); +}); + +it('rejects a handshake from a different Host identity and nonempty early payloads', () => { + const hostKey = randomBytes(32); + const fresh = (hostId: string, role: 'initiator' | 'responder') => + new ConnectChannel({ + role, + hostId, + staticKey: role === 'responder' ? hostKey : randomBytes(32), + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + const wrong = fresh('other-host', 'initiator'), + host = fresh('host', 'responder'); + expect(() => host.receive(wrong.start())).toThrow(); + wrong.close(); + host.close(); + const empty = fresh('host', 'initiator'), + target = fresh('host', 'responder'); + expect(() => target.receive(empty.start() + '00')).toThrow(); + empty.close(); + target.close(); + const lowOrder = fresh('host', 'responder'); + expect(() => lowOrder.receive('h' + '00'.repeat(48))).toThrow(); + lowOrder.close(); +}); diff --git a/packages/core/test/connect-encrypted-gateway.test.ts b/packages/core/test/connect-encrypted-gateway.test.ts new file mode 100644 index 0000000000..dd22d91db4 --- /dev/null +++ b/packages/core/test/connect-encrypted-gateway.test.ts @@ -0,0 +1,408 @@ +import { encryptedCarrier } from '../../../apps/mobile/src/host/encrypted-carrier.js'; +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { WebSocket, WebSocketServer } from 'ws'; +import { expect, it } from 'vitest'; +import { createEncryptedGateway } from '../src/devices/encrypted-gateway.js'; +import { ConnectChannel } from '../src/devices/noise/channel.js'; +import { noisePublicKey } from '../src/devices/noise/xk.js'; +import { createDevicePairing } from '../src/devices/pairing.js'; +import { attachOperationalModule, mountOperationalDatabase } from '../src/database/owner.js'; +import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; +import { createTempRoot, trackTestOwner } from './helpers.js'; + +it('admits only the approved Noise key, forwards private RPC and push settings, and closes on device revocation', async () => { + const owner = trackTestOwner( + mountOperationalDatabase({ + dshHome: createTempRoot('noise-gateway-'), + schemaPlan: BOT_HARNESS_SCHEMA_PLAN, + }), + ); + const pairing = createDevicePairing({ + database: attachOperationalModule(owner, 'device-pairing'), + hostName: 'Host', + }); + const hostKey = randomBytes(32), + deviceKey = randomBytes(32); + const publicKey = Buffer.from(noisePublicKey(deviceKey)).toString('hex'); + const offer = pairing.createOffer(); + pairing.requestKey({ offer: offer.offer, publicKey }); + pairing.approveKey(offer.receipt, publicKey); + const paired = pairing.redeem({ offer: offer.offer, publicKey }); + if (!paired.ok) throw new Error('pairing failed'); + const observed: string[] = []; + const server = createServer(async (request, response) => { + expect(request.headers.authorization).toBe(`Device ${paired.credential}`); + if (request.url === '/botharness-device/push') { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + registered: false, + enabled: false, + preferences: { approval: true, question: true, 'private-input': true, reply: true }, + quietChannelIds: [], + }), + ); + return; + } + let body = ''; + for await (const chunk of request) body += String(chunk); + response.end(body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const gateway = createEncryptedGateway({ + pairing, + key: async () => Uint8Array.from(hostKey), + port: () => port, + }); + server.on('upgrade', (req, socket, head) => gateway.upgrade(req, socket, head)); + const channel = new ConnectChannel({ + role: 'initiator', + hostId: pairing.host().id, + staticKey: deviceKey, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + const socket = new WebSocket(`ws://127.0.0.1:${port}/botharness-device/channel`); + const answers: Record[] = []; + let next: (() => void) | undefined; + socket.on('message', (data) => { + const wire = data.toString(); + observed.push(wire); + const result = channel.receive(wire); + if (result.reply) socket.send(result.reply); + if (result.ready) + for (const frame of channel.send( + JSON.stringify({ type: 'admit', credential: paired.credential }), + )) + socket.send(frame); + if (result.message) { + answers.push(JSON.parse(result.message)); + next?.(); + } + }); + const wait = async () => { + if (answers.length) return answers.shift()!; + await new Promise((resolve) => { + next = resolve; + }); + next = undefined; + return answers.shift()!; + }; + try { + await new Promise((resolve) => socket.once('open', resolve)); + socket.send(channel.start()); + expect(await wait()).toMatchObject({ type: 'admitted' }); + const sentinel = 'disposable-private-value'; + for (const frame of channel.send( + JSON.stringify({ + type: 'request', + id: '1', + method: 'POST', + path: '/botharness-device/api/botharness/privateInputSubmit', + body: sentinel, + }), + )) { + observed.push(frame); + socket.send(frame); + } + const answer = await wait(); + expect(Buffer.from(String(answer.body), 'base64').toString()).toBe(sentinel); + expect(observed.join('')).not.toContain(sentinel); + const carrier = encryptedCarrier({ + hostId: pairing.host().id, + credential: paired.credential, + encryption: { + protocol: 'connect-noise-xk-v1', + hostKey: Buffer.from(noisePublicKey(hostKey)).toString('hex'), + deviceKey: deviceKey.toString('hex'), + }, + raw: { + request: async () => { + throw new Error('plaintext request forbidden'); + }, + openSocket: (url, events, headers) => { + expect(headers).toBeUndefined(); + expect(url).toBe('wss://host.deepseekbot.dev/botharness-device/channel'); + const peer = new WebSocket(`ws://127.0.0.1:${port}/botharness-device/channel`); + peer.on('open', events.open); + peer.on('message', (data) => events.message(data.toString())); + peer.on('close', () => events.closed('closed')); + peer.on('error', () => events.closed('error')); + return { + send: (text) => { + observed.push(text); + peer.send(text); + }, + close: () => peer.close(), + }; + }, + }, + }); + try { + const response = await carrier.request( + 'https://host.deepseekbot.dev/botharness-device/api/botharness/privateInputSubmit', + { + method: 'POST', + body: sentinel, + headers: { authorization: `Device ${paired.credential}` }, + signal: new AbortController().signal, + }, + ); + expect(await response.text()).toBe(sentinel); + const settings = await carrier.request( + 'https://host.deepseekbot.dev/botharness-device/push', + { + method: 'GET', + signal: new AbortController().signal, + }, + ); + expect(settings.status).toBe(200); + expect(JSON.parse(await settings.text())).toMatchObject({ + registered: false, + quietChannelIds: [], + }); + + expect(observed.join('')).not.toContain(paired.credential); + } finally { + carrier.close?.(); + } + const closed = new Promise((resolve) => socket.once('close', () => resolve())); + pairing.revoke(paired.device.id); + await closed; + } finally { + socket.terminate(); + channel.close(); + gateway.close(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +async function endpointFixture() { + const owner = trackTestOwner( + mountOperationalDatabase({ + dshHome: createTempRoot('noise-endpoint-'), + schemaPlan: BOT_HARNESS_SCHEMA_PLAN, + }), + ); + const pairing = createDevicePairing({ + database: attachOperationalModule(owner, 'device-pairing'), + hostName: 'Host', + }); + const hostKey = randomBytes(32), + deviceKey = randomBytes(32); + const publicKey = Buffer.from(noisePublicKey(deviceKey)).toString('hex'); + const offer = pairing.createOffer(); + pairing.requestKey({ offer: offer.offer, publicKey }); + pairing.approveKey(offer.receipt, publicKey); + const paired = pairing.redeem({ offer: offer.offer, publicKey }); + if (!paired.ok) throw new Error('pairing failed'); + const effects: string[] = []; + const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += String(chunk); + effects.push(body); + if (!body.startsWith('hold-')) response.end(body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const gateway = createEncryptedGateway({ + pairing, + key: async () => Uint8Array.from(hostKey), + port: () => port, + }); + const streams = new WebSocketServer({ noServer: true }); + server.on('upgrade', (req, socket, head) => { + if (req.url === '/botharness-device/api/remote.mux') { + streams.handleUpgrade(req, socket, head, (peer) => { + peer.on('message', (data) => peer.send(data.toString())); + }); + } else void gateway.upgrade(req, socket, head); + }); + const peers: { socket: WebSocket; channel: ConnectChannel }[] = []; + const connect = async (key = deviceKey) => { + const channel = new ConnectChannel({ + role: 'initiator', + hostId: pairing.host().id, + staticKey: key, + ephemeralKey: randomBytes(32), + hostKey: noisePublicKey(hostKey), + }); + const socket = new WebSocket(`ws://127.0.0.1:${port}/botharness-device/channel`); + peers.push({ socket, channel }); + const answers: Record[] = []; + const waiters: ((value: Record) => void)[] = []; + const closed = new Promise((resolve) => socket.once('close', () => resolve())); + const frames = (value: unknown) => channel.send(JSON.stringify(value)); + const send = (value: unknown) => { + for (const wire of frames(value)) socket.send(wire); + }; + socket.on('message', (data) => { + const result = channel.receive(data.toString()); + if (result.reply) socket.send(result.reply); + if (result.ready) send({ type: 'admit', credential: paired.credential }); + if (result.message) { + const value = JSON.parse(result.message) as Record; + const waiter = waiters.shift(); + if (waiter) waiter(value); + else answers.push(value); + } + }); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + socket.send(channel.start()); + const next = () => + answers.length + ? Promise.resolve(answers.shift()!) + : new Promise>((resolve) => waiters.push(resolve)); + return { socket, channel, frames, send, next, closed }; + }; + return { + effects, + connect, + pairing, + deviceId: paired.device.id, + async close() { + for (const { socket, channel } of peers) { + socket.terminate(); + channel.close(); + } + gateway.close(); + for (const peer of streams.clients) peer.terminate(); + streams.close(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +const endpointRequest = (id: string) => ({ + type: 'request', + id, + method: 'POST', + path: '/botharness-device/api/botharness/privateInputSubmit', + body: `effect-${id}`, +}); + +it.each(['tamper', 'replay', 'reconnect-replay'] as const)( + 'rejects %s at the receiving endpoint without an extra application effect', + async (fault) => { + const f = await endpointFixture(); + try { + let peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + let wire = peer.frames(endpointRequest('one'))[0]!; + if (fault !== 'tamper') { + peer.socket.send(wire); + expect(await peer.next()).toMatchObject({ type: 'response', id: 'one' }); + expect(f.effects).toEqual(['effect-one']); + } + if (fault === 'reconnect-replay') { + peer.socket.close(); + await peer.closed; + peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + } + if (fault === 'tamper') wire = wire.slice(0, -2) + (wire.endsWith('00') ? '01' : '00'); + peer.socket.send(wire); + await peer.closed; + expect(f.effects).toEqual(fault === 'tamper' ? [] : ['effect-one']); + } finally { + await f.close(); + } + }, +); + +it('keeps concurrent sends ordered and refuses a revoked device on a fresh channel', async () => { + const f = await endpointFixture(); + try { + const peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + await Promise.all( + Array.from({ length: 16 }, (_, i) => + Promise.resolve().then(() => peer.send(endpointRequest(String(i)))), + ), + ); + const replies = await Promise.all(Array.from({ length: 16 }, () => peer.next())); + expect(replies.map((r) => r.id).sort()).toEqual( + Array.from({ length: 16 }, (_, i) => String(i)).sort(), + ); + expect([...f.effects].sort()).toEqual( + Array.from({ length: 16 }, (_, i) => `effect-${i}`).sort(), + ); + f.pairing.revoke(f.deviceId); + await peer.closed; + const reconnect = await f.connect(); + expect(await reconnect.next()).toMatchObject({ type: 'refused' }); + await reconnect.closed; + expect(f.effects).toHaveLength(16); + } finally { + await f.close(); + } +}); + +it('refuses a substituted device key even with the valid device credential', async () => { + const f = await endpointFixture(); + try { + const peer = await f.connect(randomBytes(32)); + expect(await peer.next()).toMatchObject({ type: 'refused' }); + await peer.closed; + expect(f.effects).toEqual([]); + } finally { + await f.close(); + } +}); + +it('keeps a replacement logical stream alive when the old instance closes later', async () => { + const f = await endpointFixture(); + try { + const peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + const open = { type: 'open', id: 'stream', path: '/botharness-device/api/remote.mux' }; + peer.send(open); + expect(await peer.next()).toMatchObject({ type: 'opened', id: 'stream' }); + peer.send({ type: 'close', id: 'stream' }); + peer.send(open); + expect(await peer.next()).toMatchObject({ type: 'opened', id: 'stream' }); + peer.send({ type: 'send', id: 'stream', text: 'replacement' }); + expect(await peer.next()).toMatchObject({ type: 'message', id: 'stream', text: 'replacement' }); + expect(f.effects).toEqual([]); + } finally { + await f.close(); + } +}); + +it('bounds in-flight work for a slow upstream and closes without admitting excess work', async () => { + const f = await endpointFixture(); + try { + const peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + for (let i = 0; i < 33; i++) peer.send({ ...endpointRequest(String(i)), body: `hold-${i}` }); + await peer.closed; + expect(f.effects.length).toBeLessThanOrEqual(32); + expect(f.effects).not.toContain('hold-32'); + } finally { + await f.close(); + } +}); + +it('does not let a cancelled request callback consume its replacement request', async () => { + const f = await endpointFixture(); + try { + const peer = await f.connect(); + expect(await peer.next()).toMatchObject({ type: 'admitted' }); + peer.send({ ...endpointRequest('same'), body: 'hold-old' }); + await expect.poll(() => f.effects).toEqual(['hold-old']); + peer.send({ type: 'cancel', id: 'same' }); + peer.send(endpointRequest('same')); + const result = await peer.next(); + expect(result).toMatchObject({ type: 'response', id: 'same', status: 200 }); + expect(Buffer.from(String(result.body), 'base64').toString()).toBe('effect-same'); + expect(f.effects).toEqual(['hold-old', 'effect-same']); + } finally { + await f.close(); + } +}); diff --git a/packages/core/test/connect-identity.test.ts b/packages/core/test/connect-identity.test.ts new file mode 100644 index 0000000000..f8315d99f0 --- /dev/null +++ b/packages/core/test/connect-identity.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'; +import { createNoiseIdentity } from '../src/devices/noise-identity.js'; + +it('keeps one Host identity across concurrent reads and restart; never replaces a corrupt key', async () => { + type Entry = Awaited>; + let entry: Entry; + const provider: Pick = { + async modifyRecord(_key, mutate) { + entry = await mutate(entry); + return entry; + }, + }; + const first = createNoiseIdentity(() => provider); + const [a, b] = await Promise.all([first.publicIdentity(), first.publicIdentity()]); + expect(a).toEqual(b); + const copy = await first.key(); + copy.fill(0); + expect(await first.publicIdentity()).toEqual(a); + first.close(); + await expect(first.key()).rejects.toThrow('channel-closed'); + const restarted = createNoiseIdentity(() => provider); + expect(await restarted.publicIdentity()).toEqual(a); + restarted.close(); + entry = { kind: 'grant', payload: { version: 1, privateKey: 'corrupt' } }; + const corrupt = createNoiseIdentity(() => provider); + await expect(corrupt.publicIdentity()).rejects.toThrow('host-key-invalid'); + expect(entry).toEqual({ kind: 'grant', payload: { version: 1, privateKey: 'corrupt' } }); + corrupt.close(); +}); diff --git a/packages/core/test/connect-noise.test.ts b/packages/core/test/connect-noise.test.ts new file mode 100644 index 0000000000..f9b6384294 --- /dev/null +++ b/packages/core/test/connect-noise.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import vector from './fixtures/noise/xk-cacophony.json'; +import { NoiseXk } from '../src/devices/noise/xk.js'; + +const bytes = (hex: string) => Uint8Array.from(Buffer.from(hex, 'hex')); +const hex = (value: Uint8Array) => Buffer.from(value).toString('hex'); + +describe('Connect Noise XK public protocol', () => { + it('matches both handshake peers and transport against the independent Cacophony vector', () => { + const initiator = new NoiseXk({ + role: 'initiator', + staticKey: bytes(vector.init_static), + ephemeralKey: bytes(vector.init_ephemeral), + remoteStatic: bytes(vector.init_remote_static), + prologue: bytes(vector.init_prologue), + }); + const responder = new NoiseXk({ + role: 'responder', + staticKey: bytes(vector.resp_static), + ephemeralKey: bytes(vector.resp_ephemeral), + prologue: bytes(vector.resp_prologue), + }); + for (const [index, message] of vector.messages.slice(0, 3).entries()) { + const sender = index % 2 === 0 ? initiator : responder; + const receiver = index % 2 === 0 ? responder : initiator; + expect(hex(sender.write(bytes(message.payload)))).toBe(message.ciphertext); + expect(hex(receiver.read(bytes(message.ciphertext)))).toBe(message.payload); + } + expect(hex(initiator.handshakeHash())).toBe(vector.handshake_hash); + expect(hex(responder.handshakeHash())).toBe(vector.handshake_hash); + const a = initiator.split(); + const b = responder.split(); + for (const [index, message] of vector.messages.slice(3).entries()) { + const sender = index % 2 === 0 ? b : a; + const receiver = index % 2 === 0 ? a : b; + expect(hex(sender.encrypt(bytes(message.payload)))).toBe(message.ciphertext); + expect(hex(receiver.decrypt(bytes(message.ciphertext)))).toBe(message.payload); + } + a.close(); + b.close(); + }); +}); diff --git a/packages/core/test/connect-pairing.test.ts b/packages/core/test/connect-pairing.test.ts new file mode 100644 index 0000000000..dee060dab1 --- /dev/null +++ b/packages/core/test/connect-pairing.test.ts @@ -0,0 +1,154 @@ +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { WebSocket } from 'ws'; +import { expect, it, vi } from 'vitest'; +import { redeemPairing, revokePairing } from '../../../apps/mobile/src/host/pairing.js'; +import { encryptedCarrier } from '../../../apps/mobile/src/host/encrypted-carrier.js'; +import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js'; +import { createEncryptedGateway } from '../src/devices/encrypted-gateway.js'; +import { noisePublicKey, noiseFingerprint } from '../src/devices/noise/xk.js'; +import { createDevicePairing } from '../src/devices/pairing.js'; +import { attachOperationalModule, mountOperationalDatabase } from '../src/database/owner.js'; +import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; +import { createTempRoot, trackTestOwner } from './helpers.js'; + +it('pairs over Noise only after both fingerprint confirmations, rejects a stolen bearer with a new key, and carries bytes', async () => { + const owner = trackTestOwner( + mountOperationalDatabase({ + dshHome: createTempRoot('noise-pair-'), + schemaPlan: BOT_HARNESS_SCHEMA_PLAN, + }), + ); + const pairing = createDevicePairing({ + database: attachOperationalModule(owner, 'device-pairing'), + hostName: 'Test Host', + }); + const key = randomBytes(32), + publicKey = noisePublicKey(key); + const seen: string[] = []; + const forwarded = vi.fn(); + const server = createServer(async (request, response) => { + forwarded(request.url); + expect(pairing.admit(request.headers.authorization).ok).toBe(true); + if (request.url === '/botharness-device/unpair') { + const admitted = pairing.admit(request.headers.authorization); + if (admitted.ok) pairing.revoke(admitted.device.id); + response.end(JSON.stringify({ revoked: true })); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + response.setHeader('content-type', 'image/png'); + response.end(Buffer.concat(chunks)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const gateway = createEncryptedGateway({ + pairing, + key: async () => Uint8Array.from(key), + port: () => port, + }); + server.on('upgrade', (req, socket, head) => gateway.upgrade(req, socket, head)); + const raw: HostCarrier = { + request: vi.fn(async () => { + throw new Error('plaintext'); + }), + randomBytes: () => randomBytes(32), + openSocket(url, events, headers) { + expect(url).toBe('wss://pair.deepseekbot.dev/botharness-device/channel'); + expect(headers).toBeUndefined(); + const socket = new WebSocket(`ws://127.0.0.1:${port}/botharness-device/channel`); + socket.on('open', events.open); + socket.on('message', (data) => { + seen.push(data.toString()); + events.message(data.toString()); + }); + socket.on('close', () => events.closed('closed')); + socket.on('error', () => events.closed('error')); + return { + get bufferedAmount() { + return socket.bufferedAmount; + }, + send(text) { + seen.push(text); + socket.send(text); + }, + close: () => socket.close(), + }; + }, + }; + let carrier: HostCarrier | undefined, stolen: HostCarrier | undefined; + try { + const offer = pairing.createOffer(); + const payload = { + v: 1 as const, + host: pairing.host(), + offer: offer.offer, + expiresAt: offer.expiresAt, + routes: ['https://pair.deepseekbot.dev'], + encryption: { + protocol: 'connect-noise-xk-v1' as const, + publicKey: Buffer.from(publicKey).toString('hex'), + }, + }; + expect(await redeemPairing({ payload, deviceName: 'Phone', carrier: raw })).toEqual({ + ok: false, + code: 'host-key-confirmation-required', + }); + expect(seen).toHaveLength(0); + let approved = false; + const result = await redeemPairing({ + payload, + deviceName: 'Phone', + carrier: raw, + confirmFingerprint: async (fingerprint) => fingerprint === noiseFingerprint(publicKey), + onPending: (fingerprint) => { + const pending = pairing.pendingKeys()[0]!; + expect(pairing.list()).toHaveLength(0); + expect(forwarded).not.toHaveBeenCalled(); + expect(fingerprint).toBe(noiseFingerprint(Buffer.from(pending.publicKey, 'hex'))); + approved = pairing.approveKey(pending.receipt, pending.publicKey); + }, + }); + expect(approved).toBe(true); + if (!result.ok) throw new Error(result.code); + expect(pairing.list()).toHaveLength(1); + carrier = encryptedCarrier({ ...result.host, raw }); + const privateBytes = new Uint8Array([137, 80, 78, 71, 0, 255, 1]); + const response = await carrier.request( + 'https://pair.deepseekbot.dev/botharness-device/api/attachments/upload', + { + method: 'POST', + body: new Blob([privateBytes]), + signal: AbortSignal.timeout(5000), + headers: { 'content-type': 'application/octet-stream' }, + }, + ); + expect(await response.bytes?.()).toEqual(privateBytes); + expect(response.contentType).toBe('image/png'); + expect(seen.join('')).not.toContain(offer.offer); + expect(seen.join('')).not.toContain(result.host.credential); + stolen = encryptedCarrier({ + ...result.host, + encryption: { ...result.host.encryption!, deviceKey: randomBytes(32).toString('hex') }, + raw, + }); + await expect( + stolen.request('https://pair.deepseekbot.dev/botharness-device/api/botharness/list', { + method: 'POST', + signal: AbortSignal.timeout(5000), + }), + ).rejects.toThrow(); + expect(forwarded).toHaveBeenCalledTimes(1); + expect(raw.request).not.toHaveBeenCalled(); + expect(await revokePairing({ host: result.host, carrier: raw, timeoutMs: 2000 })).toBe( + 'revoked', + ); + } finally { + carrier?.close?.(); + stolen?.close?.(); + gateway.close(); + await new Promise((resolve) => server.close(() => resolve())); + } +}, 10_000); diff --git a/packages/core/test/connect-secret-route.test.ts b/packages/core/test/connect-secret-route.test.ts index 0ba809c3f2..3a2c8f7105 100644 --- a/packages/core/test/connect-secret-route.test.ts +++ b/packages/core/test/connect-secret-route.test.ts @@ -7,7 +7,7 @@ import { SECRET_ROUTE_METHODS } from '../src/bridge/secret-route.js'; import { createGatewayOrigin } from '../src/devices/connect.js'; import { createDeviceGateway } from '../src/devices/gateway.js'; import { createDevicePairing } from '../src/devices/pairing.js'; -import { DEVICE_ROUTE_HEADER } from '../src/devices/route-context.js'; +import { DEVICE_ROUTE_HEADER, stampConnectRoute } from '../src/devices/route-context.js'; import { attachOperationalModule, mountOperationalDatabase } from '../src/database/owner.js'; import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; import { createTempRoot, trackTestOwner } from './helpers.js'; @@ -93,12 +93,21 @@ async function rig() { authenticatedUrl: (base) => `${base}?token=test`, log: (line) => logs.push(line), }); - const origin = createGatewayOrigin(() => port); + const origin = createServer((request, response) => { + stampConnectRoute(request); + void gateway.handle(request, response); + }); + origin.on('upgrade', (request, socket, head) => { + stampConnectRoute(request); + void gateway.upgrade(request, socket, head); + }); const originPort = await listen(origin); + const exposed = createGatewayOrigin(() => port); + const exposedPort = await listen(exposed); cleanup.push(() => { for (const client of nativeMux.clients) client.terminate(); nativeMux.close(); - for (const socket of origin.upgraded) socket.destroy(); + for (const socket of exposed.upgraded) socket.destroy(); }); const offer = pairing.createOffer(); const paired = await fetch(`http://127.0.0.1:${port}/botharness-device/pair`, { @@ -141,7 +150,7 @@ async function rig() { }); return client; }; - return { seen, logs, call, socket, pairing, device, originPort, headers }; + return { seen, logs, call, socket, pairing, device, originPort, exposedPort, headers }; } function frame(client: WebSocket, endpoint: string, streamId: string) { @@ -161,7 +170,7 @@ function frame(client: WebSocket, endpoint: string, streamId: string) { }); } -describe('Connect secret dispatch', () => { +describe('Unencrypted Connect route guard behind the origin', () => { it('covers private input, provider credential values, generic secret settings and IM setup', () => { expect(SECRET_ROUTE_METHODS).toEqual([ 'botharness/privateInputSubmit', @@ -267,3 +276,31 @@ describe('Connect secret dispatch', () => { expect(host.seen).toEqual(['botharness/followRoster']); }); }); + +it('refuses legacy plaintext HTTP and mux at the actual Connect origin before dispatch', async () => { + const host = await rig(); + for (const path of ['/pair', '/stream-ticket', '/api/botharness/list', '/api/credentials/set']) { + for (const headers of [{}, host.headers]) { + const response = await fetch( + `http://127.0.0.1:${host.exposedPort}/botharness-device${path}`, + { method: 'POST', headers, body: '{}' }, + ); + expect(response.status).toBe(404); + } + } + const client = new WebSocket( + `ws://127.0.0.1:${host.exposedPort}/botharness-device/api/remote.mux`, + { headers: host.headers }, + ); + client.on('error', () => undefined); + cleanup.push(() => client.terminate()); + const status = await new Promise((resolve) => + client.once('unexpected-response', (_request, response) => { + response.resume(); + resolve(response.statusCode); + client.terminate(); + }), + ); + expect(status).toBe(404); + expect(host.seen).toEqual([]); +}); diff --git a/packages/core/test/device-connect.test.ts b/packages/core/test/device-connect.test.ts index 1280ba524c..30dd3ce9a0 100644 --- a/packages/core/test/device-connect.test.ts +++ b/packages/core/test/device-connect.test.ts @@ -203,15 +203,17 @@ describe('device Connect tunnel', () => { }); const origin = connect.status().origin ?? ''; - expect(await get(origin, '/botharness-device/pair')).toEqual({ + expect((await get(origin, '/botharness-device/pair')).status).toBe(404); + expect(await get(origin, '/botharness-device/channel')).toEqual({ status: 200, - body: 'dsh /botharness-device/pair', + body: 'dsh /botharness-device/channel', }); expect((await get(origin, '/api/botharness/devices')).status).toBe(404); expect((await get(origin, '/')).status).toBe(404); - expect(await upgrade(origin, '/botharness-device/api/remote.mux')).toContain( - 'x-path: /botharness-device/api/remote.mux', + expect(await upgrade(origin, '/botharness-device/channel')).toContain( + 'x-path: /botharness-device/channel', ); + expect(await upgrade(origin, '/botharness-device/api/remote.mux')).toContain('404 Not Found'); expect(await upgrade(origin, '/api/remote.mux')).toContain('404 Not Found'); const stored = [...credentials.records.values()][0] as { @@ -254,7 +256,7 @@ describe('device Connect tunnel', () => { const opened = new Promise((done) => socket.once('data', () => done())); const closed = new Promise((done) => socket.once('close', () => done())); socket.write( - 'GET /botharness-device/api/remote.mux HTTP/1.1\r\nhost: studio.deepseekbot.dev\r\nconnection: upgrade\r\nupgrade: websocket\r\n\r\n', + 'GET /botharness-device/channel HTTP/1.1\r\nhost: studio.deepseekbot.dev\r\nconnection: upgrade\r\nupgrade: websocket\r\n\r\n', ); await opened; expect(await connect.disable()).toEqual({ ok: true, status: { state: 'off' } }); diff --git a/packages/core/test/device-gateway.test.ts b/packages/core/test/device-gateway.test.ts index bd66ba0d45..ef4d4e56d8 100644 --- a/packages/core/test/device-gateway.test.ts +++ b/packages/core/test/device-gateway.test.ts @@ -510,7 +510,7 @@ describe('Connect route provenance', () => { } it.each([undefined, 'connect', 'tailscale', 'loopback', ''])( - 'overwrites an absent or forged HTTP tag (%s) before forwarding to DSH', + 'refuses plaintext Connect HTTP regardless of forged route tag (%s)', async (tag) => { const host = await fakeDsh(); const origin = await originFor(host.port); @@ -526,7 +526,8 @@ describe('Connect route provenance', () => { headers, }); await tunneled.text(); - expect(host.state.seen.at(-1)).toMatchObject({ connect: true, routeHeaderPresent: true }); + expect(tunneled.status).toBe(404); + expect(host.state.seen).toHaveLength(1); expect(tunneled.headers.get(DEVICE_ROUTE_HEADER)).toBeNull(); }, ); @@ -559,7 +560,7 @@ describe('Connect route provenance', () => { expect(host.state.seen).toEqual([]); }); - it('overwrites a forged upgrade tag and strips forged direct tags', async () => { + it('refuses plaintext Connect upgrades and strips forged direct tags', async () => { const host = await fakeDsh(); const origin = await originFor(host.port); const paired = await pair(host.base, host.pairing.createOffer().offer); @@ -576,8 +577,7 @@ describe('Connect route provenance', () => { headers, '/botharness-device/api/browser/webrtc/signaling', ); - expect(tunneled.status).toBe(101); - expect(host.state.seen.at(-1)).toMatchObject({ connect: true, routeHeaderCount: 1 }); - tunneled.socket!.destroy(); + expect(tunneled.status).toBe(404); + expect(host.state.seen).toHaveLength(1); }); }); diff --git a/packages/core/test/device-pairing.test.ts b/packages/core/test/device-pairing.test.ts index e858a8da0b..99f969466b 100644 --- a/packages/core/test/device-pairing.test.ts +++ b/packages/core/test/device-pairing.test.ts @@ -39,6 +39,32 @@ function fixture() { } describe('Device Pairing', () => { + it('requires approval of the exact Noise device key and keeps that binding across restart', () => { + const f = fixture(); + let host = f.open(); + const offer = host.pairing.createOffer(); + const publicKey = '11'.repeat(32); + const otherKey = '22'.repeat(32); + expect(host.pairing.requestKey({ offer: offer.offer, publicKey, name: 'Phone' })).toMatchObject( + { ok: true }, + ); + expect(host.pairing.redeem({ offer: offer.offer, publicKey })).toEqual({ + ok: false, + code: 'device-approval-required', + }); + expect(host.pairing.approveKey(offer.receipt, otherKey)).toBe(false); + expect(host.pairing.approveKey(offer.receipt, publicKey)).toBe(true); + const paired = host.pairing.redeem({ offer: offer.offer, publicKey }); + if (!paired.ok) throw new Error('pairing failed'); + host.owner.close(); + host = f.open(); + expect(host.pairing.admitKey(`Device ${paired.credential}`, publicKey).ok).toBe(true); + expect(host.pairing.admitKey(`Device ${paired.credential}`, otherKey)).toMatchObject({ + ok: false, + }); + expect(host.pairing.list()[0]?.publicKey).toBe(publicKey); + }); + it('redeems an offer once, admits the device across a Host restart, and refuses it after revocation', () => { const f = fixture(); let host = f.open(); diff --git a/packages/core/test/fixtures/noise/README.md b/packages/core/test/fixtures/noise/README.md new file mode 100644 index 0000000000..3092751e4c --- /dev/null +++ b/packages/core/test/fixtures/noise/README.md @@ -0,0 +1,5 @@ +# Independent Noise vector + +`xk-cacophony.json` is the exact `Noise_XK_25519_ChaChaPoly_SHA256` vector selected from [Cacophony vectors/cacophony.txt](https://github.com/centromere/cacophony/blob/8ee9d41e34a1a596cfa3ab12aa4069ff87dc1247/vectors/cacophony.txt). The static and ephemeral private values are published test material, never runtime credentials. Tests check both peers and every published flight rather than generating expectations with BotHarness code. + +Upstream dedicates the vector under [the Unlicense](https://github.com/centromere/cacophony/blob/8ee9d41e34a1a596cfa3ab12aa4069ff87dc1247/LICENSE). diff --git a/packages/core/test/fixtures/noise/xk-cacophony.json b/packages/core/test/fixtures/noise/xk-cacophony.json new file mode 100644 index 0000000000..87dc3f705b --- /dev/null +++ b/packages/core/test/fixtures/noise/xk-cacophony.json @@ -0,0 +1,37 @@ +{ + "protocol_name": "Noise_XK_25519_ChaChaPoly_SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "handshake_hash": "cefffc5d1074126cc980ebfe902587ff36ba61dc77d4447ebe0f96dc22ae59d7", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944a3785af283c991bab613473804356ef6931f83acf64f99c274b93570857cfc5e" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088433a4534805fa9fe4eb8343ace6609160c767ad9b832e8eea1d9b7a2111818dd" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "5d8e67b9c1b8e36f5dc674bc5cd2ce243fb5d1710fa57de0370da7cc979015398eaad94603b05498ba9a613d2fd923dcaa6fd4288dfd8d70f419bf737efb4cd37f5da37ebb728849318c82" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "3205e1265f809505e6edc092839d3156745d2abafbfd946b261e41" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "470bcb1ae099555ff0d729500df550418d6ee5149d9e40bd2f4c6b3d263cc818d5" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "d7187ed9d217ba6e91cf596e4871012ccedf7b5bed0d4cb8f7affb020fa17a95a23371e0f6" + } + ] +} diff --git a/packages/core/test/onboarding.test.ts b/packages/core/test/onboarding.test.ts index 993a58c7aa..5cd7eef15d 100644 --- a/packages/core/test/onboarding.test.ts +++ b/packages/core/test/onboarding.test.ts @@ -452,7 +452,7 @@ it.each(['stale', 'missing'] as const)( }), }); const client = createHostClient({ - routes: ['https://source.example'], + routes: ['https://source.example.ts.net'], credential: 'test-only', carrier: { async request(url, init) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a0a899915..aec57aa2d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -793,6 +793,15 @@ importers: '@botharness/pixel-banner': specifier: 0.1.0 version: 0.1.0 + '@noble/ciphers': + specifier: 2.4.0 + version: 2.4.0 + '@noble/curves': + specifier: 2.4.0 + version: 2.4.0 + '@noble/hashes': + specifier: 2.4.0 + version: 2.4.0 '@resvg/resvg-js': specifier: 2.6.2 version: 2.6.2 @@ -5858,6 +5867,18 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -19358,6 +19379,14 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd6ad79724..6cb7f3c813 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -383,8 +383,12 @@ minimumReleaseAgeExclude: - expo-font@58.0.9 - expo-camera@58.0.11 || 58.0.12 - expo-image-manipulator@58.0.15 - - '@expo/config-plugins@58.0.10' + - 'expo-clipboard@58.0.4' + - 'expo-status-bar@58.0.4' - '@expo/config@58.0.7' + - 'expo-modules-macros@0.22.0' + - '@expo/config-plugins@58.0.10' + - '@expo/env@2.5.3' - '@expo/fingerprint@0.21.6' - '@expo/inline-modules@0.2.7' @@ -399,8 +403,6 @@ minimumReleaseAgeExclude: - '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.8' - '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.8' - noxcturnal@0.1.8 - - expo-clipboard@58.0.4 - - expo-status-bar@58.0.4 patchedDependencies: '@deepseek-ai/dsh-tool-fs@0.2.1-rc.2': patches/@deepseek-ai__dsh-tool-fs@0.2.1-rc.2.patch diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 92425c5782..6f13946b93 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -61,14 +61,17 @@ "description": "Device list, offers, offer status and revoke; the Connect tunnel action on the same route family has connect-status/enable/disable/sign-in/sign-out.", "scope": "Profile", "surfaces": [ - "route:packages/core/src/devices/mount.ts:path in [ [DEVICES_PATH, ['GET']], [`${DEVICES_PATH}/offer`, ['POST']], [`${DEVICES_PATH}/offer-status`, ['POST']], [`${DEVICES_PATH}/revoke`, ['POST']], [`${DEVICES_PATH}/connect`, ['POST']], ] as const", + "route:packages/core/src/devices/mount.ts:path in [ [DEVICES_PATH, ['GET']], [`${DEVICES_PATH}/offer`, ['POST']], [`${DEVICES_PATH}/offer-status`, ['POST']], [`${DEVICES_PATH}/approve-key`, ['POST']], [`${DEVICES_PATH}/revoke`, ['POST']], [`${DEVICES_PATH}/connect`, ['POST']], ] as const", "setting:packages/client/src/client/paired-devices.tsx:{device.name}", + "setting:packages/client/src/client/paired-devices.tsx:{item.name}", + "route:packages/core/src/devices/mount.ts:CONNECT_CHANNEL_PATH", "setting:packages/client/src/client/paired-devices.tsx:{t('devices.pair.title')}" ], "commands": [ "devices", "device-offer", "device-offer-status", + "device-approve-key", "device-revoke", "connect-enable", "connect-disable" diff --git a/scripts/verification-inventory.json b/scripts/verification-inventory.json index 6c739595b8..cf5376b0d4 100644 --- a/scripts/verification-inventory.json +++ b/scripts/verification-inventory.json @@ -19,6 +19,14 @@ "task": 2500, "gap": "Two isolated local Hosts qualify the browser path; DSH Desktop, physical tailnet machines and remote image/card rendering remain unqualified." }, + { + "id": "connect.encrypted-channel", + "label": "Pair exact device keys and carry Connect messages, resources and private input through Noise XK", + "source": "packages/core/src/devices/encrypted-gateway.ts", + "guide": ".agents/skills/verify-deepseekbot/features/connect-channel.md", + "task": 2542, + "gap": "Real Cloudflare network, iOS/Android and Human acceptance remain separate gates; browser/Node local-origin capture is not native proof." + }, { "id": "desktop.approval.three-actions", "label": "Remember only the current tool through three actions and keep Full Access permission management Human-only",