@ljwei-stak/dsh-model-route-bot 0.66.4 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +18 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- package/remote-computer-agent/src/transport.ts +39 -15
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# End-to-end encrypted phone connection, wire spec v1
|
|
2
|
+
|
|
3
|
+
The phone and this computer agree on keys at QR pairing, and every connection proves both sides. The same encrypted
|
|
4
|
+
connection runs over three routes: straight over Wi-Fi, over Tailscale, and through MausBot Relay, which passes sealed
|
|
5
|
+
bytes between the two and can't open them. This file is the normative spec; `vectors/v1.json` is the contract every
|
|
6
|
+
implementation (desktop, relay, phones) tests against.
|
|
7
|
+
|
|
8
|
+
| File | What |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `credentials.ts` | psk, admission value, keyId, strict base64url, relay token mint and verify |
|
|
11
|
+
| `noise.ts` | `Noise_NKpsk0_P256_AESGCM_SHA256` initiator and responder, transport records |
|
|
12
|
+
| `yamux.ts` | the yamux profile: codec and an I/O-free stream state machine |
|
|
13
|
+
| `descriptor.ts` | the `e2e` pairing descriptor |
|
|
14
|
+
| `gen-vectors.ts` | writes `vectors/v1.json` and `vectors/v1.sha256`; `--check` fails if they would change |
|
|
15
|
+
|
|
16
|
+
Tests: `companion/test/e2e/`.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Everything a Node, Swift or Kotlin implementer needs. MUST and NEVER are binding.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Everything a Node, Swift or Kotlin implementer needs. MUST and NEVER are binding.
|
|
23
|
+
|
|
24
|
+
## 1. Conventions
|
|
25
|
+
|
|
26
|
+
- **Bytes.** Binary values in JSON and URLs are base64url without padding ("b64u"). Integers on the wire are big-endian unless stated.
|
|
27
|
+
- **P-256 keys.** Public keys are 65-byte uncompressed SEC1 (`0x04‖X‖Y`). An implementation MUST reject points that are off the curve or not
|
|
28
|
+
canonical. CryptoKit, JCA and Node's `ECDH.computeSecret` do this when given the raw point; keep that check.
|
|
29
|
+
- **DH(priv, pub)** is P-256 ECDH and outputs the 32-byte X coordinate (CryptoKit `sharedSecretFromKeyAgreement` raw bytes; JCA
|
|
30
|
+
`KeyAgreement("ECDH").generateSecret()`; Node `ECDH.computeSecret`).
|
|
31
|
+
- **Primitives.** SHA-256 and HMAC-SHA256. AES-256-GCM with a 12-byte nonce and a 16-byte tag.
|
|
32
|
+
- **Strings** are UTF-8. `‖` is concatenation.
|
|
33
|
+
|
|
34
|
+
## 2. Identities and derived secrets
|
|
35
|
+
|
|
36
|
+
| Name | What | Where it lives |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `T` | The computer's **transport key**, a long-lived P-256 key pair separate from the HPKE credential key | Private half: Electron secure credential document field `e2eTransportKey` (same pattern as `phoneSecretHpke`, `electron/phone-secret-identity.mjs`), handed to the sidecar in memory. Standalone sidecar: `$OMB_COMPANION_DIR/e2e-key.json`, mode 0600 |
|
|
39
|
+
| `keyId` | `SHA256(T.pub)[0:16]` (16 bytes) | Derived |
|
|
40
|
+
| `hostId` | 16 random bytes, made by the control plane once per installation | D1 `installations.relay_host_id`; Electron credential document |
|
|
41
|
+
| `cred` | A device token `omb_…` (`electron/desktop-companion-client.mjs:11`), or the pairing token `omb_pair_…` while a window is open | Phone keychain; desktop stores only `SHA256(cred)` hex (`companion/src/devices.ts:54`) |
|
|
42
|
+
| `psk` | `HMAC-SHA256(key = SHA256(cred), msg = "omb-e2e psk v1" ‖ keyId)` (32 bytes) | Derived on both sides; the desktop computes it from the stored hash |
|
|
43
|
+
| `adm` | `HMAC-SHA256(key = SHA256(cred), msg = "omb-relay admission v1" ‖ hostId)` (32 bytes) | The phone sends it to the relay; the relay only ever holds `SHA256(adm)` |
|
|
44
|
+
|
|
45
|
+
`SHA256(cred)` is the raw 32-byte digest of the token string's UTF-8 bytes. The desktop gets it by hex-decoding `tokenHash`. Knowing `adm`
|
|
46
|
+
reveals nothing about `psk`: the two are separate HMAC outputs under different labels.
|
|
47
|
+
|
|
48
|
+
## 3. Pairing payload (QR link) and descriptors
|
|
49
|
+
|
|
50
|
+
- **The QR link** (`openmausbot://pair?...`, built by `shared/pairing-link.ts`) keeps every field it has today and gains one query parameter:
|
|
51
|
+
`e2e=<b64u(JSON)>`, where the JSON is:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{"v":1,"k":"<b64u 65-byte T.pub, 87 chars>","h":"<b64u 16-byte hostId, 22 chars>","r":["relay1.openmausbot.com","relay2.openmausbot.com"]}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Field rules:**
|
|
58
|
+
- `v` MUST be 1.
|
|
59
|
+
- `k` is required.
|
|
60
|
+
- `h` and `r` are present only when the computer has a relay token (signed in, relay enabled). A device restricted to "Wi-Fi and
|
|
61
|
+
Tailscale only" still gets them: they admit nothing by themselves, and they let a restricted phone away from home probe the relay
|
|
62
|
+
now and then (4404 until it is allowed again).
|
|
63
|
+
- `r` lists 1 to 4 hostnames in the order to try. Each MUST match `^relay[1-9][0-9]?\.openmausbot\.com$`.
|
|
64
|
+
- `routes` is optional, `"any"` or `"local"`. `"local"` means the computer restricted this device to Wi-Fi and
|
|
65
|
+
Tailscale; it may come with `h` and `r` (an older computer sends it without them). Absent means `"any"`, so a
|
|
66
|
+
descriptor without `h` and `r` and without `"local"` only says the computer has no relay right now (signed out, or
|
|
67
|
+
outside the rollout). The QR never carries `"local"`: a restriction is set per phone after pairing, and reaches it
|
|
68
|
+
through the endpoint list and the `companion.endpoints` event.
|
|
69
|
+
- The encoded value is at most 1,024 bytes.
|
|
70
|
+
- **Phone parsing rules:**
|
|
71
|
+
- A malformed `e2e` rejects the whole invite. That includes bad base64, bad JSON, a bad point, `h` without `r` or `r` without `h`, and a host
|
|
72
|
+
outside the allowlist.
|
|
73
|
+
- `v > 1` shows "Update the MausBot app to pair with this computer". It NEVER falls back to plain pairing.
|
|
74
|
+
- Old app builds ignore the unknown parameter, as they do today.
|
|
75
|
+
- **Direct routes** come from the existing `endpoints` and `hosts` parameters. The phone turns each `lan`, `bonjour` or `tailnet` URL
|
|
76
|
+
`http://H:P` into `ws://H:P/v1/e2e`. A `hosted` URL is never used for E2E.
|
|
77
|
+
- **Pair response.** `POST /api/pair` returns 200 with `{token, device, serverName, hosts?, endpoints?}` plus `"e2e": {…same object…}`.
|
|
78
|
+
- **Endpoint list.** `GET /api/companion/endpoints` returns `{serverName, hosts?, endpoints[]}` plus `"e2e": {…}`. The response is built for
|
|
79
|
+
the device that asks: a restricted device's `e2e` says `"routes":"local"`.
|
|
80
|
+
- **Endpoint event.** When a device's routes change, the sidecar writes the same body plus `"kind":"companion.endpoints"` as one event
|
|
81
|
+
(`data:` only, no `id:` and no `seq`, never replayed) on every `/api/events` stream that device holds, so the phone hears it inside its
|
|
82
|
+
session. Narrowing to `"local"`: the event first, then the relay keeps the device admitted 300 ms more so the event lands, then the
|
|
83
|
+
set shrinks (the relay closes its relay sessions with 4404) and the computer closes its own; direct sessions stay. Widening to
|
|
84
|
+
`"any"`: admitted first, then the event.
|
|
85
|
+
- **Pinning:**
|
|
86
|
+
- A phone that holds a QR-pinned `k` MUST NEVER replace it from a network response.
|
|
87
|
+
- A phone paired before this feature (no `k`) accepts `k` once from `/api/companion/endpoints`, over the route it already trusts (trust on
|
|
88
|
+
first use), and pins it from then on.
|
|
89
|
+
- A pin mismatch is a hard stop (section 12).
|
|
90
|
+
|
|
91
|
+
## 4. Routes and URLs
|
|
92
|
+
|
|
93
|
+
| Route | URL the phone dials | WebSocket subprotocol | Prologue `transport` byte |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| Wi-Fi | `ws://<lan or bonjour host>:<port>/v1/e2e` | `omb-e2e.v1` | `0x02` (direct) |
|
|
96
|
+
| Tailscale | `ws://<name>.ts.net:<port>/v1/e2e` | `omb-e2e.v1` | `0x02` (direct) |
|
|
97
|
+
| Relay | `wss://<r[i]>/v1/phone` | `omb-relay.v1` | `0x01` (relay) |
|
|
98
|
+
|
|
99
|
+
The sidecar serves `/v1/e2e` on its existing `:8810` upgrade handler (`companion/src/index.ts:145`). It refuses any upgrade that carries an
|
|
100
|
+
`Origin` header, the same rule as `companion/src/proxy.ts:637-639`.
|
|
101
|
+
|
|
102
|
+
## 5. Handshake: `Noise_NKpsk0_P256_AESGCM_SHA256`
|
|
103
|
+
|
|
104
|
+
This follows the Noise Protocol Framework rev 34 (its §5 processing rules, §9 PSK rules, §11.2 AESGCM and §12 HASH/HKDF) with DH = P-256 as in
|
|
105
|
+
section 1, `DHLEN` = 32 and public keys encoded at 65 bytes.
|
|
106
|
+
|
|
107
|
+
**Pattern:**
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
NKpsk0:
|
|
111
|
+
<- s
|
|
112
|
+
...
|
|
113
|
+
-> psk, e, es
|
|
114
|
+
<- e, ee
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Symmetric state** (Noise §5.2):
|
|
118
|
+
- `h`: protocol name `"Noise_NKpsk0_P256_AESGCM_SHA256"` (31 bytes) padded with zeros to 32 bytes.
|
|
119
|
+
- `ck = h`.
|
|
120
|
+
- `k` empty, `n = 0`.
|
|
121
|
+
- `HKDF(ck, ikm, num)` is Noise's HKDF (HMAC-SHA256, `temp = HMAC(ck, ikm)`, `out_i = HMAC(temp, out_{i-1} ‖ byte(i))`).
|
|
122
|
+
- `MixHash`, `MixKey`, `MixKeyAndHash`, `EncryptAndHash` and `DecryptAndHash` are exactly as Noise defines them.
|
|
123
|
+
- `ENCRYPT(k, n, ad, p)` is AES-256-GCM with nonce `0x00000000 ‖ uint64_be(n)` and AAD `ad`.
|
|
124
|
+
|
|
125
|
+
**Prologue** (11 bytes): `"omb-e2e/1" ‖ 0x00 ‖ transport`, with `transport` from section 4.
|
|
126
|
+
|
|
127
|
+
**Initialization, both sides:** `MixHash(prologue)`, then `MixHash(T.pub)` for the responder's pre-message `s`.
|
|
128
|
+
|
|
129
|
+
**Message 1, phone to computer**, one WebSocket binary message:
|
|
130
|
+
1. `MixKeyAndHash(psk)`.
|
|
131
|
+
2. Generate an ephemeral `e`. Write `e.pub` (65 bytes). `MixHash(e.pub)`, then `MixKey(e.pub)` (the psk rule).
|
|
132
|
+
3. `MixKey(DH(e.priv, T.pub))`.
|
|
133
|
+
4. Write `EncryptAndHash(payload1)`, where `payload1` is UTF-8 JSON of at most 512 bytes:
|
|
134
|
+
`{"v":1,"conn":"<b64u 16 random bytes>","app":"<platform/version, ≤40 chars>"}`.
|
|
135
|
+
|
|
136
|
+
The message is `e.pub(65) ‖ ciphertext(len(payload1) + 16)`.
|
|
137
|
+
|
|
138
|
+
**Computer processing message 1:**
|
|
139
|
+
- Compute `es = DH(T.priv, re)` once.
|
|
140
|
+
- Run the symmetric state from the post-initialization snapshot once per candidate `psk`. The candidates are every paired device that is allowed
|
|
141
|
+
on this transport, plus the open pairing window if there is one (at most 21).
|
|
142
|
+
- Process `psk, e, es` for each candidate and try `DecryptAndHash`. The first success identifies the device.
|
|
143
|
+
- No match, a bad point, or a `payload1` with `v ≠ 1` or missing `conn`: close with 4001 (section 11). Never answer with anything else.
|
|
144
|
+
- A device restricted to "Wi-Fi and Tailscale only" is not a candidate when `transport = 0x01`.
|
|
145
|
+
|
|
146
|
+
**Message 2, computer to phone:**
|
|
147
|
+
1. Generate `e`. Write `e.pub`. `MixHash(e.pub)`, then `MixKey(e.pub)`.
|
|
148
|
+
2. `MixKey(DH(e.priv, re))`.
|
|
149
|
+
3. Write `EncryptAndHash(payload2)`, where `payload2` is `{"v":1,"conn":"<echo of payload1.conn>"}`.
|
|
150
|
+
|
|
151
|
+
**Phone processing message 2:**
|
|
152
|
+
- A decrypt failure means the computer doesn't hold the pinned key's private half, or the message was tampered with. Hard stop (section 12).
|
|
153
|
+
- If `v ≠ 1` or `conn` doesn't match: hard stop.
|
|
154
|
+
|
|
155
|
+
**Split** (Noise `Split()`): `HKDF(ck, empty, 2)` gives `k_pc`, the key for phone→computer messages, then `k_cp`, the key for
|
|
156
|
+
computer→phone messages. Each starts at `n = 0`. The final `h` is the session's `handshakeHash`. Logs may record `hex(h[0:4])`.
|
|
157
|
+
|
|
158
|
+
**Deadline:** both sides close if the handshake isn't complete 10 s after the WebSocket opens (or after relay `ready`, section 9).
|
|
159
|
+
|
|
160
|
+
## 6. Transport messages
|
|
161
|
+
|
|
162
|
+
- After `Split`, every WebSocket binary message in each direction is exactly one AEAD record:
|
|
163
|
+
`ENCRYPT(k_dir, n_dir, ad = empty, plaintext)`, where the plaintext is exactly one yamux frame (section 7).
|
|
164
|
+
- The receiver tracks its own `n` for that direction. A record that fails to open closes the session with 4000, with no retry and no skipping.
|
|
165
|
+
Because `n` is never sent, any drop, reorder, replay or reflection fails to open.
|
|
166
|
+
- A record is at most 65,535 bytes, so the plaintext is at most 65,519.
|
|
167
|
+
- A text WebSocket message after the handshake is a protocol error (4000).
|
|
168
|
+
- Either side closes the session (code 1000) before `n` reaches 2^32. The phone then reconnects. At a realistic 100 records a second that is
|
|
169
|
+
more than 400 days.
|
|
170
|
+
|
|
171
|
+
## 7. Multiplexing: the yamux profile
|
|
172
|
+
|
|
173
|
+
This uses the yamux wire format (`hashicorp/yamux` spec): a 12-byte header of `version:u8 = 0`, `type:u8`, `flags:u16`, `streamId:u32` and
|
|
174
|
+
`length:u32`.
|
|
175
|
+
|
|
176
|
+
| Item | Value |
|
|
177
|
+
|---|---|
|
|
178
|
+
| Types | 0 Data, 1 WindowUpdate, 2 Ping, 3 GoAway |
|
|
179
|
+
| Flags | SYN 0x1, ACK 0x2, FIN 0x4, RST 0x8 |
|
|
180
|
+
| Who opens streams | The phone only. Odd IDs 1, 3, 5, … increasing, never reused. A computer-opened stream or an even ID from the phone means GoAway(1), then close 4000 |
|
|
181
|
+
| Open | WindowUpdate {SYN} with `length = 1,835,008`, which raises the 256 KiB starting window to 2 MiB. The phone may send Data before the ACK, within 256 KiB |
|
|
182
|
+
| Accept | WindowUpdate {ACK} with `length = 1,835,008` |
|
|
183
|
+
| Refuse | WindowUpdate {RST}, length 0 |
|
|
184
|
+
| Max Data payload | 65,507 bytes (65,519 − 12) |
|
|
185
|
+
| Per-stream receive window | 2 MiB. Send a WindowUpdate once at least 1 MiB has been consumed. Data beyond the window means GoAway(1), then close 4000 |
|
|
186
|
+
| Concurrent streams per session | 64, counting un-ACKed opens. The 65th is answered with RST |
|
|
187
|
+
| Half close | FIN on Data or WindowUpdate. A stream ends when both FINs are seen, or on RST |
|
|
188
|
+
| Keepalive | Each side sends Ping {SYN, length = opaque u32} every 20 s. The peer answers Ping {ACK, same value}. No answer within 10 s closes the session (1001) |
|
|
189
|
+
| GoAway | StreamId 0, `length` = 0 normal, 1 protocol error, 2 internal error, 3 removed. 0 is sent before a planned close (quit, sleep, restart). 3 goes only from the computer to the phone, when the person removed this device, right before the 4001 close |
|
|
190
|
+
| Session flow | No session-wide window. Backpressure comes from the WebSocket: a sender stops writing records while the socket's buffered amount is above 1 MiB |
|
|
191
|
+
| Stream ID ceiling | At `streamId > 2^31 − 1` the phone opens a new session |
|
|
192
|
+
|
|
193
|
+
## 8. Stream semantics
|
|
194
|
+
|
|
195
|
+
- **Every stream is a raw, two-way byte pipe** from the phone's HTTP stack to the sidecar's E2E origin listener.
|
|
196
|
+
- The listener is a private Unix socket or named pipe served by the same proxy handler as the managed origin
|
|
197
|
+
(`companion/src/index.ts:147-148`): allowlist, device-token auth, gzip and WebSocket upgrades.
|
|
198
|
+
- The bytes are HTTP/1.1 as the phone's stack writes them, including keep-alive, chunked bodies, `Upgrade: websocket`, `Range` and SSE.
|
|
199
|
+
- The E2E layer never parses them. v1 has no other stream services.
|
|
200
|
+
- **Computer side:**
|
|
201
|
+
- On SYN it connects to the E2E origin socket.
|
|
202
|
+
- If that fails it sends RST; once connected it sends ACK.
|
|
203
|
+
- Phone FIN calls `socket.end()`. Origin end sends FIN. An error on either side sends RST.
|
|
204
|
+
- It grants window only as origin writes drain.
|
|
205
|
+
- **Phone side:**
|
|
206
|
+
- **iOS:** a loopback listener per session, `NWListener` on `127.0.0.1:<random>`. Each accepted TCP connection becomes one stream.
|
|
207
|
+
- Before opening the stream the listener reads the first request head (up to 16 KiB, 2 s deadline). The head MUST carry
|
|
208
|
+
`X-OMB-Loopback: <b64u 32-byte per-launch secret>`.
|
|
209
|
+
- Anything else is closed without opening a stream.
|
|
210
|
+
- `CompanionClient` adds that header on every request for an E2E connection, including `viewerSocketRequest` and `BrowserLiveClient`.
|
|
211
|
+
- **Android:** an in-process `javax.net.SocketFactory` that returns a `java.net.Socket` subclass backed by one stream. There is no port.
|
|
212
|
+
- The companion `OkHttpClient` uses it together with a `Dns` that maps `h-<hostIdHex>.e2e.invalid` to a placeholder address.
|
|
213
|
+
- `setSoTimeout` MUST be honoured, so OkHttp's existing read timeouts (90 s for SSE) keep working.
|
|
214
|
+
- The outer WebSocket client is a separate `OkHttpClient` that uses the normal socket factory.
|
|
215
|
+
|
|
216
|
+
## 9. Relay protocol (outside the encryption)
|
|
217
|
+
|
|
218
|
+
- **Hosts.** The relay nodes are `relay1.openmausbot.com` and `relay2.openmausbot.com`, WSS on 443. Caddy terminates TLS and proxies to the
|
|
219
|
+
relay on `127.0.0.1:8080`. Auth is always the **first WebSocket message**, which works with every WebSocket client (Node's built-in client
|
|
220
|
+
included).
|
|
221
|
+
- **Pre-auth rules:**
|
|
222
|
+
- The relay accepts exactly one text message of at most 4 KiB within 5 s.
|
|
223
|
+
- Anything else before auth, a binary message included, closes with 4400.
|
|
224
|
+
- The WebSocket `maxPayload` is 70,000 bytes on every socket, before and after auth (1009 if exceeded).
|
|
225
|
+
|
|
226
|
+
**Host control socket** (`GET /v1/host`, subprotocol `omb-relay.v1`, text JSON only):
|
|
227
|
+
|
|
228
|
+
| Dir | Message | Meaning |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| host→relay | `{"t":"auth","v":1,"token":"<relay token>"}` | First message. Sent again to refresh before `exp`. Must keep the same `hid` |
|
|
231
|
+
| relay→host | `{"t":"hello","v":1,"node":"relay1","sessionsMax":16}` | Auth accepted |
|
|
232
|
+
| host→relay | `{"t":"admit","set":["<b64u SHA256(adm)>",…]}` | The full replacement admission set, at most 32 entries. Required within 5 s of `hello`, and again on every pair, revoke, restriction change, or pairing window open or close. Sessions whose `adm` leaves the set are closed with 4404 |
|
|
233
|
+
| relay→host | `{"t":"open","sid":"<b64u 16 bytes>"}` | A phone is waiting. The host must open a data socket within 10 s |
|
|
234
|
+
| relay→host | `{"t":"restarting","reconnectInMs":0,"tryForMs":15000}` | Deploy or shutdown. Reconnect after `reconnectInMs + random(0, tryForMs)` |
|
|
235
|
+
| relay→host | close 4409 | A newer control socket for the same `hid` took over. Spliced sessions are not affected |
|
|
236
|
+
|
|
237
|
+
**Host data socket** (`GET /v1/host/data`): the first message is `{"t":"data","v":1,"sid":"…","token":"<relay token>"}`. The token's `hid` must
|
|
238
|
+
own the `sid`, and a `sid` is single-use with a 10 s life. The relay then splices and the socket carries only binary.
|
|
239
|
+
|
|
240
|
+
**Phone socket** (`GET /v1/phone`):
|
|
241
|
+
- The first message is `{"t":"phone","v":1,"hid":"<b64u hostId>","adm":"<b64u adm>"}`.
|
|
242
|
+
- If that host isn't connected to this node **or** `SHA256(adm)` isn't in its set, the relay closes with **4404** (both cases, so online status
|
|
243
|
+
doesn't leak).
|
|
244
|
+
- Otherwise it sends `open` to the host, waits for the data socket, and sends `{"t":"ready"}` to the phone. From then on only binary passes.
|
|
245
|
+
- The phone sends handshake message 1 after `ready`. Binary from the phone before `ready` means 4400.
|
|
246
|
+
|
|
247
|
+
**Close codes:**
|
|
248
|
+
|
|
249
|
+
| Code | Sent by | Meaning | Phone or host does |
|
|
250
|
+
|---|---|---|---|
|
|
251
|
+
| 4400 | relay | Malformed or out-of-order message | Treat as a bug; back off |
|
|
252
|
+
| 4401 | relay | Host token invalid or expired | Host fetches a new token |
|
|
253
|
+
| 4404 | relay | Computer not connected here, or not admitted | Phone tries the next `r[]` node; when all are tried, "asleep or offline" |
|
|
254
|
+
| 4408 | relay | The host didn't open a data socket in time | Retry once, then back off |
|
|
255
|
+
| 4409 | relay | Replaced by a newer host control socket | Host: stop (the newer one wins) |
|
|
256
|
+
| 4429 | relay | Rate, size or byte limit | Back off, at least 30 s |
|
|
257
|
+
| 4503 | relay | Restarting or full. Reason is JSON `{"retryInMs":N}` | Wait N plus jitter, then try the next node |
|
|
258
|
+
| 4000 | either end | E2E protocol error (AEAD, framing, window) | Hard stop for this session; reconnect with backoff |
|
|
259
|
+
| 4001 | computer | Handshake rejected (no psk matched), or the device was removed (after GoAway(3)) | Phone: on a direct route, or after GoAway(3), "This phone was removed from your computer. Pair again." Through the relay without GoAway(3), the relay may have written it: retry with backoff |
|
|
260
|
+
| 4002 | either end | Handshake deadline passed | Retry with backoff |
|
|
261
|
+
|
|
262
|
+
**Relay token** (minted by the control plane, checked by the relay offline):
|
|
263
|
+
- Format: `b64u(payloadJSON) "." b64u(Ed25519 signature over the ASCII bytes of the first part)`.
|
|
264
|
+
- Payload: `{"v":1,"kid":"k1","hid":"<b64u>","iid":"<installation id>","iat":<s>,"exp":<s>}` with `exp = iat + 12 h`.
|
|
265
|
+
- The relay holds public keys only: `RELAY_TOKEN_KEYS = {"k1":"<b64u 32-byte Ed25519 public key>"}`, up to 2 for rotation.
|
|
266
|
+
|
|
267
|
+
**Control plane endpoint:** `POST /v1/installations/self/relay-token` with `Authorization: Bearer <omb_install_…>`.
|
|
268
|
+
- **Request body:** `{"appVersion":"0.1.150","devices":{"e2e":2,"legacy":1}}`. The counts are for adoption gates and carry no
|
|
269
|
+
identifiers.
|
|
270
|
+
- **200 response:** `{"token","hostId","nodes":["relay1.openmausbot.com","relay2.openmausbot.com"],"expiresAt","refreshAfter"}`.
|
|
271
|
+
- `refreshAfter = iat + 1 h`.
|
|
272
|
+
- `nodes` is ordered by rendezvous hash: highest `SHA256(hostId ‖ nodeName)[0:8]` first. A future US node is ranked first when the
|
|
273
|
+
request's `cf.continent` is NA or SA.
|
|
274
|
+
- **Other responses:**
|
|
275
|
+
- `503 relay_unavailable`: rollout off or kill switch. The desktop stays on Wi-Fi and Tailscale.
|
|
276
|
+
- `401`: install credential expired, which leads to recovery as today.
|
|
277
|
+
- `429`: more than 60 a hour.
|
|
278
|
+
|
|
279
|
+
## 10. Timers and limits
|
|
280
|
+
|
|
281
|
+
| What | Value |
|
|
282
|
+
|---|---|
|
|
283
|
+
| Handshake deadline | 10 s, both ends |
|
|
284
|
+
| yamux ping | Every 20 s; dead if no answer in 10 s |
|
|
285
|
+
| Relay WebSocket ping (to every socket it owns) | Every 20 s; dead if no pong in 10 s |
|
|
286
|
+
| Phone direct attempt timeout / relay attempt timeout | 5 s / 10 s |
|
|
287
|
+
| Host reconnect after loss | Immediate, then 1, 2, 4 … 60 s with ±20% jitter |
|
|
288
|
+
| Host reconnect after `restarting` / 4503 | `reconnectInMs + random(0, tryForMs)`; default random 1–15 s |
|
|
289
|
+
| Sleep detection (desktop) | A wall-clock jump of more than 5 s against the monotonic clock, checked every 2 s, or Electron `powerMonitor` `resume`: redial now |
|
|
290
|
+
| Host returns to its first node | While on a later node, try the first every 10 min ±2 min. Move when it accepts: open the new control socket, then close the old one |
|
|
291
|
+
| Token refresh | Every 1 h. On failure retry with backoff; spliced sessions survive an expired control socket |
|
|
292
|
+
| Relay per-IP | 60 new sockets a minute (token bucket), 256 open sockets (allows for carrier NAT) |
|
|
293
|
+
| Relay per host | 1 control socket, 16 phone sessions, admission set of 32 |
|
|
294
|
+
| Relay backpressure | Pause the source when the destination's buffered amount is above 1 MiB; resume below 256 KiB. No drain progress for 60 s means close 4429 |
|
|
295
|
+
| Relay heavy session | Averaging more than 50 Mbit/s over 30 min: throttle to 20 Mbit/s |
|
|
296
|
+
| Relay per-host byte budget | 50 GB a day, then 4429 plus an alert (abuse brake) |
|
|
297
|
+
| Relay global | 30,000 sockets per node, then 4503 `{"retryInMs":5000}`. Alert at 70% |
|
|
298
|
+
|
|
299
|
+
## 11. Fail-closed rules (MUST)
|
|
300
|
+
|
|
301
|
+
1. There is no plaintext mode, and no message from the relay can switch encryption off or change keys, versions or cipher.
|
|
302
|
+
2. A pin mismatch, a message 2 that fails to decrypt, `v ≠ 1`, or a `conn` mismatch stops the session. The phone shows "Couldn't verify your
|
|
303
|
+
computer. Pair again." and does **not** retry that connection automatically.
|
|
304
|
+
3. Once a connection has a pinned `k`, the phone NEVER again uses the plain HTTP routes (`lan`, `bonjour`, plain `tailnet`) or the `hosted`
|
|
305
|
+
tunnel route for that connection. E2E routes only.
|
|
306
|
+
4. Once a device has completed an E2E session (`DeviceRecord.e2eAt`), the sidecar refuses that device's token on the plain `:8810` listener
|
|
307
|
+
(`426 {"error":"update_app"}`). The only exception is `/api/health`.
|
|
308
|
+
5. Every AEAD failure, unexpected yamux frame or window violation closes the whole session. Nothing is skipped.
|
|
309
|
+
6. Key and pin comparisons are full-length and constant-time.
|
|
310
|
+
|
|
311
|
+
## 12. What the phone shows (error → one line)
|
|
312
|
+
|
|
313
|
+
| Evidence | Line |
|
|
314
|
+
|---|---|
|
|
315
|
+
| Every route failed and the relay answered 4404 from every node | "Your computer is asleep or offline." |
|
|
316
|
+
| The phone has no network (path monitor) | "You're offline." |
|
|
317
|
+
| Direct routes failed and the relay couldn't be reached | "Can't reach MausBot Relay right now. Trying again…" |
|
|
318
|
+
| Restricted device, not on the home Wi-Fi or the tailnet | "This phone is set to Wi-Fi and Tailscale only." |
|
|
319
|
+
| Message 2 failed / pin mismatch | "Couldn't verify your computer. Pair again from your computer's Settings." |
|
|
320
|
+
| GoAway(3) inside the session, or close 4001 on a direct route | "This phone was removed from your computer. Pair again." |
|
|
321
|
+
| Close 4001 through the relay with no GoAway(3) before it | Nothing yet: a relay can forge close codes, so keep retrying with backoff. It reads as "removed" only if a direct route also answers 4001 |
|
|
322
|
+
| `e2e.v > 1` or 426 | "Update the MausBot app to connect to this computer." |
|
|
323
|
+
| 4429 / 4503 lasting more than 30 s | "MausBot Relay is busy. Trying again…" |
|
|
324
|
+
|
|
325
|
+
## 13. Rules the vectors pin
|
|
326
|
+
|
|
327
|
+
These make the sections above exact where an implementation could otherwise choose. Each one has a V6 case.
|
|
328
|
+
|
|
329
|
+
**Handshake**
|
|
330
|
+
- Message 1 is 81 to 593 bytes (`65 + 16` to `65 + 512 + 16`), message 2 the same. Anything else is refused before any
|
|
331
|
+
crypto: the computer closes 4001, the phone hard-stops.
|
|
332
|
+
- `payload1`: a JSON object with `v` equal to the number 1, `conn` the b64u of exactly 16 bytes, and `app` (optional) a
|
|
333
|
+
string of at most 40 characters. Key order and whitespace are free. Anything else closes 4001.
|
|
334
|
+
- `payload2` is checked the same way: `v` must be 1 and `conn` must equal the one sent.
|
|
335
|
+
- The computer tries every candidate psk, not only until the first match, so timing doesn't reveal which device it is.
|
|
336
|
+
- Vector tests feed `payload1` and `payload2` as exact bytes, since JSON encoders differ in key order.
|
|
337
|
+
|
|
338
|
+
**Records**
|
|
339
|
+
- A record is 16 to 65,535 bytes. An empty or longer one closes the session (4000).
|
|
340
|
+
|
|
341
|
+
**yamux**
|
|
342
|
+
- One record's plaintext is exactly one frame: Data is `12 + length` bytes, every other type exactly 12.
|
|
343
|
+
- Refused at decode (GoAway(1), then close 4000): `version ≠ 0`; `type > 3`; a flag bit above 0x8; Data `length > 65,507`;
|
|
344
|
+
Data or WindowUpdate on stream 0; Ping or GoAway on a stream other than 0; Ping flags other than exactly SYN or exactly
|
|
345
|
+
ACK; GoAway with any flag.
|
|
346
|
+
- A stream opens only with a WindowUpdate whose flags are exactly SYN; SYN on Data, or with another flag, is refused. The
|
|
347
|
+
open's `length` is the window delta like any WindowUpdate (1,835,008 when sent).
|
|
348
|
+
- Only the computer sends ACK, only on WindowUpdate, and once per stream. ACK from the phone, or a second ACK, is refused.
|
|
349
|
+
- A frame for an odd stream ID at or below the highest opened, whose stream has ended (both FINs, or RST either way), is
|
|
350
|
+
ignored: an RST and the peer's last frames cross. A frame for an even ID, or an ID above the highest opened, is refused.
|
|
351
|
+
- Data or FIN after the peer's FIN is refused.
|
|
352
|
+
- FIN is sent as WindowUpdate {FIN} with length 0, RST as WindowUpdate {RST} with length 0. FIN on Data is accepted.
|
|
353
|
+
- Window accounting: each side's receive window for a stream starts at 256 KiB and grows by every delta it grants (the
|
|
354
|
+
open's 1,835,008 on the phone, the accept's 1,835,008 on the computer, and later WindowUpdates). Data beyond it is
|
|
355
|
+
refused. The grant is sent once consumed-but-ungranted bytes reach 1 MiB, as one WindowUpdate for all of them.
|
|
356
|
+
- The computer answers a SYN that would be the 65th concurrent stream, or any SYN after it has sent GoAway, with RST.
|
|
357
|
+
- The computer sends no Data on a stream before it has sent the ACK.
|
|
358
|
+
|
|
359
|
+
**Descriptor**
|
|
360
|
+
- `v` must be an integer. `v > 1` means "update the app" whatever the other fields hold; `v < 1`, a string, or a missing
|
|
361
|
+
`v` rejects.
|
|
362
|
+
- Unknown fields are ignored. Field order is free.
|
|
363
|
+
- `routes`, when present, must be the string `"any"` or `"local"`. `"local"` may carry `h` and `r` (vector
|
|
364
|
+
`routes_local_with_relay`); a restricted phone dials only its direct routes, and probes the relay on a slow backoff only
|
|
365
|
+
when they fail.
|
|
366
|
+
|
|
367
|
+
**Removal**
|
|
368
|
+
- A close code travels outside the encryption, and over the relay the relay writes it. When a device is removed, the
|
|
369
|
+
computer first sends GoAway with `length` 3 inside the session, then closes with 4001. The phone treats only that
|
|
370
|
+
GoAway(3), or a 4001 on a direct route, as removed. A 4001 from the relay without it (including at the handshake, where
|
|
371
|
+
no session exists yet) means "couldn't confirm": retry with backoff and try a direct route. The computer refuses a
|
|
372
|
+
GoAway(3) from the phone as a protocol error.
|
|
373
|
+
- base64url is strict everywhere (descriptor, keys, `h`, `conn`, tokens): the URL-safe alphabet only, no padding, and
|
|
374
|
+
canonical (unused low bits of the last character are zero). Decode, re-encode and compare is enough.
|
|
375
|
+
|
|
376
|
+
**Relay token**
|
|
377
|
+
- At most 2,048 characters, exactly one `.`, both halves strict b64u, a 64-byte signature.
|
|
378
|
+
- Payload: `v` = 1, `kid` matching `^k[0-9]{1,3}$`, `hid` the b64u of 16 bytes, `iid` a string of 1 to 64 characters,
|
|
379
|
+
`iat` and `exp` positive integers with `iat < exp ≤ iat + 43,200`.
|
|
380
|
+
- A `kid` not in `RELAY_TOKEN_KEYS` fails as `kid`; a bad signature as `signature`; `now ≥ exp` as `expired`; a refresh
|
|
381
|
+
with a different `hid` as `hid`. Error names are informative: only pass or fail is required.
|
|
382
|
+
|
|
383
|
+
## 14. Test vectors
|
|
384
|
+
|
|
385
|
+
`vectors/v1.json` holds V1 to V9; `vectors/v1.sha256` pins it. Copies elsewhere check the SHA-256 so a stale copy fails.
|
|
386
|
+
Binary is hex unless a field name ends in `B64u`. Implementations take fixed ephemeral keys and exact payload bytes in
|
|
387
|
+
tests.
|
|
388
|
+
|
|
389
|
+
| # | Case |
|
|
390
|
+
|---|---|
|
|
391
|
+
| V1 | Derivations: `T.pub`, `keyId`, `SHA256(cred)`, `psk` and `adm` for a device and a pairing token, the DH outputs, and which encodings of a point are valid |
|
|
392
|
+
| V2 | Handshake over the relay (`0x01`) with the device psk: prologue, `h`, `ck` and `k` after every step, both messages, `k_pc`, `k_cp`, final `h` |
|
|
393
|
+
| V3 | The same over a direct route (`0x02`) with the pairing psk |
|
|
394
|
+
| V4 | Transport records of the V2 session: nonce, frame and record for each direction |
|
|
395
|
+
| V5 | yamux frames byte for byte |
|
|
396
|
+
| V6 | Must fail: handshake (computer closes 4001, or phone hard-stops), records (fails at which record), frames (decode), and session scripts |
|
|
397
|
+
| V7 | Pairing descriptors: valid ones and their parsed form; invalid ones and `reject` or `update` |
|
|
398
|
+
| V8 | Relay token: mint, and verify cases |
|
|
399
|
+
| V9 | Relay protocol messages for the vector host (informative) |
|
|
400
|
+
|
|
401
|
+
**Session scripts (V6 `sessions`).** Run the steps against a fresh mux in `role`. `{"open":true}` opens a stream from the
|
|
402
|
+
phone. `{"consume":{"stream":1,"bytes":N}}` reports N bytes consumed. `{"in":{"header":…,"payload":…}}` delivers one
|
|
403
|
+
frame; `"fill": 0` instead of `payload` means `length` zero bytes. With `"accept":"immediate"` the computer accepts each
|
|
404
|
+
stream as it opens; with `"never"` it doesn't. The expectation is either `errorAt` (the index of the step that must be
|
|
405
|
+
refused) or `sent` (every frame the mux sent, in order).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Secrets derived from a device or pairing token, the relay admission value, and the relay host token.
|
|
2
|
+
// Wire spec: companion/src/e2e/README.md, "Identities and derived secrets" and "Relay token".
|
|
3
|
+
import { createHash, createHmac, createPrivateKey, createPublicKey, sign, timingSafeEqual, verify, type KeyObject } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const B64U = /^[A-Za-z0-9_-]*$/;
|
|
6
|
+
|
|
7
|
+
export const b64u = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
|
|
8
|
+
|
|
9
|
+
/** Strict: unpadded URL-safe alphabet only, and canonical (re-encoding gives the same text). Node's own decoder
|
|
10
|
+
* skips characters it does not know, which would let two spellings mean one value. */
|
|
11
|
+
export function fromB64u(text: string): Buffer | null {
|
|
12
|
+
if (!B64U.test(text) || text.length % 4 === 1) return null;
|
|
13
|
+
const bytes = Buffer.from(text, "base64url");
|
|
14
|
+
return bytes.toString("base64url") === text ? bytes : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const sha256 = (bytes: Uint8Array | string): Buffer => createHash("sha256").update(bytes).digest();
|
|
18
|
+
export const hmacSha256 = (key: Uint8Array, message: Uint8Array): Buffer => createHmac("sha256", key).update(message).digest();
|
|
19
|
+
|
|
20
|
+
/** Full-length and constant-time; different lengths are simply unequal. */
|
|
21
|
+
export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
22
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const KEY_ID_BYTES = 16;
|
|
26
|
+
export const HOST_ID_BYTES = 16;
|
|
27
|
+
|
|
28
|
+
const PSK_LABEL = Buffer.from("omb-e2e psk v1", "utf8");
|
|
29
|
+
const ADMISSION_LABEL = Buffer.from("omb-relay admission v1", "utf8");
|
|
30
|
+
|
|
31
|
+
/** `SHA256(cred)`: the raw digest of the token's UTF-8 bytes. The desktop stores its hex as `tokenHash`. */
|
|
32
|
+
export const credentialHash = (token: string): Buffer => sha256(Buffer.from(token, "utf8"));
|
|
33
|
+
|
|
34
|
+
/** Accepts the stored hex form; null for anything that is not 64 hex characters. */
|
|
35
|
+
export function credentialHashFromHex(hex: string): Buffer | null {
|
|
36
|
+
return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const keyId = (transportPublicKey: Uint8Array): Buffer => sha256(transportPublicKey).subarray(0, KEY_ID_BYTES);
|
|
40
|
+
|
|
41
|
+
export const psk = (credHash: Uint8Array, transportKeyId: Uint8Array): Buffer =>
|
|
42
|
+
hmacSha256(credHash, Buffer.concat([PSK_LABEL, transportKeyId]));
|
|
43
|
+
|
|
44
|
+
export const admission = (credHash: Uint8Array, hostId: Uint8Array): Buffer =>
|
|
45
|
+
hmacSha256(credHash, Buffer.concat([ADMISSION_LABEL, hostId]));
|
|
46
|
+
|
|
47
|
+
/** What the host puts in its relay admission set, and all the relay ever stores. */
|
|
48
|
+
export const admissionHash = (adm: Uint8Array): string => b64u(sha256(adm));
|
|
49
|
+
|
|
50
|
+
export const RELAY_TOKEN_TTL_S = 12 * 60 * 60;
|
|
51
|
+
export const RELAY_TOKEN_MAX_CHARS = 2048;
|
|
52
|
+
const KID = /^k[0-9]{1,3}$/;
|
|
53
|
+
|
|
54
|
+
export interface RelayTokenClaims {
|
|
55
|
+
v: 1;
|
|
56
|
+
kid: string;
|
|
57
|
+
/** b64u of the 16-byte hostId */
|
|
58
|
+
hid: string;
|
|
59
|
+
iid: string;
|
|
60
|
+
iat: number;
|
|
61
|
+
exp: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ed25519 from its 32-byte seed (RFC 8032 private key) via a fixed PKCS#8 prefix. */
|
|
65
|
+
export function ed25519PrivateKey(seed: Uint8Array): KeyObject {
|
|
66
|
+
if (seed.length !== 32) throw new Error("an Ed25519 seed is 32 bytes");
|
|
67
|
+
const der = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]);
|
|
68
|
+
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ed25519PublicKey(raw: Uint8Array): KeyObject {
|
|
72
|
+
if (raw.length !== 32) throw new Error("an Ed25519 public key is 32 bytes");
|
|
73
|
+
const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]);
|
|
74
|
+
return createPublicKey({ key: der, format: "der", type: "spki" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const ed25519RawPublicKey = (key: KeyObject): Buffer =>
|
|
78
|
+
Buffer.from(createPublicKey(key).export({ format: "der", type: "spki" })).subarray(-32);
|
|
79
|
+
|
|
80
|
+
/** Field order is part of the format: the signature covers these exact bytes. */
|
|
81
|
+
export function mintRelayToken(claims: RelayTokenClaims, signingKey: KeyObject): string {
|
|
82
|
+
const body = b64u(Buffer.from(JSON.stringify({
|
|
83
|
+
v: claims.v, kid: claims.kid, hid: claims.hid, iid: claims.iid, iat: claims.iat, exp: claims.exp,
|
|
84
|
+
}), "utf8"));
|
|
85
|
+
return `${body}.${b64u(sign(null, Buffer.from(body, "ascii"), signingKey))}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type RelayTokenError = "malformed" | "kid" | "signature" | "expired" | "hid";
|
|
89
|
+
|
|
90
|
+
const isSeconds = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
|
|
91
|
+
|
|
92
|
+
/** `keys` maps kid to a raw 32-byte Ed25519 public key. `expectHid` is the hostId already bound to the socket,
|
|
93
|
+
* for a refresh. */
|
|
94
|
+
export function verifyRelayToken(
|
|
95
|
+
token: string,
|
|
96
|
+
keys: ReadonlyMap<string, Uint8Array>,
|
|
97
|
+
nowS: number,
|
|
98
|
+
expectHid?: string,
|
|
99
|
+
): { ok: true; claims: RelayTokenClaims } | { ok: false; error: RelayTokenError } {
|
|
100
|
+
const fail = (error: RelayTokenError) => ({ ok: false as const, error });
|
|
101
|
+
if (typeof token !== "string" || token.length > RELAY_TOKEN_MAX_CHARS) return fail("malformed");
|
|
102
|
+
const parts = token.split(".");
|
|
103
|
+
if (parts.length !== 2) return fail("malformed");
|
|
104
|
+
const [body, signatureText] = parts;
|
|
105
|
+
const payload = fromB64u(body);
|
|
106
|
+
const signature = fromB64u(signatureText);
|
|
107
|
+
if (!payload || !signature || signature.length !== 64) return fail("malformed");
|
|
108
|
+
let claims: Record<string, unknown>;
|
|
109
|
+
try {
|
|
110
|
+
const parsed: unknown = JSON.parse(payload.toString("utf8"));
|
|
111
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fail("malformed");
|
|
112
|
+
claims = parsed as Record<string, unknown>;
|
|
113
|
+
} catch {
|
|
114
|
+
return fail("malformed");
|
|
115
|
+
}
|
|
116
|
+
if (claims.v !== 1 || typeof claims.kid !== "string" || !KID.test(claims.kid)) return fail("malformed");
|
|
117
|
+
const raw = keys.get(claims.kid);
|
|
118
|
+
if (!raw) return fail("kid");
|
|
119
|
+
if (!verify(null, Buffer.from(body, "ascii"), ed25519PublicKey(raw), signature)) return fail("signature");
|
|
120
|
+
const hid = typeof claims.hid === "string" ? fromB64u(claims.hid) : null;
|
|
121
|
+
if (!hid || hid.length !== HOST_ID_BYTES) return fail("malformed");
|
|
122
|
+
if (typeof claims.iid !== "string" || claims.iid.length === 0 || claims.iid.length > 64) return fail("malformed");
|
|
123
|
+
if (!isSeconds(claims.iat) || !isSeconds(claims.exp) || claims.exp <= claims.iat) return fail("malformed");
|
|
124
|
+
if (claims.exp - claims.iat > RELAY_TOKEN_TTL_S) return fail("malformed");
|
|
125
|
+
if (nowS >= claims.exp) return fail("expired");
|
|
126
|
+
if (expectHid !== undefined && claims.hid !== expectHid) return fail("hid");
|
|
127
|
+
return { ok: true, claims: claims as unknown as RelayTokenClaims };
|
|
128
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The `e2e` pairing descriptor: in the QR link's `e2e` parameter, in the pair response and in the endpoint list.
|
|
2
|
+
// Wire spec: companion/src/e2e/README.md, "Pairing payload and descriptors".
|
|
3
|
+
import { b64u, fromB64u, HOST_ID_BYTES } from "./credentials.ts";
|
|
4
|
+
import { isPublicKey, PUBLIC_KEY_BYTES } from "./noise.ts";
|
|
5
|
+
|
|
6
|
+
export const DESCRIPTOR_VERSION = 1;
|
|
7
|
+
export const MAX_DESCRIPTOR_CHARS = 1024;
|
|
8
|
+
export const MAX_RELAY_NODES = 4;
|
|
9
|
+
export const RELAY_HOST = /^relay[1-9][0-9]?\.openmausbot\.com$/;
|
|
10
|
+
|
|
11
|
+
export type DescriptorRoutes = "any" | "local";
|
|
12
|
+
|
|
13
|
+
export interface E2EDescriptor {
|
|
14
|
+
v: 1;
|
|
15
|
+
/** b64u of the 65-byte transport key `T.pub` */
|
|
16
|
+
k: string;
|
|
17
|
+
/** b64u of the 16-byte hostId; present only with `r` */
|
|
18
|
+
h?: string;
|
|
19
|
+
/** relay hostnames in the order to try */
|
|
20
|
+
r?: string[];
|
|
21
|
+
/** "local": the computer restricted this device to Wi-Fi and Tailscale. It may still carry `h` and `r`, so a phone away
|
|
22
|
+
* from home can probe the relay now and then and learn when it is allowed again; the relay refuses it (4404) until
|
|
23
|
+
* then. Absent means "any"; then a missing relay only means the computer has none (signed out, or outside the rollout). */
|
|
24
|
+
routes?: DescriptorRoutes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encodeDescriptor(descriptor: E2EDescriptor): string {
|
|
28
|
+
const relay = descriptor.h && descriptor.r ? { h: descriptor.h, r: descriptor.r } : {};
|
|
29
|
+
const json = JSON.stringify({ v: 1, k: descriptor.k, ...relay, ...(descriptor.routes ? { routes: descriptor.routes } : {}) });
|
|
30
|
+
return b64u(Buffer.from(json, "utf8"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A restricted device's descriptor says so; it keeps the relay identity, which admits nothing by itself. */
|
|
34
|
+
export function descriptorFor(
|
|
35
|
+
transportPublicKey: Uint8Array,
|
|
36
|
+
relay?: { hostId: Uint8Array; nodes: readonly string[] },
|
|
37
|
+
routes?: DescriptorRoutes,
|
|
38
|
+
): E2EDescriptor {
|
|
39
|
+
const base = relay && relay.nodes.length > 0
|
|
40
|
+
? { v: 1 as const, k: b64u(transportPublicKey), h: b64u(relay.hostId), r: [...relay.nodes] }
|
|
41
|
+
: { v: 1 as const, k: b64u(transportPublicKey) };
|
|
42
|
+
return routes === "local" ? { ...base, routes: "local" } : base;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `update` means a newer version: the phone says "Update the MausBot app" and never falls back to plain pairing.
|
|
46
|
+
* `reject` refuses the whole invite. */
|
|
47
|
+
export type DescriptorParse =
|
|
48
|
+
| { outcome: "ok"; descriptor: E2EDescriptor; transportKey: Buffer; hostId: Buffer | null }
|
|
49
|
+
| { outcome: "update" }
|
|
50
|
+
| { outcome: "reject"; reason: string };
|
|
51
|
+
|
|
52
|
+
/** Accepts either the b64u text (the QR parameter) or the already-decoded object (a JSON response field). */
|
|
53
|
+
export function parseDescriptor(input: unknown): DescriptorParse {
|
|
54
|
+
const reject = (reason: string): DescriptorParse => ({ outcome: "reject", reason });
|
|
55
|
+
let value: unknown = input;
|
|
56
|
+
if (typeof input === "string") {
|
|
57
|
+
if (input.length > MAX_DESCRIPTOR_CHARS) return reject("too_long");
|
|
58
|
+
const bytes = fromB64u(input);
|
|
59
|
+
if (!bytes) return reject("base64");
|
|
60
|
+
try {
|
|
61
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return reject("json");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return reject("json");
|
|
67
|
+
const fields = value as Record<string, unknown>;
|
|
68
|
+
if (!Number.isSafeInteger(fields.v) || (fields.v as number) < 1) return reject("version");
|
|
69
|
+
if ((fields.v as number) > DESCRIPTOR_VERSION) return { outcome: "update" };
|
|
70
|
+
const transportKey = typeof fields.k === "string" ? fromB64u(fields.k) : null;
|
|
71
|
+
if (!transportKey || transportKey.length !== PUBLIC_KEY_BYTES || !isPublicKey(transportKey)) return reject("key");
|
|
72
|
+
const hasHost = fields.h !== undefined;
|
|
73
|
+
const hasRelays = fields.r !== undefined;
|
|
74
|
+
if (hasHost !== hasRelays) return reject("relay_pair");
|
|
75
|
+
if (fields.routes !== undefined && fields.routes !== "any" && fields.routes !== "local") return reject("routes");
|
|
76
|
+
const routes = fields.routes === undefined ? {} : { routes: fields.routes as DescriptorRoutes };
|
|
77
|
+
if (!hasHost) return { outcome: "ok", descriptor: { v: 1, k: fields.k as string, ...routes }, transportKey, hostId: null };
|
|
78
|
+
const hostId = typeof fields.h === "string" ? fromB64u(fields.h) : null;
|
|
79
|
+
if (!hostId || hostId.length !== HOST_ID_BYTES) return reject("host_id");
|
|
80
|
+
const relays = fields.r;
|
|
81
|
+
if (!Array.isArray(relays) || relays.length < 1 || relays.length > MAX_RELAY_NODES) return reject("relays");
|
|
82
|
+
if (!relays.every((host): host is string => typeof host === "string" && RELAY_HOST.test(host))) return reject("relay_host");
|
|
83
|
+
return { outcome: "ok", descriptor: { v: 1, k: fields.k as string, h: fields.h as string, r: [...relays], ...routes }, transportKey, hostId };
|
|
84
|
+
}
|