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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated encryption using AES-256-GCM
|
|
3
|
+
*
|
|
4
|
+
* This is similar to NaCl secretbox but uses AES-256-GCM
|
|
5
|
+
* which is natively supported in Bun's node:crypto.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createCipheriv,
|
|
10
|
+
createDecipheriv,
|
|
11
|
+
randomBytes,
|
|
12
|
+
} from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** Nonce/IV length in bytes (96-bit for AES-GCM) */
|
|
15
|
+
export const NONCE_LENGTH = 12;
|
|
16
|
+
|
|
17
|
+
/** Auth tag length in bytes */
|
|
18
|
+
export const AUTH_TAG_LENGTH = 16;
|
|
19
|
+
|
|
20
|
+
/** Algorithm name */
|
|
21
|
+
const ALGORITHM = "aes-256-gcm";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a random nonce
|
|
25
|
+
*/
|
|
26
|
+
export function generateNonce(): Buffer {
|
|
27
|
+
return randomBytes(NONCE_LENGTH);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encrypt data using ChaCha20-Poly1305
|
|
32
|
+
*
|
|
33
|
+
* @param data - Plaintext data to encrypt
|
|
34
|
+
* @param key - 256-bit key (from deriveKey)
|
|
35
|
+
* @returns Object with nonce and ciphertext (includes auth tag)
|
|
36
|
+
*/
|
|
37
|
+
export function encrypt(
|
|
38
|
+
data: Uint8Array | Buffer,
|
|
39
|
+
key: Uint8Array | Buffer
|
|
40
|
+
): { nonce: Buffer; ciphertext: Buffer } {
|
|
41
|
+
const nonce = generateNonce();
|
|
42
|
+
|
|
43
|
+
const cipher = createCipheriv(ALGORITHM, key, nonce, {
|
|
44
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
48
|
+
const authTag = cipher.getAuthTag();
|
|
49
|
+
|
|
50
|
+
// Append auth tag to ciphertext
|
|
51
|
+
const ciphertext = Buffer.concat([encrypted, authTag]);
|
|
52
|
+
|
|
53
|
+
return { nonce, ciphertext };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decrypt data using ChaCha20-Poly1305
|
|
58
|
+
*
|
|
59
|
+
* @param ciphertext - Encrypted data (includes auth tag at end)
|
|
60
|
+
* @param nonce - Nonce used for encryption
|
|
61
|
+
* @param key - 256-bit key (same as used for encryption)
|
|
62
|
+
* @returns Decrypted plaintext, or null if authentication failed
|
|
63
|
+
*/
|
|
64
|
+
export function decrypt(
|
|
65
|
+
ciphertext: Uint8Array | Buffer,
|
|
66
|
+
nonce: Uint8Array | Buffer,
|
|
67
|
+
key: Uint8Array | Buffer
|
|
68
|
+
): Buffer | null {
|
|
69
|
+
try {
|
|
70
|
+
// Extract auth tag from end of ciphertext
|
|
71
|
+
const encrypted = ciphertext.slice(0, -AUTH_TAG_LENGTH);
|
|
72
|
+
const authTag = ciphertext.slice(-AUTH_TAG_LENGTH);
|
|
73
|
+
|
|
74
|
+
const decipher = createDecipheriv(ALGORITHM, key, nonce, {
|
|
75
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
decipher.setAuthTag(authTag);
|
|
79
|
+
|
|
80
|
+
const decrypted = Buffer.concat([
|
|
81
|
+
decipher.update(encrypted),
|
|
82
|
+
decipher.final(),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
return decrypted;
|
|
86
|
+
} catch {
|
|
87
|
+
// Authentication failed or other error
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Encrypt data and return a single buffer with nonce prepended
|
|
94
|
+
*
|
|
95
|
+
* Format: nonce (12 bytes) || ciphertext || authTag (16 bytes)
|
|
96
|
+
*/
|
|
97
|
+
export function seal(
|
|
98
|
+
data: Uint8Array | Buffer,
|
|
99
|
+
key: Uint8Array | Buffer
|
|
100
|
+
): Buffer {
|
|
101
|
+
const { nonce, ciphertext } = encrypt(data, key);
|
|
102
|
+
return Buffer.concat([nonce, ciphertext]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Decrypt data from a sealed buffer (nonce prepended)
|
|
107
|
+
*
|
|
108
|
+
* @param sealed - Buffer with format: nonce || ciphertext || authTag
|
|
109
|
+
* @param key - 256-bit key
|
|
110
|
+
* @returns Decrypted plaintext, or null if authentication failed
|
|
111
|
+
*/
|
|
112
|
+
export function open(
|
|
113
|
+
sealed: Uint8Array | Buffer,
|
|
114
|
+
key: Uint8Array | Buffer
|
|
115
|
+
): Buffer | null {
|
|
116
|
+
if (sealed.length < NONCE_LENGTH + AUTH_TAG_LENGTH) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const nonce = sealed.slice(0, NONCE_LENGTH);
|
|
121
|
+
const ciphertext = sealed.slice(NONCE_LENGTH);
|
|
122
|
+
|
|
123
|
+
return decrypt(ciphertext, nonce, key);
|
|
124
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-side X3DH handshake handler
|
|
3
|
+
*
|
|
4
|
+
* This class manages X3DH handshakes for multiple concurrent client connections.
|
|
5
|
+
* It processes incoming handshake messages, validates clients via access lists
|
|
6
|
+
* or invite tokens, and returns established sessions on success.
|
|
7
|
+
*
|
|
8
|
+
* The relay server forwards raw bytes between clients and machines - the handshake
|
|
9
|
+
* is peer-to-peer between the CLIENT and MACHINE.
|
|
10
|
+
*
|
|
11
|
+
* Message flow:
|
|
12
|
+
* 1. ClientHello → Machine creates state, returns ServerHello
|
|
13
|
+
* 2. ClientAuth → Machine validates auth, returns ServerAuth with accept/reject
|
|
14
|
+
* 3. On accept → Returns established session with keys
|
|
15
|
+
*
|
|
16
|
+
* @module handshake-handler
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createServerState,
|
|
21
|
+
processClientHello,
|
|
22
|
+
createServerHello,
|
|
23
|
+
processClientAuth,
|
|
24
|
+
createServerAuth,
|
|
25
|
+
type X3DHServerState,
|
|
26
|
+
} from "./crypto/handshake.js";
|
|
27
|
+
import { AccessControlList } from "./crypto/access-control.js";
|
|
28
|
+
import { parseInviteToken, isInviteExpired } from "./crypto/invites.js";
|
|
29
|
+
import type {
|
|
30
|
+
Identity,
|
|
31
|
+
SessionKeys,
|
|
32
|
+
AccessType,
|
|
33
|
+
X3DHInitMessage,
|
|
34
|
+
X3DHAuthMessage,
|
|
35
|
+
} from "../../types/identity.js";
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Types
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/** Configuration for HandshakeHandler */
|
|
42
|
+
export interface HandshakeHandlerConfig {
|
|
43
|
+
/** Machine's identity for authentication */
|
|
44
|
+
identity: Identity;
|
|
45
|
+
/** Access control list for authorized clients */
|
|
46
|
+
accessList: AccessControlList;
|
|
47
|
+
/**
|
|
48
|
+
* Optional custom invite validator
|
|
49
|
+
* Returns access type if valid, null if rejected
|
|
50
|
+
*/
|
|
51
|
+
validateInvite?: (token: string) => Promise<{ accessType: AccessType; sessionId?: string } | null>;
|
|
52
|
+
/** Handshake timeout in milliseconds (default: 30000) */
|
|
53
|
+
handshakeTimeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-connection handshake state */
|
|
57
|
+
interface HandshakeContext {
|
|
58
|
+
/** X3DH server state */
|
|
59
|
+
state: X3DHServerState;
|
|
60
|
+
/** When handshake started (for timeout) */
|
|
61
|
+
startedAt: number;
|
|
62
|
+
/** Timeout handle for cleanup */
|
|
63
|
+
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Handshake message envelope */
|
|
67
|
+
export interface HandshakeMessage {
|
|
68
|
+
type: "handshake";
|
|
69
|
+
phase: "client_hello" | "server_hello" | "client_auth" | "server_auth";
|
|
70
|
+
data: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Result of processing a handshake message */
|
|
74
|
+
export type ProcessResult =
|
|
75
|
+
| { type: "reply"; message: HandshakeMessage }
|
|
76
|
+
| { type: "established"; session: EstablishedSession; message: HandshakeMessage }
|
|
77
|
+
| { type: "error"; reason: string; close: boolean };
|
|
78
|
+
|
|
79
|
+
/** Established session after successful handshake */
|
|
80
|
+
export interface EstablishedSession {
|
|
81
|
+
/** Connection ID (maps to relay connection) */
|
|
82
|
+
connectionId: string;
|
|
83
|
+
/** Peer's identity ID */
|
|
84
|
+
peerIdentityId: string;
|
|
85
|
+
/** Granted access type */
|
|
86
|
+
accessType: AccessType;
|
|
87
|
+
/** Session ID for session-invite access */
|
|
88
|
+
sessionId?: string;
|
|
89
|
+
/** Derived session keys for encryption */
|
|
90
|
+
sessionKeys: SessionKeys;
|
|
91
|
+
/** When session was established (Unix ms) */
|
|
92
|
+
establishedAt: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// HandshakeHandler Class
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handles X3DH handshakes for multiple concurrent client connections
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const handler = new HandshakeHandler({
|
|
105
|
+
* identity: machineIdentity,
|
|
106
|
+
* accessList: acl,
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* // On receiving a handshake message from client
|
|
110
|
+
* const result = await handler.processMessage(connectionId, message);
|
|
111
|
+
* if (result.type === "reply") {
|
|
112
|
+
* relay.send(connectionId, result.message);
|
|
113
|
+
* } else if (result.type === "established") {
|
|
114
|
+
* sessions.set(connectionId, result.session);
|
|
115
|
+
* } else if (result.type === "error") {
|
|
116
|
+
* console.error(result.reason);
|
|
117
|
+
* if (result.close) relay.close(connectionId);
|
|
118
|
+
* }
|
|
119
|
+
*
|
|
120
|
+
* // On client disconnect
|
|
121
|
+
* handler.cleanup(connectionId);
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export class HandshakeHandler {
|
|
125
|
+
private config: HandshakeHandlerConfig;
|
|
126
|
+
private contexts: Map<string, HandshakeContext> = new Map();
|
|
127
|
+
private readonly defaultTimeoutMs = 30000;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a new HandshakeHandler
|
|
131
|
+
*
|
|
132
|
+
* @param config - Handler configuration
|
|
133
|
+
*/
|
|
134
|
+
constructor(config: HandshakeHandlerConfig) {
|
|
135
|
+
this.config = config;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Process an incoming handshake message from a client
|
|
140
|
+
*
|
|
141
|
+
* @param connectionId - Unique identifier for this connection
|
|
142
|
+
* @param message - Handshake message to process
|
|
143
|
+
* @returns Processing result (reply, established, or error)
|
|
144
|
+
*/
|
|
145
|
+
async processMessage(
|
|
146
|
+
connectionId: string,
|
|
147
|
+
message: HandshakeMessage
|
|
148
|
+
): Promise<ProcessResult> {
|
|
149
|
+
try {
|
|
150
|
+
switch (message.phase) {
|
|
151
|
+
case "client_hello":
|
|
152
|
+
return this.handleClientHello(connectionId, message.data as X3DHInitMessage);
|
|
153
|
+
|
|
154
|
+
case "client_auth":
|
|
155
|
+
return await this.handleClientAuth(connectionId, message.data as X3DHAuthMessage);
|
|
156
|
+
|
|
157
|
+
default:
|
|
158
|
+
return {
|
|
159
|
+
type: "error",
|
|
160
|
+
reason: `Unexpected handshake phase: ${message.phase}`,
|
|
161
|
+
close: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
type: "error",
|
|
167
|
+
reason: `Handshake error: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
+
close: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handle ClientHello message (phase 1)
|
|
175
|
+
*
|
|
176
|
+
* Creates fresh server state and returns ServerHello
|
|
177
|
+
*/
|
|
178
|
+
private handleClientHello(
|
|
179
|
+
connectionId: string,
|
|
180
|
+
clientHello: X3DHInitMessage
|
|
181
|
+
): ProcessResult {
|
|
182
|
+
// Clean up any existing state for this connection
|
|
183
|
+
this.cleanup(connectionId);
|
|
184
|
+
|
|
185
|
+
// Create fresh server state
|
|
186
|
+
const serverState = createServerState(this.config.identity);
|
|
187
|
+
|
|
188
|
+
// Process ClientHello
|
|
189
|
+
const newState = processClientHello(serverState, clientHello);
|
|
190
|
+
if (!newState) {
|
|
191
|
+
return {
|
|
192
|
+
type: "error",
|
|
193
|
+
reason: "Invalid ClientHello",
|
|
194
|
+
close: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Create ServerHello response
|
|
199
|
+
const { state: stateAfterHello, message: serverHello } = createServerHello(
|
|
200
|
+
newState,
|
|
201
|
+
this.config.identity
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Store context with timeout
|
|
205
|
+
const timeoutMs = this.config.handshakeTimeoutMs ?? this.defaultTimeoutMs;
|
|
206
|
+
const timeoutHandle = setTimeout(() => {
|
|
207
|
+
this.cleanup(connectionId);
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
|
|
210
|
+
this.contexts.set(connectionId, {
|
|
211
|
+
state: stateAfterHello,
|
|
212
|
+
startedAt: Date.now(),
|
|
213
|
+
timeoutHandle,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
type: "reply",
|
|
218
|
+
message: {
|
|
219
|
+
type: "handshake",
|
|
220
|
+
phase: "server_hello",
|
|
221
|
+
data: serverHello,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle ClientAuth message (phase 2)
|
|
228
|
+
*
|
|
229
|
+
* Validates client identity and authorization, returns ServerAuth
|
|
230
|
+
*/
|
|
231
|
+
private async handleClientAuth(
|
|
232
|
+
connectionId: string,
|
|
233
|
+
clientAuth: X3DHAuthMessage
|
|
234
|
+
): Promise<ProcessResult> {
|
|
235
|
+
// Get existing context
|
|
236
|
+
const context = this.contexts.get(connectionId);
|
|
237
|
+
if (!context) {
|
|
238
|
+
return {
|
|
239
|
+
type: "error",
|
|
240
|
+
reason: "No handshake in progress for this connection",
|
|
241
|
+
close: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Clear timeout (we're completing the handshake)
|
|
246
|
+
if (context.timeoutHandle) {
|
|
247
|
+
clearTimeout(context.timeoutHandle);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Process ClientAuth to get peer identity
|
|
251
|
+
const authResult = processClientAuth(
|
|
252
|
+
context.state,
|
|
253
|
+
clientAuth,
|
|
254
|
+
this.config.identity
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (!authResult) {
|
|
258
|
+
this.cleanup(connectionId);
|
|
259
|
+
return {
|
|
260
|
+
type: "error",
|
|
261
|
+
reason: "Invalid ClientAuth or identity proof",
|
|
262
|
+
close: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check authorization
|
|
267
|
+
const authCheck = await this.checkAuthorization(
|
|
268
|
+
authResult.peerIdentityId,
|
|
269
|
+
authResult.authorization,
|
|
270
|
+
authResult.clientIdentityKey,
|
|
271
|
+
authResult.clientKeyExchangeKey
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Create ServerAuth response
|
|
275
|
+
const { message: serverAuth, sessionKeys } = createServerAuth(
|
|
276
|
+
this.config.identity,
|
|
277
|
+
context.state,
|
|
278
|
+
authResult.clientIdentityKey,
|
|
279
|
+
authCheck
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Clean up handshake context
|
|
283
|
+
this.cleanup(connectionId);
|
|
284
|
+
|
|
285
|
+
// If rejected, send ServerAuth with rejection and close
|
|
286
|
+
if (authCheck.type === "rejected") {
|
|
287
|
+
return {
|
|
288
|
+
type: "reply",
|
|
289
|
+
message: {
|
|
290
|
+
type: "handshake",
|
|
291
|
+
phase: "server_auth",
|
|
292
|
+
data: serverAuth,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Success! Return established session with ServerAuth message
|
|
298
|
+
const session: EstablishedSession = {
|
|
299
|
+
connectionId,
|
|
300
|
+
peerIdentityId: authResult.peerIdentityId,
|
|
301
|
+
accessType: authCheck.accessType,
|
|
302
|
+
sessionId: authCheck.sessionId,
|
|
303
|
+
sessionKeys,
|
|
304
|
+
establishedAt: Date.now(),
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Return established session with ServerAuth message
|
|
308
|
+
// The caller should send the ServerAuth reply then handle the established session
|
|
309
|
+
return {
|
|
310
|
+
type: "established",
|
|
311
|
+
session,
|
|
312
|
+
message: {
|
|
313
|
+
type: "handshake",
|
|
314
|
+
phase: "server_auth",
|
|
315
|
+
data: serverAuth,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check client authorization via access list or invite token
|
|
322
|
+
*/
|
|
323
|
+
private async checkAuthorization(
|
|
324
|
+
peerIdentityId: string,
|
|
325
|
+
authorization: X3DHAuthMessage["authorization"],
|
|
326
|
+
clientIdentityKey: Uint8Array,
|
|
327
|
+
clientKeyExchangeKey: Uint8Array
|
|
328
|
+
): Promise<
|
|
329
|
+
| { type: "accepted"; accessType: AccessType; sessionId?: string }
|
|
330
|
+
| { type: "rejected"; reason: string }
|
|
331
|
+
> {
|
|
332
|
+
if (authorization.type === "access_list") {
|
|
333
|
+
// Check access list
|
|
334
|
+
const entry = this.config.accessList.getEntry(peerIdentityId);
|
|
335
|
+
if (!entry) {
|
|
336
|
+
return {
|
|
337
|
+
type: "rejected",
|
|
338
|
+
reason: "Not in access list",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
type: "accepted",
|
|
344
|
+
accessType: entry.accessType,
|
|
345
|
+
sessionId: entry.sessionId,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (authorization.type === "invite") {
|
|
350
|
+
// Validate invite token
|
|
351
|
+
const token = parseInviteToken(authorization.inviteToken);
|
|
352
|
+
|
|
353
|
+
if (!token) {
|
|
354
|
+
return {
|
|
355
|
+
type: "rejected",
|
|
356
|
+
reason: "Invalid invite token",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (isInviteExpired(token)) {
|
|
361
|
+
return {
|
|
362
|
+
type: "rejected",
|
|
363
|
+
reason: "Invite token expired",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Verify token was issued by this machine
|
|
368
|
+
if (token.machineId !== this.config.identity.id) {
|
|
369
|
+
return {
|
|
370
|
+
type: "rejected",
|
|
371
|
+
reason: "Invite token not issued by this machine",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Check custom validator if provided
|
|
376
|
+
if (this.config.validateInvite) {
|
|
377
|
+
const customResult = await this.config.validateInvite(
|
|
378
|
+
authorization.inviteToken
|
|
379
|
+
);
|
|
380
|
+
if (!customResult) {
|
|
381
|
+
return {
|
|
382
|
+
type: "rejected",
|
|
383
|
+
reason: "Invite rejected by custom validator",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
type: "accepted",
|
|
388
|
+
accessType: customResult.accessType,
|
|
389
|
+
sessionId: customResult.sessionId,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Use access type from token
|
|
394
|
+
// Security: Only add to permanent access list if NOT a single-use invite
|
|
395
|
+
// Single-use invites grant access for this session only
|
|
396
|
+
if (!token.singleUse) {
|
|
397
|
+
this.config.accessList.addEntry(
|
|
398
|
+
{
|
|
399
|
+
id: peerIdentityId,
|
|
400
|
+
signingPublicKey: Buffer.from(clientIdentityKey).toString("base64"),
|
|
401
|
+
keyExchangePublicKey: Buffer.from(clientKeyExchangeKey).toString("base64"),
|
|
402
|
+
},
|
|
403
|
+
token.accessType,
|
|
404
|
+
token.sessionId
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
type: "accepted",
|
|
410
|
+
accessType: token.accessType,
|
|
411
|
+
sessionId: token.sessionId,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
type: "rejected",
|
|
417
|
+
reason: "Unknown authorization type",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Clean up state for a disconnected client
|
|
423
|
+
*
|
|
424
|
+
* Call this when a client disconnects to free resources
|
|
425
|
+
*
|
|
426
|
+
* @param connectionId - Connection ID to clean up
|
|
427
|
+
*/
|
|
428
|
+
cleanup(connectionId: string): void {
|
|
429
|
+
const context = this.contexts.get(connectionId);
|
|
430
|
+
if (context) {
|
|
431
|
+
if (context.timeoutHandle) {
|
|
432
|
+
clearTimeout(context.timeoutHandle);
|
|
433
|
+
}
|
|
434
|
+
this.contexts.delete(connectionId);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get number of active handshakes
|
|
440
|
+
*/
|
|
441
|
+
get activeHandshakes(): number {
|
|
442
|
+
return this.contexts.size;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Check if a connection has an active handshake
|
|
447
|
+
*/
|
|
448
|
+
hasActiveHandshake(connectionId: string): boolean {
|
|
449
|
+
return this.contexts.has(connectionId);
|
|
450
|
+
}
|
|
451
|
+
}
|