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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access control list management for managing authorized identities
|
|
3
|
+
*
|
|
4
|
+
* This module provides an in-memory access control list that can be serialized
|
|
5
|
+
* to JSON for persistence. It manages which public keys are allowed to connect.
|
|
6
|
+
*
|
|
7
|
+
* Access types:
|
|
8
|
+
* - 'full': Complete machine access (browse, create sessions, etc.)
|
|
9
|
+
* - 'session-invite': View-only access to a specific session
|
|
10
|
+
*
|
|
11
|
+
* @module access-control
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AccessEntry,
|
|
16
|
+
AccessType,
|
|
17
|
+
PublicIdentity,
|
|
18
|
+
} from "../../../types/identity.js";
|
|
19
|
+
import { verify, deriveIdentityId } from "./identity.js";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Constants
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/** Default access type for new entries via `gssh access add` */
|
|
26
|
+
export const DEFAULT_ACCESS_TYPE: AccessType = 'full';
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Helper Functions
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an access entry is expired
|
|
34
|
+
*
|
|
35
|
+
* @param entry - Access entry to check
|
|
36
|
+
* @returns True if the entry has an expiry time and it has passed
|
|
37
|
+
*/
|
|
38
|
+
export function isAccessExpired(entry: AccessEntry): boolean {
|
|
39
|
+
if (!entry.expiresAt) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return Date.now() >= entry.expiresAt;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// AccessControlList Class
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Manages the access control list for authorized identities
|
|
51
|
+
*
|
|
52
|
+
* This class maintains an in-memory map of identity IDs to access entries,
|
|
53
|
+
* providing methods to add, remove, and check access. It also supports
|
|
54
|
+
* signature verification to authenticate incoming connections.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* const acl = new AccessControlList();
|
|
59
|
+
*
|
|
60
|
+
* // Add a new identity with full access
|
|
61
|
+
* const entry = acl.addEntry(publicIdentity);
|
|
62
|
+
*
|
|
63
|
+
* // Add a session invite (view-only)
|
|
64
|
+
* const invite = acl.addEntry(publicIdentity, 'session-invite', 'session-123');
|
|
65
|
+
*
|
|
66
|
+
* // Check access
|
|
67
|
+
* if (acl.hasAccess(identityId)) {
|
|
68
|
+
* console.log('Access granted');
|
|
69
|
+
* }
|
|
70
|
+
*
|
|
71
|
+
* // Verify signature and check access
|
|
72
|
+
* const entry = acl.verifyAndCheckAccess(message, signature, publicKey);
|
|
73
|
+
* if (entry) {
|
|
74
|
+
* console.log('Authenticated with access type:', entry.accessType);
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export class AccessControlList {
|
|
79
|
+
private entries: Map<string, AccessEntry> = new Map();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Add an identity to the access list
|
|
83
|
+
*
|
|
84
|
+
* Creates a new access entry with the given access type.
|
|
85
|
+
* If the identity already exists, it will be replaced.
|
|
86
|
+
*
|
|
87
|
+
* @param publicIdentity - Public identity information to add
|
|
88
|
+
* @param accessType - Access type to grant (default: 'full')
|
|
89
|
+
* @param sessionId - For session-invite: the specific session ID
|
|
90
|
+
* @returns The created access entry
|
|
91
|
+
*/
|
|
92
|
+
addEntry(
|
|
93
|
+
publicIdentity: PublicIdentity,
|
|
94
|
+
accessType: AccessType = DEFAULT_ACCESS_TYPE,
|
|
95
|
+
sessionId?: string
|
|
96
|
+
): AccessEntry {
|
|
97
|
+
const entry: AccessEntry = {
|
|
98
|
+
identityId: publicIdentity.id,
|
|
99
|
+
signingPublicKey: publicIdentity.signingPublicKey,
|
|
100
|
+
keyExchangePublicKey: publicIdentity.keyExchangePublicKey,
|
|
101
|
+
label: publicIdentity.label,
|
|
102
|
+
grantedAt: Date.now(),
|
|
103
|
+
accessType,
|
|
104
|
+
sessionId,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
this.entries.set(publicIdentity.id, entry);
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove an identity from the access list
|
|
113
|
+
*
|
|
114
|
+
* @param identityId - Identity ID to remove
|
|
115
|
+
* @returns True if the entry was removed, false if it didn't exist
|
|
116
|
+
*/
|
|
117
|
+
removeEntry(identityId: string): boolean {
|
|
118
|
+
return this.entries.delete(identityId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if an identity has access
|
|
123
|
+
*
|
|
124
|
+
* Checks if the identity exists in the access list and is not expired.
|
|
125
|
+
*
|
|
126
|
+
* @param identityId - Identity ID to check
|
|
127
|
+
* @returns True if the identity has access and is not expired
|
|
128
|
+
*/
|
|
129
|
+
hasAccess(identityId: string): boolean {
|
|
130
|
+
const entry = this.entries.get(identityId);
|
|
131
|
+
if (!entry) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return !isAccessExpired(entry);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if an identity has full access
|
|
139
|
+
*
|
|
140
|
+
* @param identityId - Identity ID to check
|
|
141
|
+
* @returns True if the identity has full access and is not expired
|
|
142
|
+
*/
|
|
143
|
+
hasFullAccess(identityId: string): boolean {
|
|
144
|
+
const entry = this.entries.get(identityId);
|
|
145
|
+
if (!entry || isAccessExpired(entry)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return entry.accessType === 'full';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if an identity has access to a specific session
|
|
153
|
+
*
|
|
154
|
+
* @param identityId - Identity ID to check
|
|
155
|
+
* @param sessionId - Session ID to check access for
|
|
156
|
+
* @returns True if the identity has access (full or session-invite for this session)
|
|
157
|
+
*/
|
|
158
|
+
hasSessionAccess(identityId: string, sessionId: string): boolean {
|
|
159
|
+
const entry = this.entries.get(identityId);
|
|
160
|
+
if (!entry || isAccessExpired(entry)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
// Full access can access any session
|
|
164
|
+
if (entry.accessType === 'full') {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
// Session invite can only access the specific session
|
|
168
|
+
return entry.accessType === 'session-invite' && entry.sessionId === sessionId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get access entry by identity ID
|
|
173
|
+
*
|
|
174
|
+
* @param identityId - Identity ID to look up
|
|
175
|
+
* @returns Access entry if found and not expired, undefined otherwise
|
|
176
|
+
*/
|
|
177
|
+
getEntry(identityId: string): AccessEntry | undefined {
|
|
178
|
+
const entry = this.entries.get(identityId);
|
|
179
|
+
if (!entry || isAccessExpired(entry)) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
return entry;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get all entries
|
|
187
|
+
*
|
|
188
|
+
* Returns all access entries, including expired ones. Use isAccessExpired()
|
|
189
|
+
* to filter out expired entries if needed.
|
|
190
|
+
*
|
|
191
|
+
* @returns Array of all access entries
|
|
192
|
+
*/
|
|
193
|
+
getAllEntries(): AccessEntry[] {
|
|
194
|
+
return Array.from(this.entries.values());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Update access type for an identity
|
|
199
|
+
*
|
|
200
|
+
* @param identityId - Identity ID to update
|
|
201
|
+
* @param accessType - New access type
|
|
202
|
+
* @param sessionId - For session-invite: the specific session ID
|
|
203
|
+
* @returns True if the entry was updated, false if it doesn't exist
|
|
204
|
+
*/
|
|
205
|
+
updateAccessType(
|
|
206
|
+
identityId: string,
|
|
207
|
+
accessType: AccessType,
|
|
208
|
+
sessionId?: string
|
|
209
|
+
): boolean {
|
|
210
|
+
const entry = this.entries.get(identityId);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
entry.accessType = accessType;
|
|
216
|
+
entry.sessionId = sessionId;
|
|
217
|
+
|
|
218
|
+
this.entries.set(identityId, entry);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Update label for an identity
|
|
224
|
+
*
|
|
225
|
+
* @param identityId - Identity ID to update
|
|
226
|
+
* @param label - New label
|
|
227
|
+
* @returns True if the entry was updated, false if it doesn't exist
|
|
228
|
+
*/
|
|
229
|
+
updateLabel(identityId: string, label: string): boolean {
|
|
230
|
+
const entry = this.entries.get(identityId);
|
|
231
|
+
if (!entry) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
entry.label = label;
|
|
236
|
+
this.entries.set(identityId, entry);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Verify a signed message and check access
|
|
242
|
+
*
|
|
243
|
+
* This method:
|
|
244
|
+
* 1. Derives the identity ID from the signing public key
|
|
245
|
+
* 2. Verifies the signature is valid
|
|
246
|
+
* 3. Checks if the identity has access
|
|
247
|
+
* 4. Returns the access entry if all checks pass
|
|
248
|
+
*
|
|
249
|
+
* @param message - Message that was signed
|
|
250
|
+
* @param signature - Signature to verify (64 bytes)
|
|
251
|
+
* @param signingPublicKey - Ed25519 public key (32 bytes)
|
|
252
|
+
* @returns AccessEntry if signature is valid and identity has access, null otherwise
|
|
253
|
+
*/
|
|
254
|
+
verifyAndCheckAccess(
|
|
255
|
+
message: Uint8Array,
|
|
256
|
+
signature: Uint8Array,
|
|
257
|
+
signingPublicKey: Uint8Array
|
|
258
|
+
): AccessEntry | null {
|
|
259
|
+
// Verify signature
|
|
260
|
+
if (!verify(message, signature, signingPublicKey)) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Derive identity ID from public key
|
|
265
|
+
const identityId = deriveIdentityId(signingPublicKey);
|
|
266
|
+
|
|
267
|
+
// Check access
|
|
268
|
+
const entry = this.getEntry(identityId);
|
|
269
|
+
if (!entry) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return entry;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Export access list for storage
|
|
278
|
+
*
|
|
279
|
+
* Returns all entries (including expired ones) as a JSON-serializable array.
|
|
280
|
+
* This can be used to persist the access list to disk.
|
|
281
|
+
*
|
|
282
|
+
* @returns Array of access entries
|
|
283
|
+
*/
|
|
284
|
+
export(): AccessEntry[] {
|
|
285
|
+
return this.getAllEntries();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Import access list from storage
|
|
290
|
+
*
|
|
291
|
+
* Replaces the current access list with the provided entries.
|
|
292
|
+
* This will clear any existing entries.
|
|
293
|
+
*
|
|
294
|
+
* @param entries - Array of access entries to import
|
|
295
|
+
*/
|
|
296
|
+
import(entries: AccessEntry[]): void {
|
|
297
|
+
this.entries.clear();
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
this.entries.set(entry.identityId, entry);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Clear all entries
|
|
305
|
+
*
|
|
306
|
+
* Removes all access entries from the list.
|
|
307
|
+
*/
|
|
308
|
+
clear(): void {
|
|
309
|
+
this.entries.clear();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get count of entries
|
|
314
|
+
*
|
|
315
|
+
* @returns Number of entries in the access list (including expired ones)
|
|
316
|
+
*/
|
|
317
|
+
get size(): number {
|
|
318
|
+
return this.entries.size;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
encodeFrame,
|
|
4
|
+
decodeFrame,
|
|
5
|
+
peekStreamId,
|
|
6
|
+
createFrame,
|
|
7
|
+
openFrame,
|
|
8
|
+
MASTER_STREAM_ID,
|
|
9
|
+
STREAM_ID_LENGTH,
|
|
10
|
+
MIN_FRAME_LENGTH,
|
|
11
|
+
type EncryptedFrame,
|
|
12
|
+
} from "./frames";
|
|
13
|
+
import { NONCE_LENGTH, AUTH_TAG_LENGTH, generateNonce } from "./secretbox";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
const testKey = randomBytes(32);
|
|
17
|
+
|
|
18
|
+
describe("constants", () => {
|
|
19
|
+
test("MASTER_STREAM_ID is 0", () => {
|
|
20
|
+
expect(MASTER_STREAM_ID).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("STREAM_ID_LENGTH is 4", () => {
|
|
24
|
+
expect(STREAM_ID_LENGTH).toBe(4);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("MIN_FRAME_LENGTH is correct", () => {
|
|
28
|
+
expect(MIN_FRAME_LENGTH).toBe(STREAM_ID_LENGTH + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("encodeFrame/decodeFrame", () => {
|
|
33
|
+
test("encodes and decodes frame", () => {
|
|
34
|
+
const frame: EncryptedFrame = {
|
|
35
|
+
streamId: 42,
|
|
36
|
+
nonce: generateNonce(),
|
|
37
|
+
ciphertext: Buffer.from("encrypted-data-here"),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const encoded = encodeFrame(frame);
|
|
41
|
+
const decoded = decodeFrame(encoded);
|
|
42
|
+
|
|
43
|
+
expect(decoded).not.toBeNull();
|
|
44
|
+
expect(decoded!.streamId).toBe(42);
|
|
45
|
+
expect(decoded!.nonce.equals(frame.nonce)).toBe(true);
|
|
46
|
+
expect(decoded!.ciphertext.equals(frame.ciphertext)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("encodes streamId as big-endian uint32", () => {
|
|
50
|
+
const frame: EncryptedFrame = {
|
|
51
|
+
streamId: 0x12345678,
|
|
52
|
+
nonce: generateNonce(),
|
|
53
|
+
ciphertext: randomBytes(32),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const encoded = encodeFrame(frame);
|
|
57
|
+
|
|
58
|
+
expect(encoded[0]).toBe(0x12);
|
|
59
|
+
expect(encoded[1]).toBe(0x34);
|
|
60
|
+
expect(encoded[2]).toBe(0x56);
|
|
61
|
+
expect(encoded[3]).toBe(0x78);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("encodes master stream (0)", () => {
|
|
65
|
+
const frame: EncryptedFrame = {
|
|
66
|
+
streamId: MASTER_STREAM_ID,
|
|
67
|
+
nonce: generateNonce(),
|
|
68
|
+
ciphertext: randomBytes(32), // Must be >= AUTH_TAG_LENGTH
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const encoded = encodeFrame(frame);
|
|
72
|
+
const decoded = decodeFrame(encoded);
|
|
73
|
+
|
|
74
|
+
expect(decoded!.streamId).toBe(MASTER_STREAM_ID);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("encodes large streamId", () => {
|
|
78
|
+
const frame: EncryptedFrame = {
|
|
79
|
+
streamId: 0xffffffff,
|
|
80
|
+
nonce: generateNonce(),
|
|
81
|
+
ciphertext: randomBytes(32), // Must be >= AUTH_TAG_LENGTH
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const encoded = encodeFrame(frame);
|
|
85
|
+
const decoded = decodeFrame(encoded);
|
|
86
|
+
|
|
87
|
+
expect(decoded!.streamId).toBe(0xffffffff);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("decodeFrame returns null for too-short buffer", () => {
|
|
91
|
+
const tooShort = Buffer.alloc(MIN_FRAME_LENGTH - 1);
|
|
92
|
+
const decoded = decodeFrame(tooShort);
|
|
93
|
+
expect(decoded).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("decodeFrame accepts Uint8Array", () => {
|
|
97
|
+
const frame: EncryptedFrame = {
|
|
98
|
+
streamId: 1,
|
|
99
|
+
nonce: generateNonce(),
|
|
100
|
+
ciphertext: randomBytes(32), // Must be >= AUTH_TAG_LENGTH
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const encoded = new Uint8Array(encodeFrame(frame));
|
|
104
|
+
const decoded = decodeFrame(encoded);
|
|
105
|
+
|
|
106
|
+
expect(decoded).not.toBeNull();
|
|
107
|
+
expect(decoded!.streamId).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("handles empty ciphertext", () => {
|
|
111
|
+
const frame: EncryptedFrame = {
|
|
112
|
+
streamId: 1,
|
|
113
|
+
nonce: generateNonce(),
|
|
114
|
+
ciphertext: Buffer.alloc(AUTH_TAG_LENGTH), // Just auth tag, no payload
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const encoded = encodeFrame(frame);
|
|
118
|
+
const decoded = decodeFrame(encoded);
|
|
119
|
+
|
|
120
|
+
expect(decoded).not.toBeNull();
|
|
121
|
+
expect(decoded!.ciphertext.length).toBe(AUTH_TAG_LENGTH);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("peekStreamId", () => {
|
|
126
|
+
test("extracts streamId without full decode", () => {
|
|
127
|
+
const frame: EncryptedFrame = {
|
|
128
|
+
streamId: 123,
|
|
129
|
+
nonce: generateNonce(),
|
|
130
|
+
ciphertext: randomBytes(32),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const encoded = encodeFrame(frame);
|
|
134
|
+
const streamId = peekStreamId(encoded);
|
|
135
|
+
|
|
136
|
+
expect(streamId).toBe(123);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("returns null for too-short buffer", () => {
|
|
140
|
+
const tooShort = Buffer.alloc(STREAM_ID_LENGTH - 1);
|
|
141
|
+
const streamId = peekStreamId(tooShort);
|
|
142
|
+
expect(streamId).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("works with exactly STREAM_ID_LENGTH bytes", () => {
|
|
146
|
+
const buf = Buffer.alloc(STREAM_ID_LENGTH);
|
|
147
|
+
buf.writeUInt32BE(999, 0);
|
|
148
|
+
|
|
149
|
+
const streamId = peekStreamId(buf);
|
|
150
|
+
expect(streamId).toBe(999);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("accepts Uint8Array", () => {
|
|
154
|
+
const frame: EncryptedFrame = {
|
|
155
|
+
streamId: 456,
|
|
156
|
+
nonce: generateNonce(),
|
|
157
|
+
ciphertext: randomBytes(32),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const encoded = new Uint8Array(encodeFrame(frame));
|
|
161
|
+
const streamId = peekStreamId(encoded);
|
|
162
|
+
|
|
163
|
+
expect(streamId).toBe(456);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("createFrame/openFrame", () => {
|
|
168
|
+
test("creates and opens frame", () => {
|
|
169
|
+
const plaintext = Buffer.from("Hello, World!");
|
|
170
|
+
const frame = createFrame(MASTER_STREAM_ID, plaintext, testKey);
|
|
171
|
+
|
|
172
|
+
const result = openFrame(frame, testKey);
|
|
173
|
+
|
|
174
|
+
expect(result).not.toBeNull();
|
|
175
|
+
expect(result!.streamId).toBe(MASTER_STREAM_ID);
|
|
176
|
+
expect(result!.data.equals(plaintext)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("creates frame with custom streamId", () => {
|
|
180
|
+
const plaintext = Buffer.from("Session data");
|
|
181
|
+
const streamId = 42;
|
|
182
|
+
const frame = createFrame(streamId, plaintext, testKey);
|
|
183
|
+
|
|
184
|
+
const result = openFrame(frame, testKey);
|
|
185
|
+
|
|
186
|
+
expect(result).not.toBeNull();
|
|
187
|
+
expect(result!.streamId).toBe(streamId);
|
|
188
|
+
expect(result!.data.equals(plaintext)).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("openFrame fails with wrong key", () => {
|
|
192
|
+
const plaintext = Buffer.from("Secret message");
|
|
193
|
+
const frame = createFrame(MASTER_STREAM_ID, plaintext, testKey);
|
|
194
|
+
|
|
195
|
+
const wrongKey = randomBytes(32);
|
|
196
|
+
const result = openFrame(frame, wrongKey);
|
|
197
|
+
|
|
198
|
+
expect(result).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("openFrame fails with tampered frame", () => {
|
|
202
|
+
const plaintext = Buffer.from("Secret message");
|
|
203
|
+
const frame = createFrame(MASTER_STREAM_ID, plaintext, testKey);
|
|
204
|
+
|
|
205
|
+
// Tamper with encrypted data
|
|
206
|
+
frame[STREAM_ID_LENGTH + NONCE_LENGTH + 5] ^= 0xff;
|
|
207
|
+
const result = openFrame(frame, testKey);
|
|
208
|
+
|
|
209
|
+
expect(result).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("openFrame returns null for too-short frame", () => {
|
|
213
|
+
const tooShort = Buffer.alloc(MIN_FRAME_LENGTH - 1);
|
|
214
|
+
const result = openFrame(tooShort, testKey);
|
|
215
|
+
expect(result).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("handles empty plaintext", () => {
|
|
219
|
+
const plaintext = Buffer.from("");
|
|
220
|
+
const frame = createFrame(MASTER_STREAM_ID, plaintext, testKey);
|
|
221
|
+
|
|
222
|
+
const result = openFrame(frame, testKey);
|
|
223
|
+
|
|
224
|
+
expect(result).not.toBeNull();
|
|
225
|
+
expect(result!.data.length).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("handles large plaintext", () => {
|
|
229
|
+
const plaintext = randomBytes(64 * 1024); // 64KB
|
|
230
|
+
const frame = createFrame(MASTER_STREAM_ID, plaintext, testKey);
|
|
231
|
+
|
|
232
|
+
const result = openFrame(frame, testKey);
|
|
233
|
+
|
|
234
|
+
expect(result).not.toBeNull();
|
|
235
|
+
expect(result!.data.equals(plaintext)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("accepts Uint8Array inputs", () => {
|
|
239
|
+
const plaintext = new Uint8Array([1, 2, 3, 4, 5]);
|
|
240
|
+
const key = new Uint8Array(testKey);
|
|
241
|
+
const frame = createFrame(1, plaintext, key);
|
|
242
|
+
|
|
243
|
+
const result = openFrame(frame, key);
|
|
244
|
+
|
|
245
|
+
expect(result).not.toBeNull();
|
|
246
|
+
expect(Buffer.from(result!.data).equals(Buffer.from(plaintext))).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("streamId can be peeked before decryption", () => {
|
|
250
|
+
const plaintext = Buffer.from("Secret");
|
|
251
|
+
const streamId = 789;
|
|
252
|
+
const frame = createFrame(streamId, plaintext, testKey);
|
|
253
|
+
|
|
254
|
+
// Can peek without decrypting
|
|
255
|
+
const peeked = peekStreamId(frame);
|
|
256
|
+
expect(peeked).toBe(streamId);
|
|
257
|
+
|
|
258
|
+
// Can still decrypt after peeking
|
|
259
|
+
const result = openFrame(frame, testKey);
|
|
260
|
+
expect(result).not.toBeNull();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted frame encoding for E2E communication
|
|
3
|
+
*
|
|
4
|
+
* Frame format:
|
|
5
|
+
* ┌────────────┬────────────┬──────────────────────────────────────────────┐
|
|
6
|
+
* │ streamId │ nonce │ encrypted payload + authTag (16 bytes) │
|
|
7
|
+
* │ 4 bytes │ 12 bytes │ variable length │
|
|
8
|
+
* └────────────┴────────────┴──────────────────────────────────────────────┘
|
|
9
|
+
*
|
|
10
|
+
* Stream IDs:
|
|
11
|
+
* - 0: Master stream (full machine access)
|
|
12
|
+
* - 1+: Session share streams (per-terminal access)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { NONCE_LENGTH, AUTH_TAG_LENGTH, encrypt, decrypt } from "./secretbox";
|
|
16
|
+
|
|
17
|
+
/** Stream ID for master access (full machine) */
|
|
18
|
+
export const MASTER_STREAM_ID = 0;
|
|
19
|
+
|
|
20
|
+
/** Length of stream ID field in bytes */
|
|
21
|
+
export const STREAM_ID_LENGTH = 4;
|
|
22
|
+
|
|
23
|
+
/** Minimum frame length (streamId + nonce + authTag, no payload) */
|
|
24
|
+
export const MIN_FRAME_LENGTH = STREAM_ID_LENGTH + NONCE_LENGTH + AUTH_TAG_LENGTH;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encrypted frame structure
|
|
28
|
+
*/
|
|
29
|
+
export interface EncryptedFrame {
|
|
30
|
+
/** Stream ID (0 = master, 1+ = session shares) */
|
|
31
|
+
streamId: number;
|
|
32
|
+
/** Nonce used for encryption (12 bytes) */
|
|
33
|
+
nonce: Buffer;
|
|
34
|
+
/** Encrypted payload with auth tag appended */
|
|
35
|
+
ciphertext: Buffer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encode an encrypted frame to a buffer
|
|
40
|
+
*
|
|
41
|
+
* @param frame - The encrypted frame to encode
|
|
42
|
+
* @returns Buffer ready to send over the wire
|
|
43
|
+
*/
|
|
44
|
+
export function encodeFrame(frame: EncryptedFrame): Buffer {
|
|
45
|
+
const buf = Buffer.alloc(
|
|
46
|
+
STREAM_ID_LENGTH + NONCE_LENGTH + frame.ciphertext.length
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Write stream ID (big-endian)
|
|
50
|
+
buf.writeUInt32BE(frame.streamId, 0);
|
|
51
|
+
|
|
52
|
+
// Copy nonce
|
|
53
|
+
frame.nonce.copy(buf, STREAM_ID_LENGTH);
|
|
54
|
+
|
|
55
|
+
// Copy ciphertext
|
|
56
|
+
frame.ciphertext.copy(buf, STREAM_ID_LENGTH + NONCE_LENGTH);
|
|
57
|
+
|
|
58
|
+
return buf;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decode an encrypted frame from a buffer
|
|
63
|
+
*
|
|
64
|
+
* @param data - Raw buffer from the wire
|
|
65
|
+
* @returns Decoded frame, or null if too short
|
|
66
|
+
*/
|
|
67
|
+
export function decodeFrame(data: Buffer | Uint8Array): EncryptedFrame | null {
|
|
68
|
+
if (data.length < MIN_FRAME_LENGTH) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const buf = Buffer.from(data);
|
|
73
|
+
|
|
74
|
+
const streamId = buf.readUInt32BE(0);
|
|
75
|
+
const nonce = buf.slice(STREAM_ID_LENGTH, STREAM_ID_LENGTH + NONCE_LENGTH);
|
|
76
|
+
const ciphertext = buf.slice(STREAM_ID_LENGTH + NONCE_LENGTH);
|
|
77
|
+
|
|
78
|
+
return { streamId, nonce, ciphertext };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Peek at the stream ID without decoding the full frame
|
|
83
|
+
*
|
|
84
|
+
* Useful for routing frames to the right decryption key.
|
|
85
|
+
*
|
|
86
|
+
* @param data - Raw buffer from the wire
|
|
87
|
+
* @returns Stream ID, or null if buffer too short
|
|
88
|
+
*/
|
|
89
|
+
export function peekStreamId(data: Buffer | Uint8Array): number | null {
|
|
90
|
+
if (data.length < STREAM_ID_LENGTH) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Buffer.from(data).readUInt32BE(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create an encrypted frame from plaintext data
|
|
99
|
+
*
|
|
100
|
+
* @param streamId - Stream ID (0 = master, 1+ = session)
|
|
101
|
+
* @param data - Plaintext data to encrypt
|
|
102
|
+
* @param key - Encryption key (from deriveKey)
|
|
103
|
+
* @returns Encoded frame buffer ready to send
|
|
104
|
+
*/
|
|
105
|
+
export function createFrame(
|
|
106
|
+
streamId: number,
|
|
107
|
+
data: Uint8Array | Buffer,
|
|
108
|
+
key: Uint8Array | Buffer
|
|
109
|
+
): Buffer {
|
|
110
|
+
const { nonce, ciphertext } = encrypt(data, key);
|
|
111
|
+
|
|
112
|
+
return encodeFrame({
|
|
113
|
+
streamId,
|
|
114
|
+
nonce,
|
|
115
|
+
ciphertext,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Decrypt a frame and return the plaintext
|
|
121
|
+
*
|
|
122
|
+
* @param frame - Encoded frame buffer from the wire
|
|
123
|
+
* @param key - Decryption key (from deriveKey)
|
|
124
|
+
* @returns Decrypted plaintext, or null if decryption failed
|
|
125
|
+
*/
|
|
126
|
+
export function openFrame(
|
|
127
|
+
frame: Buffer | Uint8Array,
|
|
128
|
+
key: Uint8Array | Buffer
|
|
129
|
+
): { streamId: number; data: Buffer } | null {
|
|
130
|
+
const decoded = decodeFrame(frame);
|
|
131
|
+
if (!decoded) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const data = decrypt(decoded.ciphertext, decoded.nonce, key);
|
|
136
|
+
if (!data) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { streamId: decoded.streamId, data };
|
|
141
|
+
}
|