@wangyaoshen/remux 0.3.8-dev.a8ceb0c
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,99 @@
|
|
|
1
|
+
// E10-003: AdapterRegistry — manages adapter registration, queries, event dispatch
|
|
2
|
+
|
|
3
|
+
import { SemanticAdapter, SemanticEvent, AdapterState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export class AdapterRegistry {
|
|
6
|
+
private adapters: Map<string, SemanticAdapter> = new Map();
|
|
7
|
+
private eventSeq = 0;
|
|
8
|
+
private listeners: ((event: SemanticEvent) => void)[] = [];
|
|
9
|
+
|
|
10
|
+
register(adapter: SemanticAdapter): void {
|
|
11
|
+
// Stop existing adapter with same id to prevent resource leaks
|
|
12
|
+
const existing = this.adapters.get(adapter.id);
|
|
13
|
+
if (existing) existing.stop?.();
|
|
14
|
+
this.adapters.set(adapter.id, adapter);
|
|
15
|
+
adapter.start?.();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
unregister(id: string): void {
|
|
19
|
+
const adapter = this.adapters.get(id);
|
|
20
|
+
adapter?.stop?.();
|
|
21
|
+
this.adapters.delete(id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(id: string): SemanticAdapter | undefined {
|
|
25
|
+
return this.adapters.get(id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getAll(): SemanticAdapter[] {
|
|
29
|
+
return Array.from(this.adapters.values());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getAllStates(): AdapterState[] {
|
|
33
|
+
return this.getAll().map((a) => a.getCurrentState());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Subscribe to adapter events */
|
|
37
|
+
onEvent(listener: (event: SemanticEvent) => void): () => void {
|
|
38
|
+
this.listeners.push(listener);
|
|
39
|
+
return () => {
|
|
40
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Emit an event from an adapter */
|
|
45
|
+
emit(adapterId: string, type: string, data: Record<string, unknown>): void {
|
|
46
|
+
const event: SemanticEvent = {
|
|
47
|
+
type,
|
|
48
|
+
seq: ++this.eventSeq,
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
data,
|
|
51
|
+
adapterId,
|
|
52
|
+
};
|
|
53
|
+
for (const listener of this.listeners) {
|
|
54
|
+
try {
|
|
55
|
+
listener(event);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[adapter-registry] listener error:`, err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stop all adapters and clear state (for clean shutdown). */
|
|
63
|
+
shutdown(): void {
|
|
64
|
+
for (const adapter of this.adapters.values()) {
|
|
65
|
+
try { adapter.stop?.(); } catch {}
|
|
66
|
+
}
|
|
67
|
+
this.adapters.clear();
|
|
68
|
+
this.listeners = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Forward terminal data to all passive adapters */
|
|
72
|
+
dispatchTerminalData(sessionName: string, data: string): void {
|
|
73
|
+
for (const adapter of this.adapters.values()) {
|
|
74
|
+
if (adapter.mode === "passive" && adapter.onTerminalData) {
|
|
75
|
+
try {
|
|
76
|
+
adapter.onTerminalData(sessionName, data);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[adapter:${adapter.id}] onTerminalData error:`, err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Forward event file data to all passive adapters */
|
|
85
|
+
dispatchEventFile(
|
|
86
|
+
path: string,
|
|
87
|
+
event: Record<string, unknown>,
|
|
88
|
+
): void {
|
|
89
|
+
for (const adapter of this.adapters.values()) {
|
|
90
|
+
if (adapter.mode === "passive" && adapter.onEventFile) {
|
|
91
|
+
try {
|
|
92
|
+
adapter.onEventFile(path, event);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error(`[adapter:${adapter.id}] onEventFile error:`, err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// E10-001: SemanticEvent and AdapterState types
|
|
2
|
+
|
|
3
|
+
export interface SemanticEvent {
|
|
4
|
+
type: string;
|
|
5
|
+
seq: number;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
data: Record<string, unknown>;
|
|
8
|
+
adapterId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AdapterState {
|
|
12
|
+
adapterId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
mode: "none" | "passive" | "active";
|
|
15
|
+
capabilities: string[];
|
|
16
|
+
currentState: "idle" | "running" | "waiting_approval" | "error";
|
|
17
|
+
lastEvent?: SemanticEvent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// E10-002: SemanticAdapter interface
|
|
21
|
+
export interface SemanticAdapter {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
mode: "none" | "passive" | "active";
|
|
25
|
+
capabilities: string[];
|
|
26
|
+
|
|
27
|
+
// Passive mode: infer state from terminal data or event files
|
|
28
|
+
onTerminalData?(sessionName: string, data: string): void;
|
|
29
|
+
onEventFile?(path: string, event: Record<string, unknown>): void;
|
|
30
|
+
|
|
31
|
+
// Active mode: can initiate operations
|
|
32
|
+
createRun?(params: Record<string, unknown>): Promise<void>;
|
|
33
|
+
steerRun?(runId: string, instruction: string): Promise<void>;
|
|
34
|
+
|
|
35
|
+
// State query
|
|
36
|
+
getCurrentState(): AdapterState;
|
|
37
|
+
|
|
38
|
+
// Lifecycle
|
|
39
|
+
start?(): void;
|
|
40
|
+
stop?(): void;
|
|
41
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication module for Remux.
|
|
3
|
+
* Handles token auth, password auth, session token management,
|
|
4
|
+
* and device registration/trust.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import type http from "http";
|
|
9
|
+
import {
|
|
10
|
+
computeFingerprint,
|
|
11
|
+
findDeviceById,
|
|
12
|
+
findDeviceByFingerprint,
|
|
13
|
+
createDevice,
|
|
14
|
+
hasAnyDevice,
|
|
15
|
+
touchDevice,
|
|
16
|
+
type Device,
|
|
17
|
+
type TrustLevel,
|
|
18
|
+
} from "./store.js";
|
|
19
|
+
|
|
20
|
+
// ── State ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Tokens generated from password login (with TTL expiry)
|
|
23
|
+
const PASSWORD_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
24
|
+
export const passwordTokens = new Map<string, number>(); // token → expiresAt
|
|
25
|
+
|
|
26
|
+
/** Periodically clean expired password tokens (every 10 minutes). */
|
|
27
|
+
setInterval(() => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
for (const [token, expiresAt] of passwordTokens) {
|
|
30
|
+
if (now >= expiresAt) passwordTokens.delete(token);
|
|
31
|
+
}
|
|
32
|
+
}, 10 * 60 * 1000).unref();
|
|
33
|
+
|
|
34
|
+
/** Add a password token with TTL. */
|
|
35
|
+
export function addPasswordToken(token: string): void {
|
|
36
|
+
passwordTokens.set(token, Date.now() + PASSWORD_TOKEN_TTL_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── CLI password parsing ─────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse --password CLI flag from process.argv.
|
|
43
|
+
*/
|
|
44
|
+
export function parseCliPassword(argv: string[]): string | null {
|
|
45
|
+
const idx = argv.indexOf("--password");
|
|
46
|
+
return idx !== -1 && idx + 1 < argv.length ? argv[idx + 1] : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Auth config ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve authentication configuration.
|
|
53
|
+
* Priority: REMUX_TOKEN > REMUX_PASSWORD (+ --password CLI) > auto-generated token
|
|
54
|
+
*/
|
|
55
|
+
export function resolveAuth(argv: string[]): {
|
|
56
|
+
TOKEN: string | null;
|
|
57
|
+
PASSWORD: string | null;
|
|
58
|
+
} {
|
|
59
|
+
const PASSWORD =
|
|
60
|
+
process.env.REMUX_PASSWORD || parseCliPassword(argv) || null;
|
|
61
|
+
const TOKEN =
|
|
62
|
+
process.env.REMUX_TOKEN ||
|
|
63
|
+
(PASSWORD ? null : crypto.randomBytes(16).toString("hex"));
|
|
64
|
+
return { TOKEN, PASSWORD };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Token generation & validation ────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a cryptographically random session token (hex).
|
|
71
|
+
*/
|
|
72
|
+
export function generateToken(): string {
|
|
73
|
+
return crypto.randomBytes(16).toString("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate a token against the configured TOKEN, PASSWORD-generated tokens.
|
|
78
|
+
*/
|
|
79
|
+
export function validateToken(
|
|
80
|
+
token: string,
|
|
81
|
+
TOKEN: string | null,
|
|
82
|
+
): boolean {
|
|
83
|
+
// Timing-safe comparison to prevent side-channel attacks
|
|
84
|
+
if (TOKEN && token.length === TOKEN.length) {
|
|
85
|
+
const equal = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(TOKEN));
|
|
86
|
+
if (equal) return true;
|
|
87
|
+
}
|
|
88
|
+
const entry = passwordTokens.get(token);
|
|
89
|
+
if (entry && Date.now() < entry) return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Device registration ─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Register or retrieve a device.
|
|
97
|
+
* Uses client-provided deviceId (from localStorage) when available,
|
|
98
|
+
* falling back to header-based fingerprint for legacy clients.
|
|
99
|
+
* First device ever is auto-trusted (bootstrap trust).
|
|
100
|
+
*/
|
|
101
|
+
export function registerDevice(
|
|
102
|
+
req: http.IncomingMessage,
|
|
103
|
+
clientDeviceId?: string,
|
|
104
|
+
): { device: Device; isNew: boolean } {
|
|
105
|
+
// Prefer client-provided persistent deviceId over header fingerprint
|
|
106
|
+
if (clientDeviceId) {
|
|
107
|
+
const existing = findDeviceById(clientDeviceId);
|
|
108
|
+
if (existing) {
|
|
109
|
+
touchDevice(existing.id);
|
|
110
|
+
return { device: existing, isNew: false };
|
|
111
|
+
}
|
|
112
|
+
// New device with client-provided ID — create with that ID as fingerprint
|
|
113
|
+
const isFirst = !hasAnyDevice();
|
|
114
|
+
const trust: TrustLevel = isFirst ? "trusted" : "untrusted";
|
|
115
|
+
const device = createDevice(clientDeviceId, trust, undefined, clientDeviceId);
|
|
116
|
+
return { device, isNew: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Legacy fallback: header-based fingerprint
|
|
120
|
+
const ua = req.headers["user-agent"] || "";
|
|
121
|
+
const lang = req.headers["accept-language"] || "";
|
|
122
|
+
const fingerprint = computeFingerprint(ua, lang);
|
|
123
|
+
|
|
124
|
+
const existing = findDeviceByFingerprint(fingerprint);
|
|
125
|
+
if (existing) {
|
|
126
|
+
touchDevice(existing.id);
|
|
127
|
+
return { device: existing, isNew: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isFirst = !hasAnyDevice();
|
|
131
|
+
const trust: TrustLevel = isFirst ? "trusted" : "untrusted";
|
|
132
|
+
const device = createDevice(fingerprint, trust);
|
|
133
|
+
return { device, isNew: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Password page HTML ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export const PASSWORD_PAGE = `<!doctype html>
|
|
139
|
+
<html lang="en">
|
|
140
|
+
<head>
|
|
141
|
+
<meta charset="UTF-8" />
|
|
142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
143
|
+
<title>Remux \u2014 Login</title>
|
|
144
|
+
<style>
|
|
145
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
146
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
147
|
+
background: #1e1e1e; color: #ccc; height: 100vh; display: flex;
|
|
148
|
+
align-items: center; justify-content: center; }
|
|
149
|
+
.login { background: #252526; border-radius: 8px; padding: 32px; width: 320px;
|
|
150
|
+
box-shadow: 0 4px 24px rgba(0,0,0,.4); }
|
|
151
|
+
.login h1 { font-size: 18px; color: #e5e5e5; margin-bottom: 20px; text-align: center; }
|
|
152
|
+
.login input { width: 100%; padding: 10px 12px; font-size: 14px; background: #1e1e1e;
|
|
153
|
+
border: 1px solid #3a3a3a; border-radius: 4px; color: #d4d4d4;
|
|
154
|
+
font-family: inherit; outline: none; margin-bottom: 12px; }
|
|
155
|
+
.login input:focus { border-color: #007acc; }
|
|
156
|
+
.login button { width: 100%; padding: 10px; font-size: 14px; background: #007acc;
|
|
157
|
+
border: none; border-radius: 4px; color: #fff; cursor: pointer;
|
|
158
|
+
font-family: inherit; font-weight: 500; }
|
|
159
|
+
.login button:hover { background: #0098ff; }
|
|
160
|
+
.login .error { color: #f14c4c; font-size: 12px; margin-bottom: 12px; display: none; text-align: center; }
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<form class="login" method="POST" action="/auth">
|
|
165
|
+
<h1>Remux</h1>
|
|
166
|
+
<div class="error" id="error">Incorrect password</div>
|
|
167
|
+
<input type="password" name="password" placeholder="Password" autofocus required />
|
|
168
|
+
<button type="submit">Login</button>
|
|
169
|
+
</form>
|
|
170
|
+
<script>
|
|
171
|
+
if (location.search.includes('error=1')) document.getElementById('error').style.display = 'block';
|
|
172
|
+
</script>
|
|
173
|
+
</body>
|
|
174
|
+
</html>`;
|
package/src/e2ee.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE encryption layer for Remux WebSocket messages.
|
|
3
|
+
* Uses X25519 key exchange + HKDF-SHA256 key derivation + AES-256-GCM encryption.
|
|
4
|
+
*
|
|
5
|
+
* Design references:
|
|
6
|
+
* - Signal Protocol (X25519 + AES-GCM): https://signal.org/docs/specifications/x3dh/
|
|
7
|
+
* - Mosh: AES-128-OCB transport encryption with sequence numbers
|
|
8
|
+
* - Node.js crypto docs: https://nodejs.org/api/crypto.html
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
|
|
13
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const HKDF_SALT = "remux-e2ee-v1";
|
|
16
|
+
const HKDF_INFO = "aes-256-gcm";
|
|
17
|
+
const AES_KEY_LENGTH = 32; // 256 bits
|
|
18
|
+
const IV_LENGTH = 12; // 96 bits for GCM
|
|
19
|
+
const IV_PREFIX_LENGTH = 4; // fixed prefix bytes in IV
|
|
20
|
+
const IV_COUNTER_LENGTH = 8; // counter bytes in IV
|
|
21
|
+
const AUTH_TAG_LENGTH = 16; // 128 bits
|
|
22
|
+
|
|
23
|
+
// ── Key pair generation ────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate an X25519 key pair for ECDH key exchange.
|
|
27
|
+
* Returns raw 32-byte public and private key buffers.
|
|
28
|
+
*/
|
|
29
|
+
export function generateKeyPair(): { publicKey: Buffer; privateKey: Buffer } {
|
|
30
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519", {
|
|
31
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
32
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// X25519 DER-encoded SPKI public key: 12-byte header + 32-byte raw key
|
|
36
|
+
// X25519 DER-encoded PKCS8 private key: 16-byte header + 32-byte raw key
|
|
37
|
+
const rawPublic = publicKey.subarray(publicKey.length - 32);
|
|
38
|
+
const rawPrivate = privateKey.subarray(privateKey.length - 32);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
publicKey: Buffer.from(rawPublic),
|
|
42
|
+
privateKey: Buffer.from(rawPrivate),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Shared secret derivation ───────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Derive a shared AES-256-GCM key from our private key and the peer's public key.
|
|
50
|
+
* Uses X25519 ECDH followed by HKDF-SHA256 key derivation.
|
|
51
|
+
*/
|
|
52
|
+
export function deriveSharedSecret(
|
|
53
|
+
privateKey: Buffer,
|
|
54
|
+
peerPublicKey: Buffer,
|
|
55
|
+
): Buffer {
|
|
56
|
+
// Reconstruct CryptoKey objects from raw buffers
|
|
57
|
+
const privKeyObj = crypto.createPrivateKey({
|
|
58
|
+
key: buildPkcs8(privateKey),
|
|
59
|
+
format: "der",
|
|
60
|
+
type: "pkcs8",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const pubKeyObj = crypto.createPublicKey({
|
|
64
|
+
key: buildSpki(peerPublicKey),
|
|
65
|
+
format: "der",
|
|
66
|
+
type: "spki",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// X25519 ECDH to get raw shared secret
|
|
70
|
+
const rawSecret = crypto.diffieHellman({
|
|
71
|
+
privateKey: privKeyObj,
|
|
72
|
+
publicKey: pubKeyObj,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// HKDF-SHA256 to derive the final 256-bit AES key
|
|
76
|
+
const salt = Buffer.from(HKDF_SALT, "utf8");
|
|
77
|
+
const info = Buffer.from(HKDF_INFO, "utf8");
|
|
78
|
+
const derived = crypto.hkdfSync("sha256", rawSecret, salt, info, AES_KEY_LENGTH);
|
|
79
|
+
|
|
80
|
+
return Buffer.from(derived);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a DER-encoded PKCS8 wrapper around a raw 32-byte X25519 private key.
|
|
85
|
+
*/
|
|
86
|
+
function buildPkcs8(rawKey: Buffer): Buffer {
|
|
87
|
+
// PKCS8 header for X25519: 302e020100300506032b656e042204 20
|
|
88
|
+
const header = Buffer.from(
|
|
89
|
+
"302e020100300506032b656e04220420",
|
|
90
|
+
"hex",
|
|
91
|
+
);
|
|
92
|
+
return Buffer.concat([header, rawKey]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a DER-encoded SPKI wrapper around a raw 32-byte X25519 public key.
|
|
97
|
+
*/
|
|
98
|
+
function buildSpki(rawKey: Buffer): Buffer {
|
|
99
|
+
// SPKI header for X25519: 302a300506032b656e032100
|
|
100
|
+
const header = Buffer.from("302a300506032b656e032100", "hex");
|
|
101
|
+
return Buffer.concat([header, rawKey]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Encrypt / Decrypt ──────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Encrypt a plaintext buffer using AES-256-GCM.
|
|
108
|
+
*
|
|
109
|
+
* IV construction: 4 bytes fixed random prefix + 8 bytes counter (big-endian).
|
|
110
|
+
* This ensures unique IVs per message while the counter provides ordering.
|
|
111
|
+
*/
|
|
112
|
+
export function encrypt(
|
|
113
|
+
key: Buffer,
|
|
114
|
+
plaintext: Buffer,
|
|
115
|
+
counter: bigint,
|
|
116
|
+
): { ciphertext: Buffer; tag: Buffer; iv: Buffer } {
|
|
117
|
+
// Build IV: 4 bytes random prefix + 8 bytes counter (big-endian)
|
|
118
|
+
const iv = Buffer.alloc(IV_LENGTH);
|
|
119
|
+
crypto.randomFillSync(iv, 0, IV_PREFIX_LENGTH);
|
|
120
|
+
iv.writeBigUInt64BE(counter, IV_PREFIX_LENGTH);
|
|
121
|
+
|
|
122
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
123
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
124
|
+
const tag = cipher.getAuthTag();
|
|
125
|
+
|
|
126
|
+
return { ciphertext: encrypted, tag, iv };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decrypt a ciphertext buffer using AES-256-GCM.
|
|
131
|
+
* Verifies the auth tag; throws on tampered data.
|
|
132
|
+
*/
|
|
133
|
+
export function decrypt(
|
|
134
|
+
key: Buffer,
|
|
135
|
+
ciphertext: Buffer,
|
|
136
|
+
tag: Buffer,
|
|
137
|
+
iv: Buffer,
|
|
138
|
+
): Buffer {
|
|
139
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
140
|
+
decipher.setAuthTag(tag);
|
|
141
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── E2EESession ────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Manages an E2EE session for a single WebSocket connection.
|
|
148
|
+
* Handles key exchange, counter management, and anti-replay protection.
|
|
149
|
+
*/
|
|
150
|
+
export class E2EESession {
|
|
151
|
+
private sharedKey: Buffer | null = null;
|
|
152
|
+
private sendCounter: bigint = 0n;
|
|
153
|
+
private recvCounter: bigint = -1n; // last received counter; -1 means none yet
|
|
154
|
+
private localKeyPair: { publicKey: Buffer; privateKey: Buffer };
|
|
155
|
+
|
|
156
|
+
constructor() {
|
|
157
|
+
this.localKeyPair = generateKeyPair();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Get our public key as a base64-encoded string for transmission. */
|
|
161
|
+
getPublicKey(): string {
|
|
162
|
+
return this.localKeyPair.publicKey.toString("base64");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Complete the ECDH handshake with the peer's base64-encoded public key.
|
|
167
|
+
* After this, encrypt/decrypt operations become available.
|
|
168
|
+
*/
|
|
169
|
+
completeHandshake(peerPublicKeyB64: string): void {
|
|
170
|
+
const peerPublicKey = Buffer.from(peerPublicKeyB64, "base64");
|
|
171
|
+
this.sharedKey = deriveSharedSecret(
|
|
172
|
+
this.localKeyPair.privateKey,
|
|
173
|
+
peerPublicKey,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Encrypt a plaintext string for sending.
|
|
179
|
+
* Returns a base64-encoded string containing: iv (12) + ciphertext (variable) + tag (16).
|
|
180
|
+
* Increments the send counter after each call.
|
|
181
|
+
*/
|
|
182
|
+
encryptMessage(plaintext: string): string {
|
|
183
|
+
if (!this.sharedKey) {
|
|
184
|
+
throw new Error("E2EE handshake not completed");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const plaintextBuf = Buffer.from(plaintext, "utf8");
|
|
188
|
+
const { ciphertext, tag, iv } = encrypt(
|
|
189
|
+
this.sharedKey,
|
|
190
|
+
plaintextBuf,
|
|
191
|
+
this.sendCounter,
|
|
192
|
+
);
|
|
193
|
+
this.sendCounter++;
|
|
194
|
+
|
|
195
|
+
// Pack: iv (12) + ciphertext (N) + tag (16)
|
|
196
|
+
const packed = Buffer.concat([iv, ciphertext, tag]);
|
|
197
|
+
return packed.toString("base64");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Decrypt a base64-encoded encrypted message.
|
|
202
|
+
* Validates that the counter is monotonically increasing (anti-replay).
|
|
203
|
+
*/
|
|
204
|
+
decryptMessage(encrypted: string): string {
|
|
205
|
+
if (!this.sharedKey) {
|
|
206
|
+
throw new Error("E2EE handshake not completed");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const packed = Buffer.from(encrypted, "base64");
|
|
210
|
+
|
|
211
|
+
// Unpack: iv (12) + ciphertext (N) + tag (16)
|
|
212
|
+
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
|
213
|
+
throw new Error("E2EE message too short");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const iv = packed.subarray(0, IV_LENGTH);
|
|
217
|
+
const ciphertext = packed.subarray(IV_LENGTH, packed.length - AUTH_TAG_LENGTH);
|
|
218
|
+
const tag = packed.subarray(packed.length - AUTH_TAG_LENGTH);
|
|
219
|
+
|
|
220
|
+
// Extract counter from IV for anti-replay check
|
|
221
|
+
const counter = iv.readBigUInt64BE(IV_PREFIX_LENGTH);
|
|
222
|
+
if (counter <= this.recvCounter) {
|
|
223
|
+
throw new Error("E2EE replay detected: counter not monotonically increasing");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const decrypted = decrypt(this.sharedKey, ciphertext, tag, iv);
|
|
227
|
+
this.recvCounter = counter;
|
|
228
|
+
|
|
229
|
+
return decrypted.toString("utf8");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether the handshake has been completed and encryption is available. */
|
|
233
|
+
isEstablished(): boolean {
|
|
234
|
+
return this.sharedKey !== null;
|
|
235
|
+
}
|
|
236
|
+
}
|