@wangyaoshen/remux 0.3.8-dev.29e114b
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/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE tests for Remux.
|
|
3
|
+
* Tests X25519 key exchange + AES-256-GCM encryption layer.
|
|
4
|
+
*
|
|
5
|
+
* Design reference: Signal Protocol / Mosh E2EE (X25519 ECDH + HKDF + AES-GCM)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
generateKeyPair,
|
|
11
|
+
deriveSharedSecret,
|
|
12
|
+
encrypt,
|
|
13
|
+
decrypt,
|
|
14
|
+
E2EESession,
|
|
15
|
+
} from "../src/e2ee.js";
|
|
16
|
+
|
|
17
|
+
describe("e2ee", () => {
|
|
18
|
+
// ── Key pair generation ──
|
|
19
|
+
|
|
20
|
+
describe("generateKeyPair", () => {
|
|
21
|
+
it("should return publicKey and privateKey buffers", () => {
|
|
22
|
+
const kp = generateKeyPair();
|
|
23
|
+
expect(kp).toHaveProperty("publicKey");
|
|
24
|
+
expect(kp).toHaveProperty("privateKey");
|
|
25
|
+
expect(Buffer.isBuffer(kp.publicKey)).toBe(true);
|
|
26
|
+
expect(Buffer.isBuffer(kp.privateKey)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should generate 32-byte X25519 keys", () => {
|
|
30
|
+
const kp = generateKeyPair();
|
|
31
|
+
expect(kp.publicKey.length).toBe(32);
|
|
32
|
+
expect(kp.privateKey.length).toBe(32);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should generate unique key pairs each time", () => {
|
|
36
|
+
const kp1 = generateKeyPair();
|
|
37
|
+
const kp2 = generateKeyPair();
|
|
38
|
+
expect(kp1.publicKey.equals(kp2.publicKey)).toBe(false);
|
|
39
|
+
expect(kp1.privateKey.equals(kp2.privateKey)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── Shared secret derivation ──
|
|
44
|
+
|
|
45
|
+
describe("deriveSharedSecret", () => {
|
|
46
|
+
it("should derive the same shared secret on both sides", () => {
|
|
47
|
+
const alice = generateKeyPair();
|
|
48
|
+
const bob = generateKeyPair();
|
|
49
|
+
|
|
50
|
+
const secretA = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
51
|
+
const secretB = deriveSharedSecret(bob.privateKey, alice.publicKey);
|
|
52
|
+
|
|
53
|
+
expect(secretA.equals(secretB)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should return a 32-byte key (256-bit AES key)", () => {
|
|
57
|
+
const alice = generateKeyPair();
|
|
58
|
+
const bob = generateKeyPair();
|
|
59
|
+
const secret = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
60
|
+
expect(secret.length).toBe(32);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should produce different secrets with different peers", () => {
|
|
64
|
+
const alice = generateKeyPair();
|
|
65
|
+
const bob = generateKeyPair();
|
|
66
|
+
const carol = generateKeyPair();
|
|
67
|
+
|
|
68
|
+
const secretAB = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
69
|
+
const secretAC = deriveSharedSecret(alice.privateKey, carol.publicKey);
|
|
70
|
+
|
|
71
|
+
expect(secretAB.equals(secretAC)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Encrypt / Decrypt round-trip ──
|
|
76
|
+
|
|
77
|
+
describe("encrypt / decrypt", () => {
|
|
78
|
+
it("should round-trip encrypt and decrypt", () => {
|
|
79
|
+
const alice = generateKeyPair();
|
|
80
|
+
const bob = generateKeyPair();
|
|
81
|
+
const key = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
82
|
+
|
|
83
|
+
const plaintext = Buffer.from("hello, encrypted world!");
|
|
84
|
+
const { ciphertext, tag, iv } = encrypt(key, plaintext, 0n);
|
|
85
|
+
|
|
86
|
+
const decrypted = decrypt(key, ciphertext, tag, iv);
|
|
87
|
+
expect(decrypted.toString("utf8")).toBe("hello, encrypted world!");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should produce different ciphertext with different counters", () => {
|
|
91
|
+
const alice = generateKeyPair();
|
|
92
|
+
const bob = generateKeyPair();
|
|
93
|
+
const key = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
94
|
+
|
|
95
|
+
const plaintext = Buffer.from("same message");
|
|
96
|
+
const enc1 = encrypt(key, plaintext, 0n);
|
|
97
|
+
const enc2 = encrypt(key, plaintext, 1n);
|
|
98
|
+
|
|
99
|
+
expect(enc1.ciphertext.equals(enc2.ciphertext)).toBe(false);
|
|
100
|
+
expect(enc1.iv.equals(enc2.iv)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should produce 12-byte IV", () => {
|
|
104
|
+
const key = deriveSharedSecret(
|
|
105
|
+
generateKeyPair().privateKey,
|
|
106
|
+
generateKeyPair().publicKey,
|
|
107
|
+
);
|
|
108
|
+
const { iv } = encrypt(key, Buffer.from("test"), 0n);
|
|
109
|
+
expect(iv.length).toBe(12);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should produce 16-byte auth tag", () => {
|
|
113
|
+
const key = deriveSharedSecret(
|
|
114
|
+
generateKeyPair().privateKey,
|
|
115
|
+
generateKeyPair().publicKey,
|
|
116
|
+
);
|
|
117
|
+
const { tag } = encrypt(key, Buffer.from("test"), 0n);
|
|
118
|
+
expect(tag.length).toBe(16);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should fail to decrypt with wrong key", () => {
|
|
122
|
+
const alice = generateKeyPair();
|
|
123
|
+
const bob = generateKeyPair();
|
|
124
|
+
const carol = generateKeyPair();
|
|
125
|
+
|
|
126
|
+
const rightKey = deriveSharedSecret(alice.privateKey, bob.publicKey);
|
|
127
|
+
const wrongKey = deriveSharedSecret(alice.privateKey, carol.publicKey);
|
|
128
|
+
|
|
129
|
+
const plaintext = Buffer.from("secret data");
|
|
130
|
+
const { ciphertext, tag, iv } = encrypt(rightKey, plaintext, 0n);
|
|
131
|
+
|
|
132
|
+
expect(() => decrypt(wrongKey, ciphertext, tag, iv)).toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should fail to decrypt with tampered ciphertext", () => {
|
|
136
|
+
const key = deriveSharedSecret(
|
|
137
|
+
generateKeyPair().privateKey,
|
|
138
|
+
generateKeyPair().publicKey,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const plaintext = Buffer.from("untampered message");
|
|
142
|
+
const { ciphertext, tag, iv } = encrypt(key, plaintext, 0n);
|
|
143
|
+
|
|
144
|
+
// Flip a bit in the ciphertext
|
|
145
|
+
const tampered = Buffer.from(ciphertext);
|
|
146
|
+
tampered[0] ^= 0xff;
|
|
147
|
+
|
|
148
|
+
expect(() => decrypt(key, tampered, tag, iv)).toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should fail to decrypt with tampered auth tag", () => {
|
|
152
|
+
const key = deriveSharedSecret(
|
|
153
|
+
generateKeyPair().privateKey,
|
|
154
|
+
generateKeyPair().publicKey,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const plaintext = Buffer.from("tagged message");
|
|
158
|
+
const { ciphertext, tag, iv } = encrypt(key, plaintext, 0n);
|
|
159
|
+
|
|
160
|
+
const tamperedTag = Buffer.from(tag);
|
|
161
|
+
tamperedTag[0] ^= 0xff;
|
|
162
|
+
|
|
163
|
+
expect(() => decrypt(key, ciphertext, tamperedTag, iv)).toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should handle empty plaintext", () => {
|
|
167
|
+
const key = deriveSharedSecret(
|
|
168
|
+
generateKeyPair().privateKey,
|
|
169
|
+
generateKeyPair().publicKey,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const plaintext = Buffer.from("");
|
|
173
|
+
const { ciphertext, tag, iv } = encrypt(key, plaintext, 0n);
|
|
174
|
+
const decrypted = decrypt(key, ciphertext, tag, iv);
|
|
175
|
+
expect(decrypted.toString("utf8")).toBe("");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should handle large messages (64KB)", () => {
|
|
179
|
+
const key = deriveSharedSecret(
|
|
180
|
+
generateKeyPair().privateKey,
|
|
181
|
+
generateKeyPair().publicKey,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const plaintext = Buffer.alloc(65536, 0x42); // 64KB of 'B'
|
|
185
|
+
const { ciphertext, tag, iv } = encrypt(key, plaintext, 0n);
|
|
186
|
+
const decrypted = decrypt(key, ciphertext, tag, iv);
|
|
187
|
+
expect(decrypted.equals(plaintext)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── E2EESession ──
|
|
192
|
+
|
|
193
|
+
describe("E2EESession", () => {
|
|
194
|
+
it("should expose a base64-encoded public key", () => {
|
|
195
|
+
const session = new E2EESession();
|
|
196
|
+
const pubKey = session.getPublicKey();
|
|
197
|
+
expect(typeof pubKey).toBe("string");
|
|
198
|
+
// Should be valid base64
|
|
199
|
+
const decoded = Buffer.from(pubKey, "base64");
|
|
200
|
+
expect(decoded.length).toBe(32);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should not be established before handshake", () => {
|
|
204
|
+
const session = new E2EESession();
|
|
205
|
+
expect(session.isEstablished()).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should complete handshake between two sessions", () => {
|
|
209
|
+
const client = new E2EESession();
|
|
210
|
+
const server = new E2EESession();
|
|
211
|
+
|
|
212
|
+
// Exchange public keys
|
|
213
|
+
server.completeHandshake(client.getPublicKey());
|
|
214
|
+
client.completeHandshake(server.getPublicKey());
|
|
215
|
+
|
|
216
|
+
expect(client.isEstablished()).toBe(true);
|
|
217
|
+
expect(server.isEstablished()).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should encrypt/decrypt messages after handshake", () => {
|
|
221
|
+
const client = new E2EESession();
|
|
222
|
+
const server = new E2EESession();
|
|
223
|
+
|
|
224
|
+
server.completeHandshake(client.getPublicKey());
|
|
225
|
+
client.completeHandshake(server.getPublicKey());
|
|
226
|
+
|
|
227
|
+
// Client sends to server
|
|
228
|
+
const encrypted = client.encryptMessage("hello server");
|
|
229
|
+
const decrypted = server.decryptMessage(encrypted);
|
|
230
|
+
expect(decrypted).toBe("hello server");
|
|
231
|
+
|
|
232
|
+
// Server sends to client
|
|
233
|
+
const encrypted2 = server.encryptMessage("hello client");
|
|
234
|
+
const decrypted2 = client.decryptMessage(encrypted2);
|
|
235
|
+
expect(decrypted2).toBe("hello client");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should handle multiple messages with incrementing counters", () => {
|
|
239
|
+
const client = new E2EESession();
|
|
240
|
+
const server = new E2EESession();
|
|
241
|
+
|
|
242
|
+
server.completeHandshake(client.getPublicKey());
|
|
243
|
+
client.completeHandshake(server.getPublicKey());
|
|
244
|
+
|
|
245
|
+
const messages = [
|
|
246
|
+
"first message",
|
|
247
|
+
"second message",
|
|
248
|
+
"third message",
|
|
249
|
+
"unicode: \u4f60\u597d\u4e16\u754c\ud83d\ude00",
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
for (const msg of messages) {
|
|
253
|
+
const enc = client.encryptMessage(msg);
|
|
254
|
+
const dec = server.decryptMessage(enc);
|
|
255
|
+
expect(dec).toBe(msg);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should reject replayed messages (anti-replay)", () => {
|
|
260
|
+
const client = new E2EESession();
|
|
261
|
+
const server = new E2EESession();
|
|
262
|
+
|
|
263
|
+
server.completeHandshake(client.getPublicKey());
|
|
264
|
+
client.completeHandshake(server.getPublicKey());
|
|
265
|
+
|
|
266
|
+
const encrypted = client.encryptMessage("original message");
|
|
267
|
+
// First decrypt succeeds
|
|
268
|
+
server.decryptMessage(encrypted);
|
|
269
|
+
// Replay: same encrypted message should fail
|
|
270
|
+
expect(() => server.decryptMessage(encrypted)).toThrow();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should throw when encrypting before handshake", () => {
|
|
274
|
+
const session = new E2EESession();
|
|
275
|
+
expect(() => session.encryptMessage("test")).toThrow();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should throw when decrypting before handshake", () => {
|
|
279
|
+
const session = new E2EESession();
|
|
280
|
+
expect(() => session.decryptMessage("dGVzdA==")).toThrow();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should handle concurrent independent sessions", () => {
|
|
284
|
+
// Two separate E2EE channels that don't interfere
|
|
285
|
+
const clientA = new E2EESession();
|
|
286
|
+
const serverA = new E2EESession();
|
|
287
|
+
const clientB = new E2EESession();
|
|
288
|
+
const serverB = new E2EESession();
|
|
289
|
+
|
|
290
|
+
serverA.completeHandshake(clientA.getPublicKey());
|
|
291
|
+
clientA.completeHandshake(serverA.getPublicKey());
|
|
292
|
+
|
|
293
|
+
serverB.completeHandshake(clientB.getPublicKey());
|
|
294
|
+
clientB.completeHandshake(serverB.getPublicKey());
|
|
295
|
+
|
|
296
|
+
// Channel A messages
|
|
297
|
+
const encA = clientA.encryptMessage("channel A");
|
|
298
|
+
const decA = serverA.decryptMessage(encA);
|
|
299
|
+
expect(decA).toBe("channel A");
|
|
300
|
+
|
|
301
|
+
// Channel B messages
|
|
302
|
+
const encB = clientB.encryptMessage("channel B");
|
|
303
|
+
const decB = serverB.decryptMessage(encB);
|
|
304
|
+
expect(decB).toBe("channel B");
|
|
305
|
+
|
|
306
|
+
// Cross-channel: A's ciphertext should NOT decrypt on B
|
|
307
|
+
const encCross = clientA.encryptMessage("cross-channel test");
|
|
308
|
+
expect(() => serverB.decryptMessage(encCross)).toThrow();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should handle binary-like content (terminal output with control chars)", () => {
|
|
312
|
+
const client = new E2EESession();
|
|
313
|
+
const server = new E2EESession();
|
|
314
|
+
|
|
315
|
+
server.completeHandshake(client.getPublicKey());
|
|
316
|
+
client.completeHandshake(server.getPublicKey());
|
|
317
|
+
|
|
318
|
+
// Simulate terminal output with ANSI escapes
|
|
319
|
+
const terminalData = "\x1b[32mgreen text\x1b[0m\r\n$ ls -la\r\n";
|
|
320
|
+
const enc = client.encryptMessage(terminalData);
|
|
321
|
+
const dec = server.decryptMessage(enc);
|
|
322
|
+
expect(dec).toBe(terminalData);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for server-side message buffering and session recovery.
|
|
3
|
+
* Covers MessageBuffer (per-device ring buffer) and BufferRegistry (global manager).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
// Will import from the source module once implemented
|
|
9
|
+
import { MessageBuffer, BufferRegistry } from "../src/message-buffer.js";
|
|
10
|
+
|
|
11
|
+
// ── MessageBuffer ───────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("MessageBuffer", () => {
|
|
14
|
+
let buf;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
buf = new MessageBuffer(5, 10_000); // 5 msgs, 10s TTL
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("push/drain round trip returns all messages", () => {
|
|
21
|
+
buf.push('{"type":"data","payload":"hello"}');
|
|
22
|
+
buf.push('{"type":"data","payload":"world"}');
|
|
23
|
+
|
|
24
|
+
const msgs = buf.drain();
|
|
25
|
+
expect(msgs).toHaveLength(2);
|
|
26
|
+
expect(msgs[0].data).toBe('{"type":"data","payload":"hello"}');
|
|
27
|
+
expect(msgs[1].data).toBe('{"type":"data","payload":"world"}');
|
|
28
|
+
// Each message has a timestamp
|
|
29
|
+
expect(typeof msgs[0].timestamp).toBe("number");
|
|
30
|
+
expect(msgs[0].timestamp).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("drain clears the buffer", () => {
|
|
34
|
+
buf.push("a");
|
|
35
|
+
buf.push("b");
|
|
36
|
+
const first = buf.drain();
|
|
37
|
+
expect(first).toHaveLength(2);
|
|
38
|
+
|
|
39
|
+
const second = buf.drain();
|
|
40
|
+
expect(second).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("maxSize eviction drops oldest messages", () => {
|
|
44
|
+
// maxSize = 5
|
|
45
|
+
for (let i = 0; i < 8; i++) {
|
|
46
|
+
buf.push(`msg-${i}`);
|
|
47
|
+
}
|
|
48
|
+
const msgs = buf.drain();
|
|
49
|
+
expect(msgs).toHaveLength(5);
|
|
50
|
+
// Oldest 3 (msg-0, msg-1, msg-2) should have been evicted
|
|
51
|
+
expect(msgs[0].data).toBe("msg-3");
|
|
52
|
+
expect(msgs[4].data).toBe("msg-7");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maxAgeMs expiration filters old messages on drain", () => {
|
|
56
|
+
// Use fake timers to control time
|
|
57
|
+
vi.useFakeTimers();
|
|
58
|
+
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
buf.push("old-msg");
|
|
61
|
+
|
|
62
|
+
// Advance time past TTL
|
|
63
|
+
vi.advanceTimersByTime(11_000); // 11s > 10s TTL
|
|
64
|
+
buf.push("new-msg");
|
|
65
|
+
|
|
66
|
+
const msgs = buf.drain();
|
|
67
|
+
expect(msgs).toHaveLength(1);
|
|
68
|
+
expect(msgs[0].data).toBe("new-msg");
|
|
69
|
+
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("drain with since timestamp filter returns only newer messages", () => {
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
|
|
76
|
+
buf.push("msg-1");
|
|
77
|
+
const t1 = Date.now();
|
|
78
|
+
|
|
79
|
+
vi.advanceTimersByTime(1000);
|
|
80
|
+
buf.push("msg-2");
|
|
81
|
+
|
|
82
|
+
vi.advanceTimersByTime(1000);
|
|
83
|
+
buf.push("msg-3");
|
|
84
|
+
|
|
85
|
+
// Drain messages since t1 (should exclude msg-1 which was pushed at t1)
|
|
86
|
+
const msgs = buf.drain(t1);
|
|
87
|
+
expect(msgs).toHaveLength(2);
|
|
88
|
+
expect(msgs[0].data).toBe("msg-2");
|
|
89
|
+
expect(msgs[1].data).toBe("msg-3");
|
|
90
|
+
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("clear empties the buffer", () => {
|
|
95
|
+
buf.push("a");
|
|
96
|
+
buf.push("b");
|
|
97
|
+
buf.push("c");
|
|
98
|
+
buf.clear();
|
|
99
|
+
expect(buf.size).toBe(0);
|
|
100
|
+
expect(buf.drain()).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("size is accurate", () => {
|
|
104
|
+
expect(buf.size).toBe(0);
|
|
105
|
+
buf.push("a");
|
|
106
|
+
expect(buf.size).toBe(1);
|
|
107
|
+
buf.push("b");
|
|
108
|
+
expect(buf.size).toBe(2);
|
|
109
|
+
buf.drain();
|
|
110
|
+
expect(buf.size).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("size respects maxSize cap", () => {
|
|
114
|
+
// maxSize = 5
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
buf.push(`msg-${i}`);
|
|
117
|
+
}
|
|
118
|
+
expect(buf.size).toBe(5);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("pruneExpired removes old messages", () => {
|
|
122
|
+
vi.useFakeTimers();
|
|
123
|
+
|
|
124
|
+
buf.push("old-1");
|
|
125
|
+
buf.push("old-2");
|
|
126
|
+
|
|
127
|
+
vi.advanceTimersByTime(11_000); // past TTL
|
|
128
|
+
buf.push("new-1");
|
|
129
|
+
|
|
130
|
+
buf.pruneExpired();
|
|
131
|
+
expect(buf.size).toBe(1);
|
|
132
|
+
const msgs = buf.drain();
|
|
133
|
+
expect(msgs[0].data).toBe("new-1");
|
|
134
|
+
|
|
135
|
+
vi.useRealTimers();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("uses default maxSize and maxAgeMs when not specified", () => {
|
|
139
|
+
const defaultBuf = new MessageBuffer();
|
|
140
|
+
// Default: 1000 messages, 10 minutes
|
|
141
|
+
// Push a message and verify it works
|
|
142
|
+
defaultBuf.push("test");
|
|
143
|
+
expect(defaultBuf.size).toBe(1);
|
|
144
|
+
expect(defaultBuf.drain()).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── BufferRegistry ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe("BufferRegistry", () => {
|
|
151
|
+
let registry;
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
registry = new BufferRegistry();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
registry.destroy();
|
|
160
|
+
vi.useRealTimers();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("getOrCreate creates a new buffer for unknown deviceId", () => {
|
|
164
|
+
const buf = registry.getOrCreate("device-1");
|
|
165
|
+
expect(buf).toBeInstanceOf(MessageBuffer);
|
|
166
|
+
expect(buf.size).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("getOrCreate returns the same buffer for the same deviceId", () => {
|
|
170
|
+
const buf1 = registry.getOrCreate("device-1");
|
|
171
|
+
buf1.push("hello");
|
|
172
|
+
const buf2 = registry.getOrCreate("device-1");
|
|
173
|
+
expect(buf2).toBe(buf1);
|
|
174
|
+
expect(buf2.size).toBe(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("getOrCreate returns different buffers for different deviceIds", () => {
|
|
178
|
+
const buf1 = registry.getOrCreate("device-1");
|
|
179
|
+
const buf2 = registry.getOrCreate("device-2");
|
|
180
|
+
expect(buf1).not.toBe(buf2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("remove deletes a buffer", () => {
|
|
184
|
+
const buf = registry.getOrCreate("device-1");
|
|
185
|
+
buf.push("hello");
|
|
186
|
+
|
|
187
|
+
registry.remove("device-1");
|
|
188
|
+
|
|
189
|
+
// Getting again should be a fresh buffer
|
|
190
|
+
const newBuf = registry.getOrCreate("device-1");
|
|
191
|
+
expect(newBuf.size).toBe(0);
|
|
192
|
+
expect(newBuf).not.toBe(buf);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("remove is safe for non-existent deviceId", () => {
|
|
196
|
+
// Should not throw
|
|
197
|
+
registry.remove("nonexistent");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("cleanup removes empty buffers with no recent activity", () => {
|
|
201
|
+
const buf = registry.getOrCreate("device-1");
|
|
202
|
+
// Buffer is empty and was created a while ago
|
|
203
|
+
|
|
204
|
+
// Advance time so the buffer looks stale
|
|
205
|
+
vi.advanceTimersByTime(120_000); // 2 minutes
|
|
206
|
+
|
|
207
|
+
// Trigger cleanup via the interval (60s interval)
|
|
208
|
+
vi.advanceTimersByTime(60_000);
|
|
209
|
+
|
|
210
|
+
// Getting again should create a new buffer
|
|
211
|
+
const newBuf = registry.getOrCreate("device-1");
|
|
212
|
+
// If cleanup removed it, this would be a fresh buffer
|
|
213
|
+
// (but if the buffer was still there, the reference would be different
|
|
214
|
+
// only if it was removed and recreated)
|
|
215
|
+
expect(newBuf.size).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("cleanup preserves buffers with messages", () => {
|
|
219
|
+
const buf = registry.getOrCreate("device-1");
|
|
220
|
+
buf.push("important-message");
|
|
221
|
+
|
|
222
|
+
// Advance time and trigger cleanup
|
|
223
|
+
vi.advanceTimersByTime(120_000);
|
|
224
|
+
vi.advanceTimersByTime(60_000);
|
|
225
|
+
|
|
226
|
+
// Buffer should still exist with its message
|
|
227
|
+
const sameBuf = registry.getOrCreate("device-1");
|
|
228
|
+
expect(sameBuf).toBe(buf);
|
|
229
|
+
expect(sameBuf.size).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("destroy clears all buffers and stops interval", () => {
|
|
233
|
+
registry.getOrCreate("device-1").push("msg");
|
|
234
|
+
registry.getOrCreate("device-2").push("msg");
|
|
235
|
+
|
|
236
|
+
registry.destroy();
|
|
237
|
+
|
|
238
|
+
// After destroy, getting buffers again should return fresh ones
|
|
239
|
+
// (destroy should have cleared the map)
|
|
240
|
+
const freshRegistry = new BufferRegistry();
|
|
241
|
+
const buf = freshRegistry.getOrCreate("device-1");
|
|
242
|
+
expect(buf.size).toBe(0);
|
|
243
|
+
freshRegistry.destroy();
|
|
244
|
+
});
|
|
245
|
+
});
|