gitspace 0.2.0-rc.1
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/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Flow Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the complete flow of:
|
|
5
|
+
* 1. Relay server running
|
|
6
|
+
* 2. Machine connecting, registering, and creating invites
|
|
7
|
+
* 3. Client connecting via invite
|
|
8
|
+
* 4. X3DH handshake completing
|
|
9
|
+
* 5. Encrypted data exchange
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
13
|
+
import { createRelayServer } from "../server";
|
|
14
|
+
import { generateRelayIdentity } from "../identity";
|
|
15
|
+
import { clearAllRegistries } from "../registries";
|
|
16
|
+
import type { Server } from "bun";
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
|
|
19
|
+
import { RelayClient } from "../../lib/tmux-lite/relay-client";
|
|
20
|
+
import { HandshakeHandler } from "../../lib/tmux-lite/handshake-handler";
|
|
21
|
+
import { AccessControlList } from "../../lib/tmux-lite/crypto/access-control";
|
|
22
|
+
import {
|
|
23
|
+
createTestIdentity,
|
|
24
|
+
createIdentityFixtures,
|
|
25
|
+
toPublicIdentity,
|
|
26
|
+
} from "../../lib/tmux-lite/crypto/__tests__/helpers/test-identities";
|
|
27
|
+
import { createInviteToken } from "../../lib/tmux-lite/crypto/invites";
|
|
28
|
+
import type { Identity, AccessType } from "../../types/identity";
|
|
29
|
+
import { getRelayClientTestAccess } from "../../__tests__/test-utils";
|
|
30
|
+
import { signChallenge, getSigningKeyBase64 } from "./helpers/auth";
|
|
31
|
+
import { startRelayServer } from "./helpers/ports";
|
|
32
|
+
|
|
33
|
+
const TEST_HOST = "127.0.0.1";
|
|
34
|
+
let relayUrl = "";
|
|
35
|
+
let relayHttpBase = "";
|
|
36
|
+
|
|
37
|
+
// Generate test identities
|
|
38
|
+
const testRelayIdentity = generateRelayIdentity("e2e-test-relay");
|
|
39
|
+
const testFixtures = createIdentityFixtures();
|
|
40
|
+
|
|
41
|
+
let server: Server<any>;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
// Pre-authorize the machine identity used in tests
|
|
45
|
+
server = startRelayServer({
|
|
46
|
+
bind: TEST_HOST,
|
|
47
|
+
hostname: TEST_HOST,
|
|
48
|
+
disableRateLimit: true,
|
|
49
|
+
identity: testRelayIdentity,
|
|
50
|
+
preAuthorizedMachines: new Set([
|
|
51
|
+
getSigningKeyBase64(testFixtures.machine),
|
|
52
|
+
]),
|
|
53
|
+
});
|
|
54
|
+
relayUrl = `ws://${TEST_HOST}:${server.port}/ws`;
|
|
55
|
+
relayHttpBase = `http://${TEST_HOST}:${server.port}`;
|
|
56
|
+
|
|
57
|
+
// Wait for the server to start accepting connections (avoid flakiness/races).
|
|
58
|
+
const deadline = Date.now() + 3000;
|
|
59
|
+
// eslint-disable-next-line no-constant-condition
|
|
60
|
+
while (true) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${relayHttpBase}/health`);
|
|
63
|
+
if (res.ok) break;
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore until deadline
|
|
66
|
+
}
|
|
67
|
+
if (Date.now() > deadline) {
|
|
68
|
+
throw new Error("Relay server did not become healthy in time");
|
|
69
|
+
}
|
|
70
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
server.stop(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
clearAllRegistries();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Helper Functions
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a machine connection that registers with relay using challenge-response auth
|
|
88
|
+
*/
|
|
89
|
+
async function createMachineConnection(
|
|
90
|
+
machineIdentity: Identity,
|
|
91
|
+
accessList: AccessControlList
|
|
92
|
+
): Promise<{
|
|
93
|
+
ws: WebSocket;
|
|
94
|
+
handshakeHandler: HandshakeHandler;
|
|
95
|
+
connectionId: string;
|
|
96
|
+
}> {
|
|
97
|
+
const url = new URL(relayUrl);
|
|
98
|
+
url.searchParams.set("role", "machine");
|
|
99
|
+
// No token needed - auth via challenge-response
|
|
100
|
+
|
|
101
|
+
const ws = new WebSocket(url.toString());
|
|
102
|
+
ws.binaryType = "arraybuffer";
|
|
103
|
+
|
|
104
|
+
await new Promise<void>((resolve, reject) => {
|
|
105
|
+
ws.onopen = () => resolve();
|
|
106
|
+
ws.onerror = () => reject(new Error("Machine connection failed"));
|
|
107
|
+
setTimeout(() => reject(new Error("Timeout")), 5000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Wait for relay_identity with challenge
|
|
111
|
+
const challenge = await new Promise<Uint8Array>((resolve, reject) => {
|
|
112
|
+
const timeout = setTimeout(() => reject(new Error("Challenge timeout")), 5000);
|
|
113
|
+
ws.onmessage = (event) => {
|
|
114
|
+
try {
|
|
115
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
116
|
+
const msg = JSON.parse(data);
|
|
117
|
+
if (msg.type === "relay_identity" && msg.challenge) {
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
resolve(Buffer.from(msg.challenge, "base64"));
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Ignore
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Sign challenge
|
|
128
|
+
const signature = signChallenge(challenge, machineIdentity.signing.secretKey);
|
|
129
|
+
const publicIdentity = toPublicIdentity(machineIdentity);
|
|
130
|
+
|
|
131
|
+
// Register machine with challenge response
|
|
132
|
+
ws.send(JSON.stringify({
|
|
133
|
+
type: "register_machine",
|
|
134
|
+
machineId: machineIdentity.id,
|
|
135
|
+
signingKey: publicIdentity.signingPublicKey,
|
|
136
|
+
keyExchangeKey: publicIdentity.keyExchangePublicKey,
|
|
137
|
+
challengeResponse: signature,
|
|
138
|
+
label: machineIdentity.label,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
// Wait for registration confirmation
|
|
142
|
+
await new Promise<void>((resolve, reject) => {
|
|
143
|
+
const timeout = setTimeout(() => reject(new Error("Registration timeout")), 5000);
|
|
144
|
+
ws.onmessage = (event) => {
|
|
145
|
+
try {
|
|
146
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
147
|
+
const msg = JSON.parse(data);
|
|
148
|
+
if (msg.type === "registered") {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
resolve();
|
|
151
|
+
} else if (msg.type === "error") {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
reject(new Error(msg.message));
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Create handshake handler for this machine
|
|
162
|
+
const handshakeHandler = new HandshakeHandler({
|
|
163
|
+
identity: machineIdentity,
|
|
164
|
+
accessList,
|
|
165
|
+
handshakeTimeoutMs: 30000,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
ws,
|
|
170
|
+
handshakeHandler,
|
|
171
|
+
connectionId: machineIdentity.id,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register an invite with the relay
|
|
177
|
+
*/
|
|
178
|
+
async function registerInvite(
|
|
179
|
+
machineWs: WebSocket,
|
|
180
|
+
machineIdentity: Identity,
|
|
181
|
+
inviteToken: string
|
|
182
|
+
): Promise<string> {
|
|
183
|
+
const inviteId = createHash("sha256")
|
|
184
|
+
.update(inviteToken)
|
|
185
|
+
.digest("hex")
|
|
186
|
+
.substring(0, 16);
|
|
187
|
+
|
|
188
|
+
machineWs.send(JSON.stringify({
|
|
189
|
+
type: "register_invite",
|
|
190
|
+
inviteId,
|
|
191
|
+
machineId: machineIdentity.id,
|
|
192
|
+
expiresAt: Date.now() + 3600000, // 1 hour
|
|
193
|
+
maxUses: null,
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
await new Promise<void>((resolve, reject) => {
|
|
197
|
+
const handler = (event: MessageEvent) => {
|
|
198
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
199
|
+
const msg = JSON.parse(data);
|
|
200
|
+
if (msg.type === "registered") {
|
|
201
|
+
machineWs.removeEventListener("message", handler);
|
|
202
|
+
resolve();
|
|
203
|
+
} else if (msg.type === "error") {
|
|
204
|
+
machineWs.removeEventListener("message", handler);
|
|
205
|
+
reject(new Error(msg.message));
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
machineWs.addEventListener("message", handler);
|
|
209
|
+
setTimeout(() => reject(new Error("Invite registration timeout")), 5000);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return inviteId;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// E2E Flow Tests
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
describe("E2E: Machine Registration", () => {
|
|
220
|
+
test("machine can register with relay", async () => {
|
|
221
|
+
const accessList = new AccessControlList();
|
|
222
|
+
|
|
223
|
+
const { ws } = await createMachineConnection(testFixtures.machine, accessList);
|
|
224
|
+
|
|
225
|
+
expect(ws.readyState).toBe(WebSocket.OPEN);
|
|
226
|
+
ws.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("machine can register an invite", async () => {
|
|
230
|
+
const accessList = new AccessControlList();
|
|
231
|
+
|
|
232
|
+
const { ws } = await createMachineConnection(testFixtures.machine, accessList);
|
|
233
|
+
|
|
234
|
+
// Create invite token (already returns serialized string)
|
|
235
|
+
const inviteToken = createInviteToken(testFixtures.machine, relayUrl, {
|
|
236
|
+
accessType: 'full',
|
|
237
|
+
validityMs: 3600000,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Register invite with relay
|
|
241
|
+
const inviteId = await registerInvite(ws, testFixtures.machine, inviteToken);
|
|
242
|
+
expect(inviteId).toHaveLength(16);
|
|
243
|
+
|
|
244
|
+
ws.close();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("E2E: Client Connection via Invite", () => {
|
|
249
|
+
test("client can connect to machine via invite", async () => {
|
|
250
|
+
// Set up access list with alice authorized
|
|
251
|
+
const accessList = new AccessControlList();
|
|
252
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'full');
|
|
253
|
+
|
|
254
|
+
// Create machine connection
|
|
255
|
+
const { ws: machineWs, handshakeHandler } = await createMachineConnection(
|
|
256
|
+
testFixtures.machine,
|
|
257
|
+
accessList
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// Create and register invite (createInviteToken returns serialized string)
|
|
261
|
+
const inviteToken = createInviteToken(testFixtures.machine, relayUrl, {
|
|
262
|
+
accessType: 'full',
|
|
263
|
+
validityMs: 3600000,
|
|
264
|
+
});
|
|
265
|
+
await registerInvite(machineWs, testFixtures.machine, inviteToken);
|
|
266
|
+
|
|
267
|
+
// Track handshake on machine side
|
|
268
|
+
let machineHandshakeComplete = false;
|
|
269
|
+
let machineReceivedClientId: string | null = null;
|
|
270
|
+
|
|
271
|
+
// Set up machine message handler
|
|
272
|
+
machineWs.onmessage = async (event) => {
|
|
273
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
274
|
+
const msg = JSON.parse(data);
|
|
275
|
+
|
|
276
|
+
if (msg.type === "client_connected") {
|
|
277
|
+
// New client connecting
|
|
278
|
+
const connectionId = msg.connectionId;
|
|
279
|
+
console.log("[test] Machine received client_connected:", connectionId);
|
|
280
|
+
} else if (msg.type === "data" && msg.connectionId) {
|
|
281
|
+
// Handshake data from client
|
|
282
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
283
|
+
const jsonStr = new TextDecoder().decode(msgData);
|
|
284
|
+
const envelope = JSON.parse(jsonStr);
|
|
285
|
+
|
|
286
|
+
if (envelope.type === "handshake") {
|
|
287
|
+
const result = await handshakeHandler.processMessage(msg.connectionId, envelope);
|
|
288
|
+
|
|
289
|
+
if (result.type === "reply" || result.type === "established") {
|
|
290
|
+
const response = JSON.stringify(result.message);
|
|
291
|
+
machineWs.send(JSON.stringify({
|
|
292
|
+
type: "data",
|
|
293
|
+
connectionId: msg.connectionId,
|
|
294
|
+
data: Buffer.from(response).toString("base64"),
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
if (result.type === "established") {
|
|
298
|
+
machineHandshakeComplete = true;
|
|
299
|
+
machineReceivedClientId = result.session.peerIdentityId;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Create client using RelayClient (no token needed)
|
|
307
|
+
let clientConnected = false;
|
|
308
|
+
let clientHandshakeComplete = false;
|
|
309
|
+
let clientPeerIdentityId: string | null = null;
|
|
310
|
+
let clientAccessType: AccessType | null = null;
|
|
311
|
+
|
|
312
|
+
const client = new RelayClient(
|
|
313
|
+
{
|
|
314
|
+
relayUrl: relayUrl,
|
|
315
|
+
machineId: testFixtures.machine.id,
|
|
316
|
+
identity: testFixtures.alice,
|
|
317
|
+
inviteToken: inviteToken,
|
|
318
|
+
// No token needed
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
onConnect: () => {
|
|
322
|
+
clientConnected = true;
|
|
323
|
+
},
|
|
324
|
+
onHandshakeComplete: (peerIdentityId, accessType) => {
|
|
325
|
+
clientHandshakeComplete = true;
|
|
326
|
+
clientPeerIdentityId = peerIdentityId;
|
|
327
|
+
clientAccessType = accessType;
|
|
328
|
+
},
|
|
329
|
+
onError: (error) => {
|
|
330
|
+
console.error("[test] Client error:", error);
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
await client.connect();
|
|
336
|
+
|
|
337
|
+
// Wait for handshakes to complete
|
|
338
|
+
await new Promise<void>((resolve, reject) => {
|
|
339
|
+
const checkInterval = setInterval(() => {
|
|
340
|
+
if (clientHandshakeComplete && machineHandshakeComplete) {
|
|
341
|
+
clearInterval(checkInterval);
|
|
342
|
+
resolve();
|
|
343
|
+
}
|
|
344
|
+
}, 100);
|
|
345
|
+
setTimeout(() => {
|
|
346
|
+
clearInterval(checkInterval);
|
|
347
|
+
reject(new Error("Handshake timeout"));
|
|
348
|
+
}, 10000);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Verify handshake completed on both sides
|
|
352
|
+
expect(clientConnected).toBe(true);
|
|
353
|
+
expect(clientHandshakeComplete).toBe(true);
|
|
354
|
+
expect(clientPeerIdentityId).not.toBeNull();
|
|
355
|
+
expect(clientPeerIdentityId!).toBe(testFixtures.machine.id);
|
|
356
|
+
expect(clientAccessType).not.toBeNull();
|
|
357
|
+
expect(clientAccessType!).toBe('full');
|
|
358
|
+
|
|
359
|
+
expect(machineHandshakeComplete).toBe(true);
|
|
360
|
+
expect(machineReceivedClientId).not.toBeNull();
|
|
361
|
+
expect(machineReceivedClientId!).toBe(testFixtures.alice.id);
|
|
362
|
+
|
|
363
|
+
client.disconnect();
|
|
364
|
+
machineWs.close();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("E2E: Direct Connection (Pre-authorized)", () => {
|
|
369
|
+
test("pre-authorized client can connect directly", async () => {
|
|
370
|
+
// Set up access list with alice authorized (session-invite for testing)
|
|
371
|
+
const accessList = new AccessControlList();
|
|
372
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'session-invite', 'test-session');
|
|
373
|
+
|
|
374
|
+
// Create machine connection
|
|
375
|
+
const { ws: machineWs, handshakeHandler } = await createMachineConnection(
|
|
376
|
+
testFixtures.machine,
|
|
377
|
+
accessList
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Authorize client with relay (using new accessType format)
|
|
381
|
+
machineWs.send(JSON.stringify({
|
|
382
|
+
type: "authorize_client",
|
|
383
|
+
machineId: testFixtures.machine.id,
|
|
384
|
+
clientIdentityId: testFixtures.alice.id,
|
|
385
|
+
signingKey: toPublicIdentity(testFixtures.alice).signingPublicKey,
|
|
386
|
+
keyExchangeKey: toPublicIdentity(testFixtures.alice).keyExchangePublicKey,
|
|
387
|
+
accessType: "full",
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
await new Promise<void>((resolve) => {
|
|
391
|
+
const handler = (event: MessageEvent) => {
|
|
392
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
393
|
+
const msg = JSON.parse(data);
|
|
394
|
+
if (msg.type === "client_authorized") {
|
|
395
|
+
machineWs.removeEventListener("message", handler);
|
|
396
|
+
resolve();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
machineWs.addEventListener("message", handler);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Track handshake on machine side
|
|
403
|
+
let machineHandshakeComplete = false;
|
|
404
|
+
|
|
405
|
+
machineWs.onmessage = async (event) => {
|
|
406
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
407
|
+
const msg = JSON.parse(data);
|
|
408
|
+
|
|
409
|
+
if (msg.type === "data" && msg.connectionId) {
|
|
410
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
411
|
+
const jsonStr = new TextDecoder().decode(msgData);
|
|
412
|
+
const envelope = JSON.parse(jsonStr);
|
|
413
|
+
|
|
414
|
+
if (envelope.type === "handshake") {
|
|
415
|
+
const result = await handshakeHandler.processMessage(msg.connectionId, envelope);
|
|
416
|
+
|
|
417
|
+
if (result.type === "reply" || result.type === "established") {
|
|
418
|
+
const response = JSON.stringify(result.message);
|
|
419
|
+
machineWs.send(JSON.stringify({
|
|
420
|
+
type: "data",
|
|
421
|
+
connectionId: msg.connectionId,
|
|
422
|
+
data: Buffer.from(response).toString("base64"),
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
if (result.type === "established") {
|
|
426
|
+
machineHandshakeComplete = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Client connects directly (no invite token, no token)
|
|
434
|
+
let clientHandshakeComplete = false;
|
|
435
|
+
let clientAccessType: AccessType | null = null;
|
|
436
|
+
|
|
437
|
+
const client = new RelayClient(
|
|
438
|
+
{
|
|
439
|
+
relayUrl: relayUrl,
|
|
440
|
+
machineId: testFixtures.machine.id,
|
|
441
|
+
identity: testFixtures.alice,
|
|
442
|
+
// No inviteToken - direct connection
|
|
443
|
+
// No token needed
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
onHandshakeComplete: (peerIdentityId, accessType) => {
|
|
447
|
+
clientHandshakeComplete = true;
|
|
448
|
+
clientAccessType = accessType;
|
|
449
|
+
},
|
|
450
|
+
onError: (error) => {
|
|
451
|
+
console.error("[test] Client error:", error);
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
await client.connect();
|
|
457
|
+
|
|
458
|
+
// Wait for handshakes to complete
|
|
459
|
+
await new Promise<void>((resolve, reject) => {
|
|
460
|
+
const checkInterval = setInterval(() => {
|
|
461
|
+
if (clientHandshakeComplete && machineHandshakeComplete) {
|
|
462
|
+
clearInterval(checkInterval);
|
|
463
|
+
resolve();
|
|
464
|
+
}
|
|
465
|
+
}, 100);
|
|
466
|
+
setTimeout(() => {
|
|
467
|
+
clearInterval(checkInterval);
|
|
468
|
+
reject(new Error("Handshake timeout"));
|
|
469
|
+
}, 10000);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(clientHandshakeComplete).toBe(true);
|
|
473
|
+
expect(clientAccessType).not.toBeNull();
|
|
474
|
+
expect(clientAccessType!).toBe('session-invite');
|
|
475
|
+
expect(machineHandshakeComplete).toBe(true);
|
|
476
|
+
|
|
477
|
+
client.disconnect();
|
|
478
|
+
machineWs.close();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("E2E: Encrypted Data Exchange", () => {
|
|
483
|
+
test("client and machine can exchange encrypted data", async () => {
|
|
484
|
+
// Set up access list
|
|
485
|
+
const accessList = new AccessControlList();
|
|
486
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'full');
|
|
487
|
+
|
|
488
|
+
// Create machine connection
|
|
489
|
+
const { ws: machineWs, handshakeHandler } = await createMachineConnection(
|
|
490
|
+
testFixtures.machine,
|
|
491
|
+
accessList
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// Create and register invite (createInviteToken returns serialized string)
|
|
495
|
+
const inviteToken = createInviteToken(testFixtures.machine, relayUrl, {
|
|
496
|
+
accessType: 'full',
|
|
497
|
+
validityMs: 3600000,
|
|
498
|
+
});
|
|
499
|
+
await registerInvite(machineWs, testFixtures.machine, inviteToken);
|
|
500
|
+
|
|
501
|
+
// Track received messages
|
|
502
|
+
const machineReceivedMessages: Buffer[] = [];
|
|
503
|
+
const clientReceivedMessages: Buffer[] = [];
|
|
504
|
+
let machineSessionKeys: { sendKey: Uint8Array; receiveKey: Uint8Array } | null = null;
|
|
505
|
+
let clientConnectionId: string | null = null;
|
|
506
|
+
|
|
507
|
+
// Set up machine message handler
|
|
508
|
+
machineWs.onmessage = async (event) => {
|
|
509
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
510
|
+
const msg = JSON.parse(data);
|
|
511
|
+
|
|
512
|
+
if (msg.type === "client_connected") {
|
|
513
|
+
clientConnectionId = msg.connectionId;
|
|
514
|
+
} else if (msg.type === "data" && msg.connectionId) {
|
|
515
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
516
|
+
|
|
517
|
+
// Try to parse as handshake
|
|
518
|
+
try {
|
|
519
|
+
const jsonStr = new TextDecoder().decode(msgData);
|
|
520
|
+
const envelope = JSON.parse(jsonStr);
|
|
521
|
+
|
|
522
|
+
if (envelope.type === "handshake") {
|
|
523
|
+
const result = await handshakeHandler.processMessage(msg.connectionId, envelope);
|
|
524
|
+
|
|
525
|
+
if (result.type === "reply" || result.type === "established") {
|
|
526
|
+
const response = JSON.stringify(result.message);
|
|
527
|
+
machineWs.send(JSON.stringify({
|
|
528
|
+
type: "data",
|
|
529
|
+
connectionId: msg.connectionId,
|
|
530
|
+
data: Buffer.from(response).toString("base64"),
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
if (result.type === "established") {
|
|
534
|
+
machineSessionKeys = {
|
|
535
|
+
sendKey: result.session.sessionKeys.sendKey,
|
|
536
|
+
receiveKey: result.session.sessionKeys.receiveKey,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
// Not JSON, treat as encrypted data
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Store encrypted data received by machine
|
|
547
|
+
machineReceivedMessages.push(msgData);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Create client (no token needed)
|
|
552
|
+
let clientConnected = false;
|
|
553
|
+
|
|
554
|
+
const client = new RelayClient(
|
|
555
|
+
{
|
|
556
|
+
relayUrl: relayUrl,
|
|
557
|
+
machineId: testFixtures.machine.id,
|
|
558
|
+
identity: testFixtures.alice,
|
|
559
|
+
inviteToken: inviteToken,
|
|
560
|
+
// No token needed
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
onConnect: () => {
|
|
564
|
+
clientConnected = true;
|
|
565
|
+
},
|
|
566
|
+
onMessage: (streamId, data) => {
|
|
567
|
+
clientReceivedMessages.push(data);
|
|
568
|
+
},
|
|
569
|
+
onError: (error) => {
|
|
570
|
+
console.error("[test] Client error:", error);
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
await client.connect();
|
|
576
|
+
|
|
577
|
+
// Wait for connection
|
|
578
|
+
await new Promise<void>((resolve, reject) => {
|
|
579
|
+
const checkInterval = setInterval(() => {
|
|
580
|
+
if (clientConnected && machineSessionKeys) {
|
|
581
|
+
clearInterval(checkInterval);
|
|
582
|
+
resolve();
|
|
583
|
+
}
|
|
584
|
+
}, 100);
|
|
585
|
+
setTimeout(() => {
|
|
586
|
+
clearInterval(checkInterval);
|
|
587
|
+
reject(new Error("Connection timeout"));
|
|
588
|
+
}, 10000);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Test sending data from client to machine
|
|
592
|
+
const testMessage = Buffer.from("Hello from client!");
|
|
593
|
+
const sent = client.send(testMessage);
|
|
594
|
+
expect(sent).toBe(true);
|
|
595
|
+
|
|
596
|
+
// Wait for message to arrive
|
|
597
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
598
|
+
|
|
599
|
+
// Machine should have received encrypted data
|
|
600
|
+
expect(machineReceivedMessages.length).toBeGreaterThan(0);
|
|
601
|
+
|
|
602
|
+
// Clean up
|
|
603
|
+
client.disconnect();
|
|
604
|
+
machineWs.close();
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
describe("E2E: Error Scenarios", () => {
|
|
609
|
+
test("unauthorized client is rejected", async () => {
|
|
610
|
+
// Set up access list WITHOUT untrusted client
|
|
611
|
+
const accessList = new AccessControlList();
|
|
612
|
+
// Only alice is authorized, not untrusted
|
|
613
|
+
|
|
614
|
+
// Create machine connection
|
|
615
|
+
const { ws: machineWs, handshakeHandler } = await createMachineConnection(
|
|
616
|
+
testFixtures.machine,
|
|
617
|
+
accessList
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Authorize alice with relay (but client will be untrusted)
|
|
621
|
+
machineWs.send(JSON.stringify({
|
|
622
|
+
type: "authorize_client",
|
|
623
|
+
machineId: testFixtures.machine.id,
|
|
624
|
+
clientIdentityId: testFixtures.alice.id,
|
|
625
|
+
signingKey: toPublicIdentity(testFixtures.alice).signingPublicKey,
|
|
626
|
+
keyExchangeKey: toPublicIdentity(testFixtures.alice).keyExchangePublicKey,
|
|
627
|
+
accessType: "full",
|
|
628
|
+
}));
|
|
629
|
+
|
|
630
|
+
await new Promise<void>((resolve) => {
|
|
631
|
+
const handler = (event: MessageEvent) => {
|
|
632
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
633
|
+
const msg = JSON.parse(data);
|
|
634
|
+
if (msg.type === "client_authorized") {
|
|
635
|
+
machineWs.removeEventListener("message", handler);
|
|
636
|
+
resolve();
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
machineWs.addEventListener("message", handler);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Handle handshake messages
|
|
643
|
+
machineWs.onmessage = async (event) => {
|
|
644
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
645
|
+
const msg = JSON.parse(data);
|
|
646
|
+
|
|
647
|
+
if (msg.type === "data" && msg.connectionId) {
|
|
648
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
649
|
+
try {
|
|
650
|
+
const jsonStr = new TextDecoder().decode(msgData);
|
|
651
|
+
const envelope = JSON.parse(jsonStr);
|
|
652
|
+
|
|
653
|
+
if (envelope.type === "handshake") {
|
|
654
|
+
const result = await handshakeHandler.processMessage(msg.connectionId, envelope);
|
|
655
|
+
|
|
656
|
+
if (result.type === "reply" || result.type === "established") {
|
|
657
|
+
const response = JSON.stringify(result.message);
|
|
658
|
+
machineWs.send(JSON.stringify({
|
|
659
|
+
type: "data",
|
|
660
|
+
connectionId: msg.connectionId,
|
|
661
|
+
data: Buffer.from(response).toString("base64"),
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
// Ignore
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Try to connect with untrusted identity (not in access list)
|
|
672
|
+
let errorReceived = false;
|
|
673
|
+
let errorMessage = "";
|
|
674
|
+
|
|
675
|
+
const client = new RelayClient(
|
|
676
|
+
{
|
|
677
|
+
relayUrl: relayUrl,
|
|
678
|
+
machineId: testFixtures.machine.id,
|
|
679
|
+
identity: testFixtures.untrusted, // Not authorized!
|
|
680
|
+
// No token needed
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
onError: (error) => {
|
|
684
|
+
errorReceived = true;
|
|
685
|
+
errorMessage = error.message;
|
|
686
|
+
},
|
|
687
|
+
}
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
await client.connect();
|
|
691
|
+
|
|
692
|
+
// Wait for error
|
|
693
|
+
await new Promise<void>((resolve) => {
|
|
694
|
+
const checkInterval = setInterval(() => {
|
|
695
|
+
if (errorReceived) {
|
|
696
|
+
clearInterval(checkInterval);
|
|
697
|
+
resolve();
|
|
698
|
+
}
|
|
699
|
+
}, 100);
|
|
700
|
+
setTimeout(() => {
|
|
701
|
+
clearInterval(checkInterval);
|
|
702
|
+
resolve(); // Timeout is okay, error might have been received
|
|
703
|
+
}, 5000);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
expect(errorReceived).toBe(true);
|
|
707
|
+
expect(errorMessage.toLowerCase()).toMatch(/denied|rejected|not authorized|forbidden|access/i);
|
|
708
|
+
|
|
709
|
+
client.disconnect();
|
|
710
|
+
machineWs.close();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("client handles machine disconnect gracefully", async () => {
|
|
714
|
+
const accessList = new AccessControlList();
|
|
715
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'full');
|
|
716
|
+
|
|
717
|
+
const { ws: machineWs, handshakeHandler } = await createMachineConnection(
|
|
718
|
+
testFixtures.machine,
|
|
719
|
+
accessList
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// Create and register invite (createInviteToken returns serialized string)
|
|
723
|
+
const inviteToken = createInviteToken(testFixtures.machine, relayUrl, {
|
|
724
|
+
accessType: 'full',
|
|
725
|
+
validityMs: 3600000,
|
|
726
|
+
});
|
|
727
|
+
await registerInvite(machineWs, testFixtures.machine, inviteToken);
|
|
728
|
+
|
|
729
|
+
// Set up machine to handle handshake
|
|
730
|
+
let handshakeComplete = false;
|
|
731
|
+
machineWs.onmessage = async (event) => {
|
|
732
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
733
|
+
const msg = JSON.parse(data);
|
|
734
|
+
if (msg.type === "data" && msg.connectionId) {
|
|
735
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
736
|
+
try {
|
|
737
|
+
const jsonStr = new TextDecoder().decode(msgData);
|
|
738
|
+
const envelope = JSON.parse(jsonStr);
|
|
739
|
+
|
|
740
|
+
if (envelope.type === "handshake") {
|
|
741
|
+
const result = await handshakeHandler.processMessage(msg.connectionId, envelope);
|
|
742
|
+
if (result.type === "reply" || result.type === "established") {
|
|
743
|
+
const response = JSON.stringify(result.message);
|
|
744
|
+
machineWs.send(JSON.stringify({
|
|
745
|
+
type: "data",
|
|
746
|
+
connectionId: msg.connectionId,
|
|
747
|
+
data: Buffer.from(response).toString("base64"),
|
|
748
|
+
}));
|
|
749
|
+
if (result.type === "established") {
|
|
750
|
+
handshakeComplete = true;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
} catch {
|
|
755
|
+
// Ignore
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// Connect client (no token needed)
|
|
761
|
+
let clientDisconnected = false;
|
|
762
|
+
let disconnectCode = 0;
|
|
763
|
+
let disconnectReason = "";
|
|
764
|
+
|
|
765
|
+
const client = new RelayClient(
|
|
766
|
+
{
|
|
767
|
+
relayUrl: relayUrl,
|
|
768
|
+
machineId: testFixtures.machine.id,
|
|
769
|
+
identity: testFixtures.alice,
|
|
770
|
+
inviteToken: inviteToken,
|
|
771
|
+
// No token needed
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
onDisconnect: (code, reason) => {
|
|
775
|
+
clientDisconnected = true;
|
|
776
|
+
disconnectCode = code;
|
|
777
|
+
disconnectReason = reason;
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
await client.connect();
|
|
783
|
+
|
|
784
|
+
// Wait for handshake
|
|
785
|
+
await new Promise<void>((resolve, reject) => {
|
|
786
|
+
const checkInterval = setInterval(() => {
|
|
787
|
+
if (handshakeComplete) {
|
|
788
|
+
clearInterval(checkInterval);
|
|
789
|
+
resolve();
|
|
790
|
+
}
|
|
791
|
+
}, 100);
|
|
792
|
+
setTimeout(() => {
|
|
793
|
+
clearInterval(checkInterval);
|
|
794
|
+
reject(new Error("Handshake timeout"));
|
|
795
|
+
}, 10000);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// Close machine connection
|
|
799
|
+
machineWs.close();
|
|
800
|
+
|
|
801
|
+
// Wait for client to receive disconnect
|
|
802
|
+
await new Promise<void>((resolve) => {
|
|
803
|
+
const checkInterval = setInterval(() => {
|
|
804
|
+
if (clientDisconnected) {
|
|
805
|
+
clearInterval(checkInterval);
|
|
806
|
+
resolve();
|
|
807
|
+
}
|
|
808
|
+
}, 100);
|
|
809
|
+
setTimeout(() => {
|
|
810
|
+
clearInterval(checkInterval);
|
|
811
|
+
resolve();
|
|
812
|
+
}, 3000);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(clientDisconnected).toBe(true);
|
|
816
|
+
|
|
817
|
+
client.disconnect();
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// ============================================================================
|
|
822
|
+
// PTY Session E2E Tests - Proves actual terminal sessions work through relay
|
|
823
|
+
// ============================================================================
|
|
824
|
+
|
|
825
|
+
import { ClientSessionManager } from "../../serve/client-session-manager";
|
|
826
|
+
import { createFrame, openFrame } from "../../lib/tmux-lite/crypto/frames";
|
|
827
|
+
import { STREAM_ID } from "../../serve/types";
|
|
828
|
+
|
|
829
|
+
describe("E2E: PTY Session Flow", () => {
|
|
830
|
+
test("client can establish session and exchange encrypted terminal data", async () => {
|
|
831
|
+
// Set up access list with alice authorized
|
|
832
|
+
const accessList = new AccessControlList();
|
|
833
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'full');
|
|
834
|
+
|
|
835
|
+
// Create ClientSessionManager (machine side)
|
|
836
|
+
const sessionManager = new ClientSessionManager({
|
|
837
|
+
relay: relayUrl,
|
|
838
|
+
identity: testFixtures.machine,
|
|
839
|
+
accessList,
|
|
840
|
+
shell: "/bin/sh", // Use sh for portability
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Track session events
|
|
844
|
+
const events: any[] = [];
|
|
845
|
+
sessionManager.onEvent((event) => {
|
|
846
|
+
events.push(event);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Create machine connection with challenge-response auth
|
|
850
|
+
const machineUrl = new URL(relayUrl);
|
|
851
|
+
machineUrl.searchParams.set("role", "machine");
|
|
852
|
+
// No token needed
|
|
853
|
+
|
|
854
|
+
const machineWs = new WebSocket(machineUrl.toString());
|
|
855
|
+
machineWs.binaryType = "arraybuffer";
|
|
856
|
+
|
|
857
|
+
await new Promise<void>((resolve, reject) => {
|
|
858
|
+
machineWs.onopen = () => resolve();
|
|
859
|
+
machineWs.onerror = () => reject(new Error("Machine connection failed"));
|
|
860
|
+
setTimeout(() => reject(new Error("Timeout")), 5000);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Wait for challenge
|
|
864
|
+
const challenge = await new Promise<Uint8Array>((resolve, reject) => {
|
|
865
|
+
const timeout = setTimeout(() => reject(new Error("Challenge timeout")), 5000);
|
|
866
|
+
machineWs.onmessage = (event) => {
|
|
867
|
+
try {
|
|
868
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
869
|
+
const msg = JSON.parse(data);
|
|
870
|
+
if (msg.type === "relay_identity" && msg.challenge) {
|
|
871
|
+
clearTimeout(timeout);
|
|
872
|
+
resolve(Buffer.from(msg.challenge, "base64"));
|
|
873
|
+
}
|
|
874
|
+
} catch {
|
|
875
|
+
// Ignore
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Register machine with challenge response
|
|
881
|
+
const signature = signChallenge(challenge, testFixtures.machine.signing.secretKey);
|
|
882
|
+
const publicIdentity = toPublicIdentity(testFixtures.machine);
|
|
883
|
+
machineWs.send(JSON.stringify({
|
|
884
|
+
type: "register_machine",
|
|
885
|
+
machineId: testFixtures.machine.id,
|
|
886
|
+
signingKey: publicIdentity.signingPublicKey,
|
|
887
|
+
keyExchangeKey: publicIdentity.keyExchangePublicKey,
|
|
888
|
+
challengeResponse: signature,
|
|
889
|
+
label: "Test Machine",
|
|
890
|
+
}));
|
|
891
|
+
|
|
892
|
+
await new Promise<void>((resolve, reject) => {
|
|
893
|
+
const timeout = setTimeout(() => reject(new Error("Registration timeout")), 5000);
|
|
894
|
+
machineWs.onmessage = (event) => {
|
|
895
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
896
|
+
const msg = JSON.parse(data);
|
|
897
|
+
if (msg.type === "registered") {
|
|
898
|
+
clearTimeout(timeout);
|
|
899
|
+
resolve();
|
|
900
|
+
} else if (msg.type === "error") {
|
|
901
|
+
clearTimeout(timeout);
|
|
902
|
+
reject(new Error(msg.message));
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// Create and register invite
|
|
908
|
+
const inviteToken = createInviteToken(testFixtures.machine, relayUrl, {
|
|
909
|
+
accessType: 'full',
|
|
910
|
+
validityMs: 3600000,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const inviteId = createHash("sha256")
|
|
914
|
+
.update(inviteToken)
|
|
915
|
+
.digest("hex")
|
|
916
|
+
.substring(0, 16);
|
|
917
|
+
|
|
918
|
+
machineWs.send(JSON.stringify({
|
|
919
|
+
type: "register_invite",
|
|
920
|
+
inviteId,
|
|
921
|
+
machineId: testFixtures.machine.id,
|
|
922
|
+
expiresAt: Date.now() + 3600000,
|
|
923
|
+
maxUses: null,
|
|
924
|
+
}));
|
|
925
|
+
|
|
926
|
+
await new Promise<void>((resolve, reject) => {
|
|
927
|
+
const handler = (event: MessageEvent) => {
|
|
928
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
929
|
+
const msg = JSON.parse(data);
|
|
930
|
+
if (msg.type === "registered") {
|
|
931
|
+
machineWs.removeEventListener("message", handler);
|
|
932
|
+
resolve();
|
|
933
|
+
} else if (msg.type === "error") {
|
|
934
|
+
machineWs.removeEventListener("message", handler);
|
|
935
|
+
reject(new Error(msg.message));
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
machineWs.addEventListener("message", handler);
|
|
939
|
+
setTimeout(() => reject(new Error("Invite registration timeout")), 5000);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// Track data received from machine
|
|
943
|
+
const machineReceivedData: Buffer[] = [];
|
|
944
|
+
let clientConnectionId: string | null = null;
|
|
945
|
+
|
|
946
|
+
// Machine message handler - routes to ClientSessionManager
|
|
947
|
+
machineWs.onmessage = async (event) => {
|
|
948
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
949
|
+
const msg = JSON.parse(data);
|
|
950
|
+
|
|
951
|
+
if (msg.type === "client_connected") {
|
|
952
|
+
clientConnectionId = msg.connectionId;
|
|
953
|
+
sessionManager.handleConnect(msg.connectionId);
|
|
954
|
+
|
|
955
|
+
// Set up send callback for session manager
|
|
956
|
+
sessionManager.setSendCallback(msg.connectionId, (sendData: Buffer) => {
|
|
957
|
+
// Send encrypted data back through relay
|
|
958
|
+
machineWs.send(JSON.stringify({
|
|
959
|
+
type: "data",
|
|
960
|
+
connectionId: msg.connectionId,
|
|
961
|
+
data: sendData.toString("base64"),
|
|
962
|
+
}));
|
|
963
|
+
});
|
|
964
|
+
} else if (msg.type === "data" && msg.connectionId) {
|
|
965
|
+
// Decode and route to session manager
|
|
966
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
967
|
+
machineReceivedData.push(msgData);
|
|
968
|
+
|
|
969
|
+
const response = await sessionManager.handleMessage(msg.connectionId, msgData);
|
|
970
|
+
if (response) {
|
|
971
|
+
// Send response back to client
|
|
972
|
+
machineWs.send(JSON.stringify({
|
|
973
|
+
type: "data",
|
|
974
|
+
connectionId: msg.connectionId,
|
|
975
|
+
data: Buffer.from(response).toString("base64"),
|
|
976
|
+
}));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// Track client received data
|
|
982
|
+
const clientReceivedData: Buffer[] = [];
|
|
983
|
+
let clientHandshakeComplete = false;
|
|
984
|
+
let clientSessionKeys: { sendKey: Uint8Array; receiveKey: Uint8Array } | null = null;
|
|
985
|
+
|
|
986
|
+
// Create client connection (no token needed)
|
|
987
|
+
const client = new RelayClient(
|
|
988
|
+
{
|
|
989
|
+
relayUrl: relayUrl,
|
|
990
|
+
machineId: testFixtures.machine.id,
|
|
991
|
+
identity: testFixtures.alice,
|
|
992
|
+
inviteToken: inviteToken,
|
|
993
|
+
// No token needed
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
onHandshakeComplete: (peerIdentityId, accessType) => {
|
|
997
|
+
clientHandshakeComplete = true;
|
|
998
|
+
// Get session keys from client for verification using test utility
|
|
999
|
+
const testAccess = getRelayClientTestAccess(client);
|
|
1000
|
+
clientSessionKeys = {
|
|
1001
|
+
sendKey: testAccess.writeKey!,
|
|
1002
|
+
receiveKey: testAccess.readKey!,
|
|
1003
|
+
};
|
|
1004
|
+
},
|
|
1005
|
+
onMessage: (streamId, data) => {
|
|
1006
|
+
clientReceivedData.push(data);
|
|
1007
|
+
},
|
|
1008
|
+
onError: (error) => {
|
|
1009
|
+
console.error("[test] Client error:", error);
|
|
1010
|
+
},
|
|
1011
|
+
}
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
await client.connect();
|
|
1015
|
+
|
|
1016
|
+
// Wait for handshake to complete
|
|
1017
|
+
await new Promise<void>((resolve, reject) => {
|
|
1018
|
+
const checkInterval = setInterval(() => {
|
|
1019
|
+
if (clientHandshakeComplete) {
|
|
1020
|
+
clearInterval(checkInterval);
|
|
1021
|
+
resolve();
|
|
1022
|
+
}
|
|
1023
|
+
}, 100);
|
|
1024
|
+
setTimeout(() => {
|
|
1025
|
+
clearInterval(checkInterval);
|
|
1026
|
+
reject(new Error("Handshake timeout"));
|
|
1027
|
+
}, 10000);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// Verify session was established
|
|
1031
|
+
expect(clientHandshakeComplete).toBe(true);
|
|
1032
|
+
expect(events.some(e => e.type === "client_connected")).toBe(true);
|
|
1033
|
+
expect(events.some(e => e.type === "client_authenticated")).toBe(true);
|
|
1034
|
+
|
|
1035
|
+
// Verify session manager has the session
|
|
1036
|
+
expect(sessionManager.activeSessionCount).toBe(1);
|
|
1037
|
+
expect(sessionManager.establishedSessionCount).toBe(1);
|
|
1038
|
+
|
|
1039
|
+
// Get the session
|
|
1040
|
+
const session = sessionManager.getSession(clientConnectionId!);
|
|
1041
|
+
expect(session).toBeDefined();
|
|
1042
|
+
expect(session?.state).toBe("browsing");
|
|
1043
|
+
expect(session?.peerIdentityId).toBe(testFixtures.alice.id);
|
|
1044
|
+
|
|
1045
|
+
// Send terminal input from client (e.g., "echo hello\n")
|
|
1046
|
+
// This creates an encrypted frame and sends it
|
|
1047
|
+
const testInput = Buffer.from("echo hello\n");
|
|
1048
|
+
const sent = client.send(testInput);
|
|
1049
|
+
expect(sent).toBe(true);
|
|
1050
|
+
|
|
1051
|
+
// Wait for PTY to process and send output
|
|
1052
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1053
|
+
|
|
1054
|
+
// The machine should have received encrypted data
|
|
1055
|
+
expect(machineReceivedData.length).toBeGreaterThan(0);
|
|
1056
|
+
|
|
1057
|
+
// Clean up
|
|
1058
|
+
client.disconnect();
|
|
1059
|
+
sessionManager.cleanup();
|
|
1060
|
+
machineWs.close();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test("ClientSessionManager handles multiple concurrent clients", async () => {
|
|
1064
|
+
// Create a second client identity
|
|
1065
|
+
const bob = createTestIdentity("Bob");
|
|
1066
|
+
|
|
1067
|
+
// Set up access list with both alice and bob authorized
|
|
1068
|
+
const accessList = new AccessControlList();
|
|
1069
|
+
accessList.addEntry(toPublicIdentity(testFixtures.alice), 'full');
|
|
1070
|
+
accessList.addEntry(toPublicIdentity(bob), 'session-invite', 'test-session');
|
|
1071
|
+
|
|
1072
|
+
// Create ClientSessionManager
|
|
1073
|
+
const sessionManager = new ClientSessionManager({
|
|
1074
|
+
relay: relayUrl,
|
|
1075
|
+
identity: testFixtures.machine,
|
|
1076
|
+
accessList,
|
|
1077
|
+
shell: "/bin/sh",
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
const authenticatedClients: string[] = [];
|
|
1081
|
+
sessionManager.onEvent((event) => {
|
|
1082
|
+
if (event.type === "client_authenticated") {
|
|
1083
|
+
authenticatedClients.push(event.identityId);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
// Create machine connection with challenge-response
|
|
1088
|
+
const machineUrl = new URL(relayUrl);
|
|
1089
|
+
machineUrl.searchParams.set("role", "machine");
|
|
1090
|
+
|
|
1091
|
+
const machineWs = new WebSocket(machineUrl.toString());
|
|
1092
|
+
machineWs.binaryType = "arraybuffer";
|
|
1093
|
+
|
|
1094
|
+
await new Promise<void>((resolve, reject) => {
|
|
1095
|
+
machineWs.onopen = () => resolve();
|
|
1096
|
+
machineWs.onerror = () => reject(new Error("Machine connection failed"));
|
|
1097
|
+
setTimeout(() => reject(new Error("Timeout")), 5000);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// Challenge-response
|
|
1101
|
+
const challenge = await new Promise<Uint8Array>((resolve, reject) => {
|
|
1102
|
+
const timeout = setTimeout(() => reject(new Error("Challenge timeout")), 5000);
|
|
1103
|
+
machineWs.onmessage = (event) => {
|
|
1104
|
+
try {
|
|
1105
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
1106
|
+
const msg = JSON.parse(data);
|
|
1107
|
+
if (msg.type === "relay_identity" && msg.challenge) {
|
|
1108
|
+
clearTimeout(timeout);
|
|
1109
|
+
resolve(Buffer.from(msg.challenge, "base64"));
|
|
1110
|
+
}
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Ignore
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const signature = signChallenge(challenge, testFixtures.machine.signing.secretKey);
|
|
1118
|
+
const publicIdentity = toPublicIdentity(testFixtures.machine);
|
|
1119
|
+
machineWs.send(JSON.stringify({
|
|
1120
|
+
type: "register_machine",
|
|
1121
|
+
machineId: testFixtures.machine.id,
|
|
1122
|
+
signingKey: publicIdentity.signingPublicKey,
|
|
1123
|
+
keyExchangeKey: publicIdentity.keyExchangePublicKey,
|
|
1124
|
+
challengeResponse: signature,
|
|
1125
|
+
}));
|
|
1126
|
+
|
|
1127
|
+
await new Promise<void>((resolve, reject) => {
|
|
1128
|
+
const timeout = setTimeout(() => reject(new Error("Timeout")), 5000);
|
|
1129
|
+
machineWs.onmessage = (event) => {
|
|
1130
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
1131
|
+
const msg = JSON.parse(data);
|
|
1132
|
+
if (msg.type === "registered") {
|
|
1133
|
+
clearTimeout(timeout);
|
|
1134
|
+
resolve();
|
|
1135
|
+
} else if (msg.type === "error") {
|
|
1136
|
+
clearTimeout(timeout);
|
|
1137
|
+
reject(new Error(msg.message));
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Authorize both clients with relay (using accessType format)
|
|
1143
|
+
machineWs.send(JSON.stringify({
|
|
1144
|
+
type: "authorize_client",
|
|
1145
|
+
machineId: testFixtures.machine.id,
|
|
1146
|
+
clientIdentityId: testFixtures.alice.id,
|
|
1147
|
+
signingKey: toPublicIdentity(testFixtures.alice).signingPublicKey,
|
|
1148
|
+
keyExchangeKey: toPublicIdentity(testFixtures.alice).keyExchangePublicKey,
|
|
1149
|
+
accessType: "full",
|
|
1150
|
+
}));
|
|
1151
|
+
|
|
1152
|
+
await new Promise<void>((resolve) => {
|
|
1153
|
+
const handler = (event: MessageEvent) => {
|
|
1154
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
1155
|
+
const msg = JSON.parse(data);
|
|
1156
|
+
if (msg.type === "client_authorized") {
|
|
1157
|
+
machineWs.removeEventListener("message", handler);
|
|
1158
|
+
resolve();
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
machineWs.addEventListener("message", handler);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
machineWs.send(JSON.stringify({
|
|
1165
|
+
type: "authorize_client",
|
|
1166
|
+
machineId: testFixtures.machine.id,
|
|
1167
|
+
clientIdentityId: bob.id,
|
|
1168
|
+
signingKey: toPublicIdentity(bob).signingPublicKey,
|
|
1169
|
+
keyExchangeKey: toPublicIdentity(bob).keyExchangePublicKey,
|
|
1170
|
+
accessType: "full",
|
|
1171
|
+
}));
|
|
1172
|
+
|
|
1173
|
+
await new Promise<void>((resolve) => {
|
|
1174
|
+
const handler = (event: MessageEvent) => {
|
|
1175
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
1176
|
+
const msg = JSON.parse(data);
|
|
1177
|
+
if (msg.type === "client_authorized") {
|
|
1178
|
+
machineWs.removeEventListener("message", handler);
|
|
1179
|
+
resolve();
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
machineWs.addEventListener("message", handler);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
// Machine message handler
|
|
1186
|
+
machineWs.onmessage = async (event) => {
|
|
1187
|
+
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
1188
|
+
const msg = JSON.parse(data);
|
|
1189
|
+
|
|
1190
|
+
if (msg.type === "client_connected") {
|
|
1191
|
+
sessionManager.handleConnect(msg.connectionId);
|
|
1192
|
+
sessionManager.setSendCallback(msg.connectionId, (sendData: Buffer) => {
|
|
1193
|
+
machineWs.send(JSON.stringify({
|
|
1194
|
+
type: "data",
|
|
1195
|
+
connectionId: msg.connectionId,
|
|
1196
|
+
data: sendData.toString("base64"),
|
|
1197
|
+
}));
|
|
1198
|
+
});
|
|
1199
|
+
} else if (msg.type === "data" && msg.connectionId) {
|
|
1200
|
+
const msgData = Buffer.from(msg.data, "base64");
|
|
1201
|
+
const response = await sessionManager.handleMessage(msg.connectionId, msgData);
|
|
1202
|
+
if (response) {
|
|
1203
|
+
machineWs.send(JSON.stringify({
|
|
1204
|
+
type: "data",
|
|
1205
|
+
connectionId: msg.connectionId,
|
|
1206
|
+
data: Buffer.from(response).toString("base64"),
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// Connect both clients concurrently (no tokens needed)
|
|
1213
|
+
let aliceHandshakeComplete = false;
|
|
1214
|
+
let bobHandshakeComplete = false;
|
|
1215
|
+
|
|
1216
|
+
const clientAlice = new RelayClient(
|
|
1217
|
+
{
|
|
1218
|
+
relayUrl: relayUrl,
|
|
1219
|
+
machineId: testFixtures.machine.id,
|
|
1220
|
+
identity: testFixtures.alice,
|
|
1221
|
+
// No token needed
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
onHandshakeComplete: () => {
|
|
1225
|
+
aliceHandshakeComplete = true;
|
|
1226
|
+
},
|
|
1227
|
+
onError: (error) => {
|
|
1228
|
+
console.error("[test] Alice error:", error);
|
|
1229
|
+
},
|
|
1230
|
+
}
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
const clientBob = new RelayClient(
|
|
1234
|
+
{
|
|
1235
|
+
relayUrl: relayUrl,
|
|
1236
|
+
machineId: testFixtures.machine.id,
|
|
1237
|
+
identity: bob,
|
|
1238
|
+
// No token needed
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
onHandshakeComplete: () => {
|
|
1242
|
+
bobHandshakeComplete = true;
|
|
1243
|
+
},
|
|
1244
|
+
onError: (error) => {
|
|
1245
|
+
console.error("[test] Bob error:", error);
|
|
1246
|
+
},
|
|
1247
|
+
}
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
// Connect both
|
|
1251
|
+
await Promise.all([clientAlice.connect(), clientBob.connect()]);
|
|
1252
|
+
|
|
1253
|
+
// Wait for both handshakes
|
|
1254
|
+
await new Promise<void>((resolve, reject) => {
|
|
1255
|
+
const checkInterval = setInterval(() => {
|
|
1256
|
+
if (aliceHandshakeComplete && bobHandshakeComplete) {
|
|
1257
|
+
clearInterval(checkInterval);
|
|
1258
|
+
resolve();
|
|
1259
|
+
}
|
|
1260
|
+
}, 100);
|
|
1261
|
+
setTimeout(() => {
|
|
1262
|
+
clearInterval(checkInterval);
|
|
1263
|
+
reject(new Error("Handshake timeout"));
|
|
1264
|
+
}, 15000);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// Verify both sessions established
|
|
1268
|
+
expect(aliceHandshakeComplete).toBe(true);
|
|
1269
|
+
expect(bobHandshakeComplete).toBe(true);
|
|
1270
|
+
// Note: Session count may be >= 2 due to RelayClient auto-reconnection
|
|
1271
|
+
expect(sessionManager.activeSessionCount).toBeGreaterThanOrEqual(2);
|
|
1272
|
+
expect(sessionManager.establishedSessionCount).toBeGreaterThanOrEqual(2);
|
|
1273
|
+
|
|
1274
|
+
// Verify both clients were authenticated
|
|
1275
|
+
expect(authenticatedClients).toContain(testFixtures.alice.id);
|
|
1276
|
+
expect(authenticatedClients).toContain(bob.id);
|
|
1277
|
+
|
|
1278
|
+
// Clean up
|
|
1279
|
+
clientAlice.disconnect();
|
|
1280
|
+
clientBob.disconnect();
|
|
1281
|
+
sessionManager.cleanup();
|
|
1282
|
+
machineWs.close();
|
|
1283
|
+
});
|
|
1284
|
+
});
|