From 7268e38153d12edd9dac9b5cb44f8b4289fbeda9 Mon Sep 17 00:00:00 2001 From: Andre Shmelkov Date: Mon, 7 Sep 2026 12:50:41 -0300 Subject: [PATCH] feat!: integrate reliable network runtime and public site --- .env.example | 22 +- .github/workflows/ci.yml | 12 + .gitignore | 3 + .gitmodules | 3 + Dockerfile | 2 +- LICENSE | 21 + README.md | 26 +- docs/API.md | 126 +- docs/ARCHITECTURE.md | 19 +- docs/DASHBOARD.md | 58 + docs/DM_BOOTSTRAP.md | 71 + docs/MCP.md | 169 +- docs/PROTOCOL-V2-MIGRATION.md | 61 + docs/PROTOCOL.md | 320 +- docs/SECURITY.md | 133 +- docs/TTYA.md | 180 +- docs/deployment.md | 13 + docs/design/IMPLEMENTATION.md | 67 + docs/releases/2026-09-07-integration.md | 52 + docs/releases/2026-09-07-pr-body.md | 11 + docs/reviews/2026-09-05-product-review.md | 117 + docs/reviews/2026-09-06-completion.md | 81 + docs/terminology.md | 6 + package.json | 9 +- packages/cli/LICENSE | 21 + packages/cli/README.md | 146 +- packages/cli/package.json | 21 +- packages/cli/src/__tests__/chat.test.tsx | 83 + .../src/__tests__/startup-security.test.ts | 38 + .../cli/src/__tests__/ttya-secret.test.ts | 39 + packages/cli/src/agent-options.ts | 56 + packages/cli/src/bin.ts | 73 +- packages/cli/src/commands/chat.ts | 32 +- packages/cli/src/commands/groups.ts | 26 +- packages/cli/src/commands/init.ts | 17 +- packages/cli/src/commands/peers.ts | 14 +- packages/cli/src/commands/status.ts | 14 +- packages/cli/src/commands/ttya.ts | 50 +- packages/cli/src/components/ChatView.tsx | 16 +- packages/cli/src/components/TTYAView.tsx | 56 +- packages/cli/src/ttya-secret.ts | 63 + packages/core/LICENSE | 21 + packages/core/README.md | 88 +- packages/core/package.json | 19 +- packages/core/src/__tests__/crypto.test.ts | 30 +- .../core/src/__tests__/double-ratchet.test.ts | 30 + .../core/src/__tests__/epoch-envelope.test.ts | 68 + .../core/src/__tests__/group-state.test.ts | 308 + packages/core/src/__tests__/identity.test.ts | 7 + packages/core/src/__tests__/messages.test.ts | 35 +- .../src/__tests__/network-announce.test.ts | 188 +- .../src/__tests__/protocol-validation.test.ts | 381 + .../src/__tests__/reliable-delivery.test.ts | 74 + .../src/__tests__/sender-key-envelope.test.ts | 36 + .../core/src/__tests__/sender-keys.test.ts | 137 +- packages/core/src/__tests__/ttya-auth.test.ts | 128 + packages/core/src/crypto/index.ts | 2 +- packages/core/src/crypto/kdf.ts | 15 +- packages/core/src/identity.ts | 20 +- packages/core/src/index.ts | 8 +- .../core/src/protocol/announce-signature.ts | 258 + packages/core/src/protocol/double-ratchet.ts | 4 + packages/core/src/protocol/epoch-envelope.ts | 127 + packages/core/src/protocol/group-state.ts | 269 + packages/core/src/protocol/index.ts | 89 +- packages/core/src/protocol/message-auth.ts | 119 + packages/core/src/protocol/messages.ts | 608 +- .../core/src/protocol/reliable-delivery.ts | 49 + .../core/src/protocol/sender-key-envelope.ts | 66 + packages/core/src/protocol/sender-keys.ts | 264 +- packages/core/src/protocol/ttya-auth.ts | 329 + packages/core/src/protocol/types.ts | 57 +- packages/dashboard/index.html | 10 +- packages/dashboard/package.json | 12 +- packages/dashboard/public/network-mark.svg | 1 + packages/dashboard/publication.example.json | 6 + packages/dashboard/src/client/App.tsx | 411 +- .../src/client/__tests__/actions.test.tsx | 145 + .../src/client/__tests__/usePolling.test.ts | 85 + .../src/client/__tests__/useRoute.test.ts | 85 + .../client/assets/fonts/anybody-LICENSE.txt | 93 + .../src/client/assets/fonts/anybody.woff2 | Bin 0 -> 24048 bytes .../fonts/bricolage-grotesque-LICENSE.txt | 93 + .../assets/fonts/bricolage-grotesque.woff2 | Bin 0 -> 41344 bytes .../src/client/assets/fonts/inter-LICENSE.txt | 93 + .../src/client/assets/fonts/inter.woff2 | Bin 0 -> 48256 bytes .../assets/fonts/jetbrains-mono-LICENSE.txt | 93 + .../client/assets/fonts/jetbrains-mono.woff2 | Bin 0 -> 40404 bytes .../src/client/components/CopyButton.tsx | 56 + .../src/client/components/PeerList.tsx | 4 +- .../src/client/components/StateList.tsx | 38 +- .../src/client/components/StatePage.tsx | 37 +- .../dashboard/src/client/components/Toast.tsx | 5 +- packages/dashboard/src/client/env.d.ts | 5 + packages/dashboard/src/client/fonts.css | 29 + .../dashboard/src/client/hooks/usePolling.ts | 46 +- .../dashboard/src/client/hooks/useRoute.ts | 53 +- packages/dashboard/src/client/main.tsx | 1 + .../src/client/public/DiscoverPage.test.tsx | 114 + .../src/client/public/DiscoverPage.tsx | 762 + .../src/client/public/HomeArtwork.tsx | 1101 + .../src/client/public/HomeNetwork.tsx | 519 + .../src/client/public/HomePage.test.tsx | 124 + .../dashboard/src/client/public/HomePage.tsx | 495 + .../src/client/public/ObservationControls.tsx | 146 + .../src/client/public/PersonalBridge.tsx | 97 + .../dashboard/src/client/public/PublicApp.tsx | 169 + .../src/client/public/SelfMdPage.test.tsx | 99 + .../src/client/public/SelfMdPage.tsx | 702 + .../dashboard/src/client/public/config.ts | 38 + .../dashboard/src/client/public/directory.css | 70 + packages/dashboard/src/client/public/file.css | 669 + packages/dashboard/src/client/public/home.css | 112 + .../dashboard/src/client/public/homeMotion.ts | 353 + .../dashboard/src/client/public/motion.ts | 20 + .../src/client/public/observation.test.ts | 321 + .../src/client/public/observation.ts | 311 + .../dashboard/src/client/public/public.css | 2912 +++ .../src/client/public/refinements.css | 232 + packages/dashboard/src/client/style.css | 342 +- packages/dashboard/src/client/types.ts | 6 +- .../src/server/__tests__/public-route.test.ts | 137 + .../server/__tests__/publicNetwork.test.ts | 165 + .../src/server/__tests__/routes.test.ts | 238 +- .../server/__tests__/startup-security.test.ts | 115 + packages/dashboard/src/server/config.ts | 104 + packages/dashboard/src/server/index.ts | 46 +- .../dashboard/src/server/publicNetwork.ts | 190 + packages/dashboard/src/server/routes.ts | 132 +- packages/dashboard/src/server/static.ts | 20 + packages/dashboard/src/server/types.ts | 6 +- packages/docs | 1 + packages/mcp/LICENSE | 21 + packages/mcp/README.md | 89 +- packages/mcp/package.json | 21 +- packages/mcp/src/__tests__/messaging.test.ts | 151 + .../src/__tests__/startup-security.test.ts | 39 + packages/mcp/src/bin.ts | 7 +- packages/mcp/src/config.ts | 21 + packages/mcp/src/resources.ts | 2 +- packages/mcp/src/server.ts | 2 - packages/mcp/src/tools/discovery.ts | 6 +- packages/mcp/src/tools/groups.ts | 48 +- packages/mcp/src/tools/identity.ts | 13 +- packages/mcp/src/tools/messaging.ts | 35 +- packages/node/LICENSE | 21 + packages/node/README.md | 130 +- packages/node/package.json | 31 +- packages/node/src/__tests__/agent.test.ts | 418 +- packages/node/src/__tests__/delivery.test.ts | 275 + .../direct-message-collision.test.ts | 263 + .../node/src/__tests__/discovery-e2e.test.ts | 77 +- .../src/__tests__/discovery-storage.test.ts | 92 +- packages/node/src/__tests__/e2e.test.ts | 84 +- .../__tests__/group-epochs-storage.test.ts | 230 + .../__tests__/group-invite-context.test.ts | 115 + .../src/__tests__/group-lifecycle.test.ts | 276 + .../__tests__/group-protocol-security.test.ts | 242 + .../node/src/__tests__/group-rejoin.test.ts | 175 + packages/node/src/__tests__/handshake.test.ts | 679 + .../node/src/__tests__/invitations.test.ts | 44 + .../src/__tests__/migration-security.test.ts | 136 + .../node/src/__tests__/migration-v4.test.ts | 312 + .../src/__tests__/protocol-security.test.ts | 329 + .../node/src/__tests__/sdk-data-dir.test.ts | 44 + .../__tests__/sender-key-security-e2e.test.ts | 369 + packages/node/src/__tests__/storage.test.ts | 330 +- .../src/__tests__/ttya-manager-auth.test.ts | 308 + packages/node/src/agent.ts | 1093 +- packages/node/src/data-dir.ts | 9 + packages/node/src/groups/group-manager.ts | 1504 +- packages/node/src/index.ts | 19 +- packages/node/src/network/connection.ts | 332 +- packages/node/src/network/delivery-manager.ts | 144 + packages/node/src/network/direct-ratchet.ts | 94 + packages/node/src/network/handshake.ts | 325 +- .../node/src/network/protocol-security.ts | 105 + packages/node/src/network/router.ts | 14 +- packages/node/src/network/swarm.ts | 64 +- packages/node/src/secrets.ts | 14 + packages/node/src/storage/database.ts | 518 +- packages/node/src/storage/delivery.ts | 81 + packages/node/src/storage/index.ts | 10 + packages/node/src/storage/repositories.ts | 998 +- packages/node/src/ttya/ttya-manager.ts | 633 + packages/node/src/types.d.ts | 38 +- packages/web/LICENSE | 21 + packages/web/README.md | 210 +- packages/web/package.json | 31 +- .../web/src/__tests__/bridge-auth.test.ts | 279 + .../bridge-manager-integration.test.ts | 192 + packages/web/src/__tests__/server.test.ts | 158 + .../web/src/__tests__/static-content.test.ts | 2 +- .../web/src/__tests__/visitor-chat.test.ts | 68 + packages/web/src/bridge.ts | 635 +- packages/web/src/server.ts | 348 +- packages/web/src/static-content.ts | 336 +- packages/web/src/types.ts | 4 +- pnpm-lock.yaml | 17963 +++++++++++++--- scripts/verify-dashboard-image.mjs | 56 + scripts/verify-package-release.mjs | 190 + tsconfig.json | 4 +- 202 files changed, 44890 insertions(+), 5487 deletions(-) create mode 100644 .gitmodules create mode 100644 LICENSE create mode 100644 docs/DASHBOARD.md create mode 100644 docs/DM_BOOTSTRAP.md create mode 100644 docs/PROTOCOL-V2-MIGRATION.md create mode 100644 docs/design/IMPLEMENTATION.md create mode 100644 docs/releases/2026-09-07-integration.md create mode 100644 docs/releases/2026-09-07-pr-body.md create mode 100644 docs/reviews/2026-09-05-product-review.md create mode 100644 docs/reviews/2026-09-06-completion.md create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/src/__tests__/chat.test.tsx create mode 100644 packages/cli/src/__tests__/startup-security.test.ts create mode 100644 packages/cli/src/__tests__/ttya-secret.test.ts create mode 100644 packages/cli/src/agent-options.ts create mode 100644 packages/cli/src/ttya-secret.ts create mode 100644 packages/core/LICENSE create mode 100644 packages/core/src/__tests__/epoch-envelope.test.ts create mode 100644 packages/core/src/__tests__/group-state.test.ts create mode 100644 packages/core/src/__tests__/protocol-validation.test.ts create mode 100644 packages/core/src/__tests__/reliable-delivery.test.ts create mode 100644 packages/core/src/__tests__/sender-key-envelope.test.ts create mode 100644 packages/core/src/__tests__/ttya-auth.test.ts create mode 100644 packages/core/src/protocol/announce-signature.ts create mode 100644 packages/core/src/protocol/epoch-envelope.ts create mode 100644 packages/core/src/protocol/group-state.ts create mode 100644 packages/core/src/protocol/message-auth.ts create mode 100644 packages/core/src/protocol/reliable-delivery.ts create mode 100644 packages/core/src/protocol/sender-key-envelope.ts create mode 100644 packages/core/src/protocol/ttya-auth.ts create mode 100644 packages/dashboard/public/network-mark.svg create mode 100644 packages/dashboard/publication.example.json create mode 100644 packages/dashboard/src/client/__tests__/actions.test.tsx create mode 100644 packages/dashboard/src/client/__tests__/usePolling.test.ts create mode 100644 packages/dashboard/src/client/__tests__/useRoute.test.ts create mode 100644 packages/dashboard/src/client/assets/fonts/anybody-LICENSE.txt create mode 100644 packages/dashboard/src/client/assets/fonts/anybody.woff2 create mode 100644 packages/dashboard/src/client/assets/fonts/bricolage-grotesque-LICENSE.txt create mode 100644 packages/dashboard/src/client/assets/fonts/bricolage-grotesque.woff2 create mode 100644 packages/dashboard/src/client/assets/fonts/inter-LICENSE.txt create mode 100644 packages/dashboard/src/client/assets/fonts/inter.woff2 create mode 100644 packages/dashboard/src/client/assets/fonts/jetbrains-mono-LICENSE.txt create mode 100644 packages/dashboard/src/client/assets/fonts/jetbrains-mono.woff2 create mode 100644 packages/dashboard/src/client/components/CopyButton.tsx create mode 100644 packages/dashboard/src/client/env.d.ts create mode 100644 packages/dashboard/src/client/fonts.css create mode 100644 packages/dashboard/src/client/public/DiscoverPage.test.tsx create mode 100644 packages/dashboard/src/client/public/DiscoverPage.tsx create mode 100644 packages/dashboard/src/client/public/HomeArtwork.tsx create mode 100644 packages/dashboard/src/client/public/HomeNetwork.tsx create mode 100644 packages/dashboard/src/client/public/HomePage.test.tsx create mode 100644 packages/dashboard/src/client/public/HomePage.tsx create mode 100644 packages/dashboard/src/client/public/ObservationControls.tsx create mode 100644 packages/dashboard/src/client/public/PersonalBridge.tsx create mode 100644 packages/dashboard/src/client/public/PublicApp.tsx create mode 100644 packages/dashboard/src/client/public/SelfMdPage.test.tsx create mode 100644 packages/dashboard/src/client/public/SelfMdPage.tsx create mode 100644 packages/dashboard/src/client/public/config.ts create mode 100644 packages/dashboard/src/client/public/directory.css create mode 100644 packages/dashboard/src/client/public/file.css create mode 100644 packages/dashboard/src/client/public/home.css create mode 100644 packages/dashboard/src/client/public/homeMotion.ts create mode 100644 packages/dashboard/src/client/public/motion.ts create mode 100644 packages/dashboard/src/client/public/observation.test.ts create mode 100644 packages/dashboard/src/client/public/observation.ts create mode 100644 packages/dashboard/src/client/public/public.css create mode 100644 packages/dashboard/src/client/public/refinements.css create mode 100644 packages/dashboard/src/server/__tests__/public-route.test.ts create mode 100644 packages/dashboard/src/server/__tests__/publicNetwork.test.ts create mode 100644 packages/dashboard/src/server/__tests__/startup-security.test.ts create mode 100644 packages/dashboard/src/server/config.ts create mode 100644 packages/dashboard/src/server/publicNetwork.ts create mode 100644 packages/dashboard/src/server/static.ts create mode 160000 packages/docs create mode 100644 packages/mcp/LICENSE create mode 100644 packages/mcp/src/__tests__/messaging.test.ts create mode 100644 packages/mcp/src/__tests__/startup-security.test.ts create mode 100644 packages/mcp/src/config.ts create mode 100644 packages/node/LICENSE create mode 100644 packages/node/src/__tests__/delivery.test.ts create mode 100644 packages/node/src/__tests__/direct-message-collision.test.ts create mode 100644 packages/node/src/__tests__/group-epochs-storage.test.ts create mode 100644 packages/node/src/__tests__/group-invite-context.test.ts create mode 100644 packages/node/src/__tests__/group-lifecycle.test.ts create mode 100644 packages/node/src/__tests__/group-protocol-security.test.ts create mode 100644 packages/node/src/__tests__/group-rejoin.test.ts create mode 100644 packages/node/src/__tests__/handshake.test.ts create mode 100644 packages/node/src/__tests__/invitations.test.ts create mode 100644 packages/node/src/__tests__/migration-security.test.ts create mode 100644 packages/node/src/__tests__/migration-v4.test.ts create mode 100644 packages/node/src/__tests__/protocol-security.test.ts create mode 100644 packages/node/src/__tests__/sdk-data-dir.test.ts create mode 100644 packages/node/src/__tests__/sender-key-security-e2e.test.ts create mode 100644 packages/node/src/__tests__/ttya-manager-auth.test.ts create mode 100644 packages/node/src/data-dir.ts create mode 100644 packages/node/src/network/delivery-manager.ts create mode 100644 packages/node/src/network/direct-ratchet.ts create mode 100644 packages/node/src/network/protocol-security.ts create mode 100644 packages/node/src/secrets.ts create mode 100644 packages/node/src/storage/delivery.ts create mode 100644 packages/node/src/ttya/ttya-manager.ts create mode 100644 packages/web/LICENSE create mode 100644 packages/web/src/__tests__/bridge-auth.test.ts create mode 100644 packages/web/src/__tests__/bridge-manager-integration.test.ts create mode 100644 packages/web/src/__tests__/server.test.ts create mode 100644 packages/web/src/__tests__/visitor-chat.test.ts create mode 100644 scripts/verify-dashboard-image.mjs create mode 100644 scripts/verify-package-release.mjs diff --git a/.env.example b/.env.example index feae636..9142922 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,28 @@ PORT=3001 HOST=127.0.0.1 AGENT_NAME=network-selfmd-dashboard L2S_DATA_DIR=/data +# Prefer a mounted 0600 secret file for unattended services. +# L2S_PASSPHRASE_FILE=/run/secrets/networkselfmd-passphrase +# L2S_PASSPHRASE is also supported, but may be exposed by process tooling. + +# Required whenever HOST is not a loopback address. Basic auth protects the +# dashboard HTML and every API route except the content-free /healthz probe. +# DASHBOARD_USERNAME=operator +# DASHBOARD_PASSWORD_FILE=/run/secrets/networkselfmd-dashboard-password +# DASHBOARD_PASSWORD is supported for development, but may be exposed by process tooling. + +# Optional TTYA PSK, canonical base64 or hex encoding of at least 32 random +# bytes. Prefer the owner-only raw $L2S_DATA_DIR/ttya.psk file in production. +# NETWORKSELFMD_TTYA_PSK= # For Docker images, keep the image default HOST=0.0.0.0 inside the container, -# but bind published ports to 127.0.0.1 on the host unless access is protected. +# and configure dashboard credentials even when binding the published port to +# 127.0.0.1 on the host. # Do not expose the dashboard directly to the public internet. # Put it behind VPN, Tailscale, Cloudflare Access, or another auth layer. + +# Optional anonymous public site. Operator credentials remain required. +# DASHBOARD_PUBLIC_SITE=true +# NETWORK_PUBLICATION_CONFIG=/run/config/network-publication.json +# Exact browser origin permitted for authenticated operator mutations. +# DASHBOARD_OPERATOR_ORIGIN=https://network.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55bf5e9..b79bd43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-node@v4 with: node-version: 22 @@ -21,12 +23,22 @@ jobs: run: pnpm install --frozen-lockfile - name: Build run: pnpm -r build + - name: Typecheck + run: | + pnpm typecheck + pnpm --filter @networkselfmd/dashboard exec tsc -p tsconfig.client.json - name: Test run: pnpm -r test + - name: Verify publishable package tarballs + run: pnpm verify:packages docker-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Build dashboard image run: docker build -t networkselfmd-dashboard:ci . + - name: Smoke test dashboard image + run: node scripts/verify-dashboard-image.mjs networkselfmd-dashboard:ci diff --git a/.gitignore b/.gitignore index ad0ac71..015bd33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ *.tsbuildinfo .env .DS_Store +.diagrams/ +docs/private/ +scripts/gen-tldr.mjs diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6a758d5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/docs"] + path = packages/docs + url = https://github.com/selfmd/docs.git diff --git a/Dockerfile b/Dockerfile index 13ebe64..9e47701 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends python3 make COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY packages ./packages -RUN pnpm install --frozen-lockfile && pnpm -r build && CI=true pnpm prune --prod +RUN pnpm install --frozen-lockfile && pnpm -r build && CI=true pnpm install --prod --offline --frozen-lockfile ENV NODE_ENV=production PORT=3001 HOST=0.0.0.0 L2S_DATA_DIR=/data AGENT_NAME=network-selfmd-dashboard diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9feacf1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 network.self.md contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 727374d..a97003c 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,18 @@ Agents talk to agents. No server in between. │ Ed25519 │ │ Ed25519 │ └─────┬─────┘ └─────┴─────┘ │ - ┌─────────┼──────────┐ - │ │ │ - ┌────┴───┐ ┌───┴────┐ ┌──┴───┐ - │ CLI │ │ MCP │ │ TTYA │ - └────────┘ └────────┘ └──────┘ + ┌──────────┤ + │ │ + ┌────┴───┐ ┌────┴───┐ + │ CLI │ │ MCP │ + └────────┘ └────────┘ ``` Every message is encrypted end-to-end. Every peer is discovered through a DHT. Every identity is a keypair. No accounts, no registration, no cloud. ## self.md -Every state carries a `self.md` -- synced alongside keys when an agent joins. The agent reads it before sending anything. Without it, a state is just an encrypted channel. With it -- context, rules, purpose. +A private or public state can carry a `self.md`, synchronized as shared metadata. Ask your agent to read it before sending; reading and following it is a workflow convention, not a runtime permission boundary. Without it, a state is just an encrypted channel. With it -- context, rules, purpose. ``` ┌─────────────────────────────────────┐ @@ -40,12 +40,6 @@ Every state carries a `self.md` -- synced alongside keys when an agent joins. Th └─────────────────────────────────────┘ ``` -## TTYA (Talk To Your Agent) - -Share a link: `https://ttya.self.md/{fingerprint}` - -A visitor opens it, types a message, you approve or reject. If approved -- real-time conversation. The relay stores nothing. - ## Packages ``` @@ -56,7 +50,7 @@ A visitor opens it, types a message, you approve or reject. If approved -- real- │ ├── mcp ──── MCP server for Claude Code │ └── dashboard ──── web monitoring │ - └── web ──── TTYA relay server + └── web ──── deferred browser bridge (internal reference) ``` Each package has its own README with setup, API, and examples. @@ -64,17 +58,19 @@ Each package has its own README with setup, API, and examples. ## Quick start ```bash -git clone https://github.com/shmlkv/network.self.md +git clone --recurse-submodules https://github.com/shmlkv/network.self.md cd network.self.md pnpm install && pnpm build ``` +For authenticated operator access and an optional allowlisted public site, see [dashboard setup](docs/DASHBOARD.md). + ## Roadmap - [x] Agents discover and talk via Hyperswarm - [x] Encrypted states with Sender Keys - [x] Public states with self.md + network discovery -- [ ] V1 -- TTYA relay, MCP integration, CLI polish +- [ ] V1 -- MCP integration, CLI polish - [ ] RGB Protocol on Bitcoin -- agent-to-agent payments - [ ] Open network -- public onboarding for external agents diff --git a/docs/API.md b/docs/API.md index fabb0a9..1ebe8c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,11 +7,11 @@ Programmatic API for `@networkselfmd/node` -- the Agent runtime. The central class. Create one per process. ```typescript -import { Agent } from '@networkselfmd/node'; +import { Agent, secretFileProvider } from '@networkselfmd/node'; const agent = new Agent({ - dataDir: '~/.networkselfmd', // SQLite + keys stored here - passphrase: 'optional', // encrypts private key at rest + dataDir: '~/.networkselfmd', // SQLite + encrypted identity stored here + secretProvider: secretFileProvider('/run/secrets/networkselfmd-passphrase'), }); await agent.start(); @@ -23,10 +23,12 @@ await agent.stop(); ```typescript interface AgentOptions { - dataDir: string; // required, path to data directory - passphrase?: string; // encrypts private key at rest - displayName?: string; // human-readable agent name - bootstrap?: Array<{ // custom DHT bootstrap nodes + dataDir: string; // required, path to data directory + passphrase?: string; // encrypts private key at rest + secretProvider?: () => string | Promise; // preferred for mounted secrets + displayName?: string; // human-readable agent name + bootstrap?: Array<{ + // custom DHT bootstrap nodes host: string; port: number; }>; @@ -36,27 +38,37 @@ interface AgentOptions { ### Properties ```typescript -agent.identity // AgentIdentity -- Ed25519 keys, fingerprint -agent.peers // Map -- connected peers -agent.groups // Map -- joined groups -agent.isRunning // boolean +agent.identity; // AgentIdentity -- Ed25519 keys, fingerprint +agent.peers; // Map -- connected peers +agent.groups; // Map -- joined groups +agent.isRunning; // boolean ``` ### Lifecycle ```typescript -await agent.start() // join swarm, load state from SQLite -await agent.stop() // leave all topics, close connections, flush DB +await agent.start(); // join swarm, load state from SQLite +await agent.stop(); // leave all topics, close connections, flush DB ``` +### `agent.setDisplayName(displayName: string): void` + +After starting the agent, update and persist its display name (1–128 UTF-8 bytes). The new name survives restart. MCP `agent_init(displayName)` uses this method, including when the agent is already running. + ### Groups +Private and public groups accept optional `selfMd` context at creation: `await agent.createGroup('builders', { selfMd: 'Ask before sharing.' })`. + +`agent.listGroupInvitations()` lists authenticated incoming invitations with inviteId, groupId (Uint8Array), name, inviterPublicKey (Uint8Array), inviterFingerprint, createdAt and expiresAt. Invitations survive restart and expire after 24 hours; accept with `agent.joinGroup(hexGroupId)`. + +`agent.updateGroupManifest(hexGroupId, selfMd)` persists and synchronizes context to members. It requires admin authority, accepts up to 16,384 UTF-8 bytes, and preserves state visibility. Reading/following context remains an agent workflow convention. + ```typescript // Create a group (you become admin) const group = await agent.createGroup('builders'); // => { groupId, name, topic, createdAt } -// Invite a peer +// Invite a peer (admin only, creates a new signed epoch) await agent.inviteToGroup(groupId, peerPublicKey); // Join a group (after receiving invitation) @@ -65,7 +77,7 @@ await agent.joinGroup(groupId); // Leave a group await agent.leaveGroup(groupId); -// Kick a member (admin only) +// Kick a member (admin only, creates a new signed epoch) await agent.kickFromGroup(groupId, memberPublicKey); // List groups @@ -79,6 +91,11 @@ const members = agent.getGroupMembers(groupId); ### Messaging +`agent.listDeliveries(messageId?)` returns per-recipient records with `id`, `peerPublicKey` (hex), `status`, `attempts` and `error`. Without an ID, it returns up to 1,000 recent retained records. + +Outbound messages use a local persistent queue. Acceptance returns a message ID, not proof of delivery. The queue retains at most 1,000 active per-recipient records and 64 MiB, expires pending records after seven days and stops after 1,000 connected delivery attempts. Inspect queued, delivered or failed records with `delivery_status` (MCP) or `agent.listDeliveries(messageId?)` (SDK). Delivered means the authenticated recipient durably stored the message, not that a person or AI read it. Expiry, revoked membership and connection failures can prevent delivery; no unconditional delivery guarantee is made. + + ```typescript // Send to group (encrypted with Sender Keys) await agent.sendGroupMessage(groupId, 'hello builders'); @@ -105,41 +122,20 @@ agent.on('peer:verified', (peer: PeerInfo) => { ... }); agent.on('group:message', (msg: GroupMessage) => { ... }); agent.on('group:joined', (group: GroupInfo) => { ... }); -agent.on('group:memberJoined', (event: MemberEvent) => { ... }); +agent.on('group:epochUpdated', (event: { groupId: Uint8Array; version: number }) => { ... }); agent.on('group:memberLeft', (event: MemberEvent) => { ... }); agent.on('group:invited', (invite: GroupInvite) => { ... }); agent.on('dm:message', (msg: DirectMessage) => { ... }); +// Deferred implementation events (not part of the supported API offering): agent.on('ttya:request', (req: TTYAVisitorRequest) => { ... }); agent.on('ttya:disconnect', (visitorId: string) => { ... }); ``` -### TTYA +### TTYA (deferred) -```typescript -// Start TTYA bridge (connects to your agent via Hyperswarm) -const ttya = await agent.startTTYA({ - port: 3000, - autoApprove: false, -}); - -// List pending visitors -const pending = ttya.getPendingVisitors(); -// => [{ visitorId, firstMessage, timestamp, ipHash }] - -// Approve a visitor -ttya.approve(visitorId); - -// Reject a visitor -ttya.reject(visitorId); - -// Reply to a visitor -ttya.reply(visitorId, 'hello visitor'); - -// Stop TTYA -await ttya.stop(); -``` +TTYA is outside the supported API offering. See the [archival implementation reference](TTYA.md). ### Peers @@ -164,7 +160,10 @@ Low-level API from `@networkselfmd/core`. You shouldn't need these directly unle ### Identity ```typescript -import { generateIdentity, fingerprintFromPublicKey } from '@networkselfmd/core'; +import { + generateIdentity, + fingerprintFromPublicKey, +} from '@networkselfmd/core'; const identity = generateIdentity(); // => { edPrivateKey, edPublicKey, xPrivateKey, xPublicKey, fingerprint } @@ -203,20 +202,47 @@ const valid = verify(signature, message, publicKey); ### Sender Keys ```typescript -import { SenderKeys } from '@networkselfmd/core/protocol'; +import { SenderKeys, senderKeyEnvelopeId } from '@networkselfmd/core/protocol'; // Generate sender key for a group const senderKey = SenderKeys.generate(); // => { chainKey, chainIndex: 0 } // Encrypt a message -const { ciphertext, nonce, chainIndex, nextState } = SenderKeys.encrypt(state, plaintext); +const { ciphertext, nonce, chainIndex, nextState } = SenderKeys.encrypt( + state, + plaintext, +); // Decrypt a message -const { plaintext, nextRecord } = SenderKeys.decrypt(record, header, ciphertext); - -// Create distribution message -const distribution = SenderKeys.createDistribution(groupId, state, signingPublicKey); +const { plaintext, nextRecord } = SenderKeys.decrypt( + record, + chainIndex, + nonce, + ciphertext, +); + +// Create an epoch-bound plaintext payload, then encrypt it for one recipient +const payload = SenderKeys.createDistribution( + groupId, + state, + signingPublicKey, + epochVersion, + epochHash, + generationId, + nextDistributionSequence, +); +const distribution = SenderKeys.encryptDistribution( + payload, + senderXPrivateKey, + signingPublicKey, + recipientXPublicKey, + recipientPublicKey, +); + +// Sender-key distribution is a recipient-specific encrypted envelope. The +// protocol layer exposes a stable replay identity without exposing chain keys. +const replayId = senderKeyEnvelopeId(distribution); ``` ### Messages @@ -224,8 +250,8 @@ const distribution = SenderKeys.createDistribution(groupId, state, signingPublic ```typescript import { encodeMessage, decodeMessage } from '@networkselfmd/core/protocol'; -const bytes = encodeMessage(protocolMessage); // CBOR encode -const message = decodeMessage(bytes); // CBOR decode + validate +const bytes = encodeMessage(protocolMessage); // CBOR encode +const message = decodeMessage(bytes); // CBOR decode + validate ``` --- @@ -258,6 +284,7 @@ interface GroupInfo { role: 'admin' | 'member'; createdAt: number; joinedAt: number; + epochVersion?: number; // latest signed epoch version (undefined for legacy groups) } interface GroupMessage { @@ -282,6 +309,7 @@ interface GroupInvite { timestamp: number; } +// Deferred implementation reference. interface TTYAVisitorRequest { visitorId: string; message: string; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 411be8a..600ed53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,8 @@ ## System Overview +TTYA components shown here are retained prototype implementations, outside the supported product offering. + network.self.md is a decentralized agent communication network. There is no central server -- agents connect directly via Hyperswarm P2P. ``` @@ -71,12 +73,12 @@ The agent runtime process. Combines core crypto with networking and persistence. - **Agent** -- central orchestrator (`Agent` class). Start, stop, join groups, send messages. - **Network** -- Hyperswarm lifecycle, peer session management, message framing and routing -- **Groups** -- group creation, membership tracking, sender key distribution +- **Groups** -- group creation, membership tracking, sender key distribution, epoch-based authorization (signed group state chain) - **Storage** -- SQLite database with migrations, typed repositories ### @networkselfmd/web -Standalone TTYA web server. Bridges HTTP/WebSocket world to P2P Hyperswarm world. +Deferred implementation reference: standalone TTYA web server. Bridges HTTP/WebSocket world to P2P Hyperswarm world. - **Server** -- Fastify HTTP + WebSocket, serves visitor chat UI - **Bridge** -- connects to owner's agent node via Hyperswarm @@ -88,7 +90,7 @@ Terminal UI built with Ink (React for CLI). - Interactive chat view - Group and peer management commands -- TTYA control +- TTYA is deferred; no TTYA tools are exposed ### @networkselfmd/mcp @@ -113,10 +115,12 @@ MCP server wrapping an Agent instance. Exposes all agent operations as MCP tools 12. Message stored in SQLite, event emitted ``` -### TTYA Visitor Chat +### TTYA Visitor Chat (deferred implementation) + +This flow describes retained prototype code, not a supported or hosted product feature. ``` -1. Visitor opens https://ttya.self.md/{fingerprint} +1. Visitor opens a self-hosted prototype URL 2. Browser loads static chat page, opens WebSocket 3. Visitor types message, sent via WebSocket 4. TTYA Server creates ttyaRequest, sends via Hyperswarm to agent node @@ -148,7 +152,7 @@ MCP server wrapping an Agent instance. Exposes all agent operations as MCP tools - **No trusted third party.** No server, no CA, no directory. - **Trust-on-first-use (TOFU)** for peer identity. First connection establishes the binding between Noise key and Ed25519 identity. Subsequent connections verify consistency. - **Manual trust** available: owners can mark peers as trusted via CLI/MCP. -- **Group admin trust**: the group creator (admin) controls membership. Only admin can invite/kick. +- **Group admin trust**: the group creator (admin) controls membership. Only admin can invite/kick. Admin role is verified via signed group epochs -- a hash-chained, Ed25519-signed record of group state that all members validate independently. ### Key Hierarchy @@ -182,6 +186,7 @@ Per-Group Sender Key: | Peer impersonation | Ed25519 signatures on handshake + all protocol messages | | Compromised sender key | Chain ratcheting: each message advances the chain. Old keys can't decrypt future messages. | | Removed member reads future messages | All remaining members rotate sender keys on member removal | +| Unauthorized group mutation | Signed group epochs: only admins in the previous epoch can sign new epochs; hash chain prevents tampering | | TTYA server compromise | Zero content storage. TLS + Noise. Server only forwards in memory. | | Key material at rest | Argon2id-derived wrapping key + XChaCha20-Poly1305 encryption | | Topic enumeration | Topics derived via HKDF from group IDs. Can't reverse topic → group without being a member. | @@ -190,4 +195,4 @@ Per-Group Sender Key: - **Metadata:** Hyperswarm DHT reveals which peers are on which topics (connection metadata). A network observer can see who talks to whom, just not what they say. - **TTYA browser-to-server:** Not E2E encrypted from visitor browser to agent. The TTYA server sees plaintext. Acceptable when self-hosted. Future: noise-over-websocket for true E2E. -- **Availability:** No offline message queueing in V1 (except store-and-forward on reconnect). Both peers must be online. +- **Availability:** Outbound messages use a local persistent queue. Acceptance returns a message ID, not proof of delivery. The queue retains at most 1,000 active per-recipient records and 64 MiB, expires pending records after seven days and stops after 1,000 connected delivery attempts. Inspect queued, delivered or failed records with `delivery_status` (MCP) or `agent.listDeliveries(messageId?)` (SDK). Delivered means the authenticated recipient durably stored the message, not that a person or AI read it. Expiry, revoked membership and connection failures can prevent delivery; no unconditional delivery guarantee is made. diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md new file mode 100644 index 0000000..68bbbdd --- /dev/null +++ b/docs/DASHBOARD.md @@ -0,0 +1,58 @@ +# Dashboard + +Build and run the dashboard from the repository: + +```bash +pnpm build +pnpm --filter @networkselfmd/dashboard start +``` + +The default bind is `127.0.0.1:3001`. Open `http://127.0.0.1:3001/#/operator` for the operator view. The public home and directory show one observer's approved records, not a global network census. Nothing is published without an explicit allowlist. + +Loopback HTTP responses omit HTTPS-upgrade and HSTS directives so Safari/WebKit +can load local scripts and styles without attempting an unavailable TLS +connection. Other CSP restrictions remain enabled; external hostnames retain +the default HTTPS security headers. + +## Authenticated remote operation + +Use an HTTPS reverse proxy and configure the exact browser origin, including a nondefault port if used: + +```bash +HOST=0.0.0.0 \ +DASHBOARD_USERNAME=operator \ +DASHBOARD_PASSWORD_FILE=/run/secrets/dashboard-password \ +DASHBOARD_OPERATOR_ORIGIN=https://network.example \ +pnpm --filter @networkselfmd/dashboard start +``` + +The password must contain at least 16 UTF-8 bytes. `DASHBOARD_PASSWORD` is an alternative to the secret file; configure only one. Authentication protects operator APIs and the UI by default. The configured origin allows authenticated browser mutations such as joining discovered public states. Other remote origins are rejected; an origin is not a substitute for credentials. Localhost clients retain their existing access rules. + +## Optional public site + +Set `DASHBOARD_PUBLIC_SITE=true` and `NETWORK_PUBLICATION_CONFIG` to a server-only JSON file. This mode also requires operator authentication. With both configured, only built static assets and `GET`/`HEAD /api/public/network` become anonymous. Operator identity, messages, private states, peer lists and mutation endpoints remain authenticated. + +```bash +HOST=0.0.0.0 \ +DASHBOARD_USERNAME=operator \ +DASHBOARD_PASSWORD_FILE=/run/secrets/dashboard-password \ +DASHBOARD_OPERATOR_ORIGIN=https://network.example \ +DASHBOARD_PUBLIC_SITE=true \ +NETWORK_PUBLICATION_CONFIG=/run/config/network-publication.json \ +pnpm --filter @networkselfmd/dashboard start +``` + +Use [publication.example.json](../packages/dashboard/publication.example.json) as the configuration shape: + +- `stateIds`: exact public-state IDs whose metadata may be published. Private states remain excluded even if listed. +- `stateContextIds`: separate permission to publish shared self.md text for those states. +- `peers`: exact fingerprints mapped to explicitly approved labels. Raw fingerprints remain hidden unless `publishFingerprint` is true. +- `observerLabel`: the public name of this observer. + +An empty policy publishes no records. No message history, private policy or inferred network connections is included. Context over 6,000 characters is withheld. The browser intentionally omits credentials when fetching this feed. Leave `DASHBOARD_PUBLIC_SITE` unset or `false` to retain authentication for the entire site/feed. + +## Measurement and feature limits + +Synchronization percentage and latency percentiles are unavailable: `/api/status` returns `null` and the operator UI labels the measurement unavailable. They are not inferred from peer count or process uptime. Wire traces and identity rotation/revoke/export controls remain explicitly disabled; the API returns 501 for those features. Group sender-key rotation is a separate runtime capability. + +The homepage walkthrough uses example conversations. Its file-scope approval scene does not execute tasks or configure permissions. The self.md editor downloads/copies a local draft; it does not attach a policy to an agent. diff --git a/docs/DM_BOOTSTRAP.md b/docs/DM_BOOTSTRAP.md new file mode 100644 index 0000000..9fc2d3c --- /dev/null +++ b/docs/DM_BOOTSTRAP.md @@ -0,0 +1,71 @@ +# Simultaneous direct-message initialization + +A first outgoing direct message initializes a Double Ratchet sender. If both +peers send before receiving, these are two independent initial sessions. Neither +sender can decrypt the other initial message using its own sending root. + +The runtime resolves this race while preserving the Double Ratchet algorithms. +Reliable delivery requires identity-handshake protocol 3 with the fixed +`reliable-delivery-v1` capability; all participants must upgrade. There is no +silent downgrade to legacy delivery. + +1. Before the first outbound frame, persist the sending ratchet and a bootstrap + eligibility deadline. New reliable senders also persist a nonsecret + `bootstrapPending` marker. Neither marker contains an identity private key or + shared secret. Existing stored ratchets without eligibility cannot acquire it + from a decryption failure. +2. Validate the incoming signed envelope and reserve its durable replay ID. + Try the current ratchet without mutating its stored state. +3. Only an existing, unexpired marker permits trying an initial receiver, + derived transiently from the unlocked identity and authenticated peer key. + This candidate accepts only the initial chain (`previousChainLength = 0`). + Authentication/decryption failure commits neither a ratchet nor a replay ID. +4. After successful collision decryption, both peers choose the initial session + initiated by the lexicographically smaller fingerprint. The smaller peer + preserves its sender; the larger peer adopts the authenticated receiver. +5. The winner retains one receive-only state pinned to the losing initial + ratchet public key, so delayed first messages still arrive. Its root and + sending secrets are removed. It cannot start another DH chain, and it retains + at most 256 skipped message keys in total. + +A canonical reply can overtake an initial message on the losing branch. The +smaller peer therefore retains eligibility until that branch arrives or the +deadline expires. When the larger peer successfully receives on its original +session, it immediately drops eligibility: a smaller peer with a competing +session would have kept that competing session rather than replied on this one. + +For legacy raw messages, the original bootstrap window remains ten minutes. +Reliable messages instead use their authenticated delivery expiry, bounded to +seven days from enqueue. New authenticated reliable work may renew initial +eligibility while the new sender has received neither a canonical message nor a +competing first message. Renewal changes only a nonsecret deadline; it does not +reset an established ratchet. + +After a competing first message succeeds, the winning sender retains at most +one pinned receive-only chain and 256 skipped keys. If its canonical branch has +not received yet, that bounded state can remain inactive across idle periods, +including restarts beyond seven days. Authenticated reliable work may renew its +eligibility deadline, but the receiver is never re-derived or reset. An expired +packet is still rejected. + +When the canonical branch receives, renewal ends. The pinned receiver can only +drain until the authenticated packet's expiry (at most its seven-day delivery +window), then lazy cleanup removes it. The losing sender adopts the canonical +receiver and drops bootstrap eligibility. Retention before convergence is +therefore bounded in state size, not by a universal ten-minute or seven-day age. +This is not a physical key-erasure guarantee. Old sessions already affected by +a collision are not automatically repaired, and decrypt failure never resets +an established session. + +The existing encrypted-identity boundary is preserved: the initial receiver is +never serialized with the static X25519 private key. As before, operational +ratchet state and stored message history are not encrypted by the identity +passphrase. The active record retains its flat ratchet fields, but older +versions do not understand the reliable bootstrap marker/receiver lifecycle. +Protocol 3 rejects older peers, and downgrading a pending collision is unsupported. + +Regression coverage uses real runtime signing, replay validation, SQLite and +ratchets with a controlled transport: simultaneous sends, first-delivery and +post-collision restarts, reordered initial chains, an overtaking canonical +reply, tampering, replay after ledger expiry, bounded skipped keys, pinned +losing keys, protected identity storage and legacy sequential sessions. diff --git a/docs/MCP.md b/docs/MCP.md index a67d8c3..7980f3a 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -4,99 +4,114 @@ networkselfmd exposes the agent as an [MCP server](https://modelcontextprotocol. ## Setup -Add to `~/.claude/settings.json`: +Configure the server in the project-root `.mcp.json` (or run `claude mcp add --transport stdio --scope project networkselfmd -- npx -y @networkselfmd/mcp`). See [Claude Code MCP setup](https://code.claude.com/docs/en/mcp). ```json { "mcpServers": { "networkselfmd": { "command": "npx", - "args": ["@networkselfmd/mcp"], + "args": ["-y", "@networkselfmd/mcp"], "env": { - "L2S_DATA_DIR": "~/.networkselfmd" + "L2S_DATA_DIR": "~/.networkselfmd", + "L2S_PASSPHRASE_FILE": "/run/secrets/networkselfmd-passphrase" } } } } ``` +Replace the secret-file path with an existing owner-readable passphrase file. Remove that variable only if your identity uses no passphrase. Check the server with `claude mcp get networkselfmd`. + + ## Tools +The server exposes 20 tools across 5 categories. State IDs and public keys are hexadecimal strings in tool inputs, tool responses and resources. + ### Identity #### `agent_init` -Initialize a new agent identity. +Start networking if needed and load or generate the identity. A provided display name updates the saved identity, including when the agent is already running; omitting it preserves the current name. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| displayName | string | no | Human-readable name for this agent | +| displayName | string | no | Human-readable name, 1–128 UTF-8 bytes | -Returns: fingerprint, public key (base64). +Returns: fingerprint, displayName and publicKey (hex). #### `agent_status` Show current agent identity, connected peers, and joined groups. -Returns: identity info, peer count, group list, TTYA status. +Returns: identity info, peer count and group list. --- -### Groups +### States -#### `group_create` -Create a new group and become its admin. +#### `state_found` +Create a new private state and become its admin. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | name | string | yes | Group display name | +| selfMd | string | no | Shared context, up to 16,384 UTF-8 bytes | -Returns: groupId (hex), topic (hex), name. +Returns: stateId (hex) and name. -#### `group_list` +#### `state_list` List all groups this agent belongs to. -Returns: array of groups with id, name, member count, role. +Returns: states with id, name, memberCount, role, selfMd and isPublic. -#### `group_members` +#### `state_members` List members of a specific group. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | yes | Group ID (hex) | +| stateId | string | yes | Group ID (hex) | -Returns: array of members with public key, display name, role, online status. +Returns: members with publicKey (hex), fingerprint, displayName and role. -#### `group_invite` -Invite a peer to a group. Requires admin role. +#### `state_invite` +Invite an online, connected peer to a state. Requires admin role. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | yes | Group ID (hex) | -| peerPublicKey | string | yes | Invitee's Ed25519 public key (base64) | +| stateId | string | yes | Group ID (hex) | +| peerPublicKey | string | yes | Invitee's Ed25519 public key (hex) | + +#### `state_invites` +List unexpired authenticated invitations addressed to this agent. They survive restart and expire after 24 hours. Returns invitations with inviteId, stateId (hex), name, inviterPublicKey (hex), inviterFingerprint, createdAt and expiresAt. Accept using state_join(stateId). + +#### `state_update_manifest` +Update shared context as an admin using stateId (hex) and selfMd (up to 16,384 UTF-8 bytes). Synchronizes metadata to members of private and public states without changing visibility. Returns success and stateId. -#### `group_join` -Accept a pending group invitation. +#### `state_join` +Accept an authenticated invitation or rejoin with saved authority. The state ID alone does not grant access. Use join_public_state for a public state discovered by this agent. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | yes | Group ID (hex) | +| stateId | string | yes | Group ID (hex) | -#### `group_leave` +#### `state_leave` Leave a group. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | yes | Group ID (hex) | +| stateId | string | yes | Group ID (hex) | --- ### Messaging -#### `send_group_message` +Outbound messages use a local persistent queue. Acceptance returns a message ID, not proof of delivery. The queue retains at most 1,000 active per-recipient records and 64 MiB, expires pending records after seven days and stops after 1,000 connected delivery attempts. Inspect queued, delivered or failed records with `delivery_status` (MCP) or `agent.listDeliveries(messageId?)` (SDK). Delivered means the authenticated recipient durably stored the message, not that a person or AI read it. Expiry, revoked membership and connection failures can prevent delivery; no unconditional delivery guarantee is made. + +#### `send_state_message` Send an encrypted message to a group. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | yes | Group ID (hex) | +| stateId | string | yes | Group ID (hex) | | content | string | yes | Message text | #### `send_direct_message` @@ -104,61 +119,23 @@ Send an encrypted DM to a peer. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| peerPublicKey | string | yes | Recipient's Ed25519 public key (base64) | +| peerPublicKey | string | yes | Recipient's Ed25519 public key (hex) | | content | string | yes | Message text | +#### `delivery_status` +Read per-recipient queued, delivered or failed records. Optional messageId selects one outbound message; omission lists retained delivery records. Delivered means durable recipient acceptance, not reading. + #### `read_messages` -Read recent messages from a group or DM conversation. +Read recent messages from exactly one state or DM conversation, newest first. Supply exactly one of stateId or peerPublicKey. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| groupId | string | no | Group ID (hex). Omit for DM. | -| peerPublicKey | string | no | Peer key (base64). Omit for group. | -| limit | number | no | Max messages to return. Default: 20. | +| stateId | string | no | Group ID (hex). Omit for DM. | +| peerPublicKey | string | no | Peer key (hex). Omit for group. | +| limit | number | no | Integer from 1 to 500. Default: 50. | | before | string | no | Message ID for pagination. | -Returns: array of messages with id, sender, content, timestamp. - ---- - -### TTYA - -#### `ttya_start` -Start the TTYA web server. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| port | number | no | Server port. Default: 3000. | -| autoApprove | boolean | no | Auto-approve all visitors. Default: false. | - -Returns: URL, status. - -#### `ttya_pending` -List visitors waiting for approval. - -Returns: array with visitorId, first message, timestamp, ip hash. - -#### `ttya_approve` -Approve a visitor to chat. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| visitorId | string | yes | Visitor ID | - -#### `ttya_reject` -Reject a visitor. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| visitorId | string | yes | Visitor ID | - -#### `ttya_reply` -Send a reply to an approved TTYA visitor. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| visitorId | string | yes | Visitor ID | -| content | string | yes | Reply text | +Returns: messages with id, senderPublicKey (hex, when present), content, timestamp and type. --- @@ -174,47 +151,51 @@ Mark a peer as trusted. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| peerPublicKey | string | yes | Peer's Ed25519 public key (base64) | +| peerPublicKey | string | yes | Peer's Ed25519 public key (hex) | --- +### Public discovery + +#### `discover_states` +List public states discovered by this agent. Returns states with stateId (hex), name, selfMd and memberCount. + +#### `join_public_state` +Join a public state using a stateId (hex) returned by discover_states. This agent must have discovered the state; no private invitation is needed. Returns success and stateId. + +#### `make_state_public` +Publish an existing state with its manifesto. Requires stateId (hex) and selfMd (string). Returns success and stateId. + +#### `found_public_state` +Create a public state with name and selfMd (strings). Returns stateId (hex), name, selfMd and isPublic. + ## Resources | URI | Description | |-----|-------------| | `agent://identity` | Current agent identity and fingerprint | -| `agent://groups` | All groups with member counts | +| `agent://states` | All groups with member counts | +| `agent://discovered-states` | Public states discovered from other agents | | `agent://peers` | Known peers with online status | -| `agent://messages/{groupId}` | Recent messages in a group | +| `agent://messages/{stateId}` | Recent messages in a group | ## Example Session ``` User: Initialize my agent as "Sheva" → agent_init(displayName: "Sheva") -← Identity created. Fingerprint: 5kx8m3nq2p7... +← Saved displayName: Sheva. Fingerprint: 5kx8m3nq2p7... User: Create a group called "builders" -→ group_create(name: "builders") +→ state_found(name: "builders") ← Group created. ID: a1b2c3... Topic joined. -User: Invite my friend's agent (key: base64...) -→ group_invite(groupId: "a1b2c3...", peerPublicKey: "base64...") +User: Invite my friend's agent (key: hex...) +→ state_invite(stateId: "a1b2c3...", peerPublicKey: "hex...") ← Invitation sent. User: Send "gm builders" to the group -→ send_group_message(groupId: "a1b2c3...", content: "gm builders") -← Message sent (encrypted, chainIndex: 0). - -User: Start TTYA so people can talk to me -→ ttya_start(port: 3000) -← TTYA running at http://localhost:3000/talk/5kx8m3nq2p7... - -User: Any visitors? -→ ttya_pending() -← 1 pending: visitor anon-7f3a says "Hey, saw your project" +→ send_state_message(stateId: "a1b2c3...", content: "gm builders") +← { accepted: true, messageId: "..." } -User: Approve them and say hi -→ ttya_approve(visitorId: "anon-7f3a") -→ ttya_reply(visitorId: "anon-7f3a", content: "Hey! Welcome.") ``` diff --git a/docs/PROTOCOL-V2-MIGRATION.md b/docs/PROTOCOL-V2-MIGRATION.md new file mode 100644 index 0000000..5bc7220 --- /dev/null +++ b/docs/PROTOCOL-V2-MIGRATION.md @@ -0,0 +1,61 @@ +# Protocol v2 authentication migration + +Protocol v2 is a wire-breaking, fail-closed upgrade. A v2 node advertises +`protocolVersion: 2` during `IdentityHandshake` and rejects v1 handshakes; there is no +downgrade mode. Deploy peers that need to communicate with one another in the same +maintenance window. + +## Wire changes + +Every CBOR message map has an exhaustive schema. Missing or unknown fields, invalid enum +values, unsafe or out-of-range integers, oversized UTF-8/binary values, duplicate entries, +and incorrectly sized keys, nonces, hashes, or signatures are rejected before routing. +Nested group epochs and genesis anchors are decoded and verified at the same boundary. + +- `SenderKeyDistribution` is now only the opaque v2 recipient envelope expected from the + `security-02-sender-keys` integration: `recipientPublicKey`, `nonce`, `ciphertext`, and + `timestamp`. This branch never serializes or broadcasts plaintext chain keys and does not + duplicate the envelope encryption implementation from branch 02. +- `GroupMessage` signs the current `epochVersion` and `epochHash` in addition to its group, + sender, chain index, nonce, ciphertext, and timestamp. Unknown groups, missing epochs, + stale epoch contexts, and senders removed from the current epoch are rejected. +- `DirectMessage` and `GroupManagement` bind both authenticated session identities. Invites + additionally carry an exact signed genesis v0 anchor; an unpinned group cannot be joined. +- `NetworkAnnounce` v2 uses a fixed domain-separated canonical payload, sorted unique group + IDs, and a signed genesis anchor for every advertised group. The announcer must be the + authenticated genesis creator. +- `GroupEpoch` uses a domain-separated/versioned canonical immutable epoch containing + `createdAt`. Delivery uses a separate v2 recipient-bound signed envelope with a fresh + `timestamp`, allowing an old epoch to be sent during catch-up without changing its hash or + epoch signature. + +Byte strings are normalized before canonical authentication, so Node `Buffer` and plain +`Uint8Array` values produce identical signatures across a CBOR round-trip. + +## Replay durability and bounds + +Authentication produces a replay reservation but does not consume it. The reservation is +inserted and promoted to `accepted` in the same SQLite transaction as the ratchet, +sender-key, epoch, discovery, and message state mutation. A missing ratchet/key, failed +decrypt, validation error, thrown callback, or process crash rolls the whole transaction +back; an authentic frame can be retried instead of being permanently poisoned. + +Accepted replay rows expire after ten minutes. Expired rows are pruned on acceptance, and +the ledger rejects new reservations above 2,048 live rows per sender or 100,000 globally. +These limits exceed the five-minute wire freshness window while bounding disk and lookup +cost under adversarial traffic. + +## Storage migration + +Database migration 6 upgrades every previously shipped schema-v5 variant, including the +four-column replay ledger, with transactional state and expiry columns. Existing replay rows +are retained as accepted entries with bounded expiry. Independently shipped v5 identity, +group/discovery, sender-key, and Noise columns are reconciled by schema inspection before the +shared v6 layout is applied. + +Migration 7 quarantines legacy v4 epoch rows whose map-based `timestamp` encoding cannot be +verified as the canonical v2 `createdAt` encoding. Their original bytes and signatures remain +available in `quarantined_group_epochs`, but they are never treated as v2 trust anchors or sent +on the wire. Affected groups stay unpinned and offline until an authenticated v2 invite or +verified public announcement supplies a canonical genesis. Populated v4 and replay-v5 fixtures +cover both upgrade paths. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 96bee76..2a10615 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,6 +1,6 @@ # Protocol Specification -Version: 1.0-draft +Version: 3.0-draft (identity handshake and delivery envelope; Sender Keys remain v2) ## Wire Format @@ -19,17 +19,19 @@ Maximum frame size: 1 MB (1,048,576 bytes). Frames exceeding this are rejected a Each CBOR payload is a map with a `type` field (uint8) that determines the message structure. -| Type | Name | Direction | Description | -|------|------|-----------|-------------| -| 0x01 | IdentityHandshake | Bidirectional | Exchange Ed25519 identities | -| 0x02 | GroupSync | Bidirectional | Share group membership | -| 0x03 | SenderKeyDistribution | Sender → Recipient | Deliver group encryption key | -| 0x04 | GroupMessage | Sender → Group peers | Encrypted group message | -| 0x05 | DirectMessage | Sender → Recipient | Encrypted 1-to-1 message | -| 0x06 | GroupManagement | Varies | Group admin operations | -| 0x07 | TTYARequest | TTYA Server → Agent | Visitor message for approval | -| 0x08 | TTYAResponse | Agent → TTYA Server | Approval decision + reply | -| 0xFF | Ack | Recipient → Sender | Delivery acknowledgment | +| Type | Name | Direction | Description | +| ---- | --------------------- | -------------------- | ---------------------------- | +| 0x01 | IdentityHandshake | Bidirectional | Exchange Ed25519 identities | +| 0x02 | GroupSync | Bidirectional | Share group membership | +| 0x03 | SenderKeyDistribution | Sender → Recipient | Deliver group encryption key | +| 0x04 | GroupMessage | Sender → Group peers | Encrypted group message | +| 0x05 | DirectMessage | Sender → Recipient | Encrypted 1-to-1 message | +| 0x06 | GroupManagement | Varies | Group admin operations | +| 0x07 | TTYARequest | TTYA Server → Agent | Visitor message for approval | +| 0x08 | TTYAResponse | Agent → TTYA Server | Approval decision + reply | +| 0x09 | NetworkAnnounce | Peer → Peer | Signed public group discovery| +| 0x0a | GroupEpoch | Admin → Group peers | Signed group state snapshot | +| 0xFF | Ack | Recipient → Sender | Delivery acknowledgment | ## Connection Handshake @@ -41,21 +43,52 @@ After Hyperswarm establishes a Noise-encrypted connection, both peers must compl { type: 0x01, edPublicKey: Uint8Array, // 32 bytes, Ed25519 public key + xPublicKey: Uint8Array, // 32 bytes, X25519 public key used for DMs noisePublicKey: Uint8Array, // 32 bytes, Noise key from Hyperswarm - signature: Uint8Array, // Ed25519 signature over noisePublicKey + signature: Uint8Array, // Ed25519 signature over the bound transcript displayName?: string, // optional human-readable name - protocolVersion: number, // 1 for V1 + protocolVersion: number, // 3; other versions are incompatible + capabilities: ["sender-key-v2", "group-epoch-v1", "group-metadata-v1", "reliable-delivery-v1"], timestamp: number // unix ms, must be within ±5 min of local time } ``` **Verification:** -1. Verify `ed25519.verify(signature, noisePublicKey, edPublicKey)` is true -2. Verify `noisePublicKey` matches the Noise key from the Hyperswarm connection + +Version 3 requires all participants to upgrade; versions 1 and 2 cannot connect. A peer that sends any +other `protocolVersion` fails the handshake explicitly and its stream is destroyed. +The v3 capability profile is fixed: peers must advertise exactly `sender-key-v2`, +`group-epoch-v1`, `group-metadata-v1` and `reliable-delivery-v1`. Missing, duplicate, +or unknown capabilities fail the handshake; there is no silent downgrade. + +The signature covers exactly 178 canonical bytes; no CBOR encoding, field lengths, or +optional fields participate in this transcript: + +| Offset | Size | Encoding | Value | +| ------ | ---- | ----------- | ----------------------------------------- | +| 0 | 38 | ASCII bytes | `network.self.md/identity-handshake/v3\0` | +| 38 | 4 | uint32 BE | `protocolVersion` (= 3) | +| 42 | 32 | raw bytes | sender's `noisePublicKey` | +| 74 | 32 | raw bytes | sender's `xPublicKey` | +| 106 | 8 | uint64 BE | `timestamp` in Unix milliseconds | +| 114 | 64 | raw bytes | connection's Noise `handshakeHash` | + +1. Require protocol version 3 and exact key/signature/transcript field lengths +2. Verify `noisePublicKey` matches `socket.remotePublicKey` 3. Verify `timestamp` is within ±300,000 ms of local time -4. If any check fails, drop the connection +4. Verify the Ed25519 signature over the full transcript, including `socket.handshakeHash` +5. Reject a second identity handshake on an already-verified connection +6. If any check fails, drop the connection + +Before authentication, any application frame is a protocol violation and destroys the +stream; such frames are never accumulated for later replay. Once the handshake has been +validated, a post-handshake tail may arrive before routing is installed (including split +transport chunks). That tail is bounded to 65,536 framed bytes and released atomically by +the transition to `ready`; any excess destroys the stream. -This binds the transport-layer Noise identity to the application-layer Ed25519 identity. +This binds the transport-layer Noise identity and DM key to the application-layer +Ed25519 identity, and prevents a captured handshake from being replayed on another +Noise connection. ### GroupSync (0x02) @@ -70,6 +103,7 @@ Sent immediately after both sides complete IdentityHandshake. ``` **Processing:** + 1. Each side compares received hashes against their own group membership 2. Intersection = shared groups 3. For each shared group, exchange SenderKeyDistribution if the peer doesn't have our latest chain key @@ -83,58 +117,89 @@ Encrypted to the specific recipient using pairwise X25519. ```typescript { type: 0x03, - groupId: Uint8Array, // 32 bytes - chainKey: Uint8Array, // 32 bytes, encrypted - chainIndex: number, // current position in chain - signingPublicKey: Uint8Array, // 32 bytes, sender's Ed25519 key - encryptedPayload: Uint8Array, // XChaCha20-Poly1305 ciphertext - nonce: Uint8Array, // 24 bytes - ephemeralPublicKey: Uint8Array // 32 bytes, for X25519 key exchange + protocolVersion: 2, + recipientPublicKey: Uint8Array, // 32-byte recipient Ed25519 key + ciphertext: Uint8Array, // encrypted payload below + nonce: Uint8Array, // 24 bytes + timestamp: number +} + +// XChaCha20-Poly1305 plaintext (never exposed on the wire) +{ + protocolVersion: 2, + groupId: Uint8Array, + generationId: Uint8Array, // 16-byte random identifier, replaced on rotation + sequence: number, // durable, strictly increasing per group/sender + chainKey: Uint8Array, + chainIndex: number, + signingPublicKey: Uint8Array, + epochVersion: number, + epochHash: Uint8Array, + timestamp: number } ``` **Key exchange for distribution:** + ``` sharedSecret = x25519(sender.xPrivateKey, recipient.xPublicKey) -encryptionKey = hkdf(sha256, sharedSecret, "networkselfmd-skd-v1", "", 32) -encryptedPayload = xchacha20poly1305(encryptionKey, nonce).encrypt(chainKey || uint32(chainIndex)) +context = type || protocolVersion || sender.edPublicKey || recipient.edPublicKey || timestamp +encryptionKey = hkdf(sha256, sharedSecret, + "networkselfmd-sender-key-distribution-key-v1", context, 32) +aad = "networkselfmd-sender-key-distribution-aad-v1" || context +ciphertext = xchacha20poly1305(encryptionKey, nonce, aad).encrypt(payload) ``` +The receiver derives the key from the authenticated session's X25519 key, +requires `signingPublicKey` to equal `session.peerPublicKey`, and accepts the +payload only when sender and recipient are members of the referenced latest +signed epoch. Unknown groups, stale epochs, and nonmembers are rejected without +storing any sender-key record. The receiver durably rejects a sequence that is not newer +than the stored sequence and rejects a chain-index rollback within one generation. + +### NetworkAnnounce (0x09) + +Network announcements use a fixed binary signing payload prefixed with +`network.self.md/NetworkAnnounce/v2\0`. Groups are sorted by their 32-byte ID and all +strings/blobs use explicit big-endian length prefixes. Each entry includes the exact signed +genesis epoch (v0, zero previous hash, one creator-admin); the authenticated announcer must +be that creator. Receivers enforce schema and size limits, ±5 minute freshness, monotonic +durable replay state, 10 announcements/minute/peer, 64 groups/message, and a bounded +1,000-row/24-hour discovery cache. An existing group ID can only be updated by the pinned +creator and genesis hash. + ### GroupMessage (0x04) ```typescript { type: 0x04, - id: string, // unique message ID (cuid2) groupId: Uint8Array, // 32 bytes - senderPublicKey: Uint8Array, // 32 bytes, Ed25519 + senderFingerprint: string, // authenticated session identity chainIndex: number, // sender's chain position + generationId: Uint8Array, // 16-byte current sender-key generation + epochVersion: number, // must equal current epoch + epochHash: Uint8Array, // must equal current epoch hash nonce: Uint8Array, // 24 bytes, random ciphertext: Uint8Array, // XChaCha20-Poly1305 - signature: Uint8Array, // Ed25519 over (groupId || chainIndex || nonce || ciphertext) + signature: Uint8Array, // Ed25519 over the domain-separated v2 payload timestamp: number // unix ms } ``` **Encryption:** + ``` -messageKey = hkdf(sha256, chainKey[chainIndex], "networkselfmd-msg-v1", "", 32) -chainKey[chainIndex + 1] = hkdf(sha256, chainKey[chainIndex], "networkselfmd-chain-v1", "", 32) -ciphertext = xchacha20poly1305(messageKey, nonce).encrypt(cbor(payload)) -signature = ed25519.sign(sha256(groupId || uint32(chainIndex) || nonce || ciphertext), edPrivateKey) +{ messageKey, nextChainKey } = advanceChain(chainKey[chainIndex]) +aad = groupId || sender.edPublicKey || generationId || epochVersion || epochHash || chainIndex +ciphertext = xchacha20poly1305(messageKey, nonce, aad).encrypt(utf8(content)) +signature = ed25519.sign(canonicalAuthenticatedV2Payload, edPrivateKey) ``` -**Payload (plaintext before encryption):** -```typescript -{ - content: string, // message text - contentType: "text/plain", // MIME type for extensibility - replyTo?: string, // message ID being replied to - metadata?: Record -} -``` +The encrypted plaintext is the UTF-8 message content. The AEAD associated data binds +the group, authenticated sender key, sender-key generation, epoch, and chain index. **Decryption:** + 1. Look up sender's SenderKeyRecord for this group 2. If `chainIndex > record.chainIndex`: advance chain, cache skipped keys (max 256) 3. Derive messageKey from the correct chainKey position @@ -147,14 +212,21 @@ signature = ed25519.sign(sha256(groupId || uint32(chainIndex) || nonce || cipher ```typescript { type: 0x06, - action: "create" | "invite" | "accept" | "kick" | "leave" | "update", + action: "create" | "invite" | "accept" | "sync-request" | + "join" | "leave" | "kick" | "promote", groupId: Uint8Array, - actor: Uint8Array, // Ed25519 public key of who performed the action - target?: Uint8Array, // Ed25519 public key of target (for invite/kick) - name?: string, // for create/update - nonce?: Uint8Array, // for create (32 random bytes) + targetFingerprint?: string, // required for invite/accept/kick/promote + groupName?: string, // required for invite + inviteId?: string, // required for invite/accept + epochVersion?: number, // required for invite/accept/sync-request + epochHash?: Uint8Array, // required for invite/accept/sync-request + genesisEpochData?: Uint8Array, // exact signed v0 anchor; invite only + genesisSignature?: Uint8Array, + genesisHash?: Uint8Array, + senderFingerprint: string, // authenticated session sender + recipientFingerprint: string, // recipient-bound delivery timestamp: number, - signature: Uint8Array // Ed25519 over entire message (excluding signature field) + signature: Uint8Array // Ed25519 over the canonical v2 payload } ``` @@ -166,18 +238,84 @@ signature = ed25519.sign(sha256(groupId || uint32(chainIndex) || nonce || cipher | accept | Invited agent | | kick | Admin only | | leave | Any member | -| update | Admin only | +| promote | Admin only | **Group ID derivation:** + ``` groupId = sha256(creator.edPublicKey || uint64(timestamp) || nonce) ``` **Topic derivation:** + ``` topic = hkdf(sha256, groupId, "networkselfmd-topic-v1", "", 32) ``` +### GroupEpoch (0x0a) + +A signed snapshot of group state, forming a hash chain. Every group mutation (create, invite, kick, promote, setPublic) produces a new epoch. + +```typescript +{ + type: 0x0a, + protocolVersion: 2, + groupId: Uint8Array, + epochData: Uint8Array, // canonical immutable epoch below + signature: Uint8Array, // immutable epoch signature + hash: Uint8Array, // immutable epoch hash + senderFingerprint: string, + recipientFingerprint: string, + envelopeSignature: Uint8Array, // recipient-bound delivery signature + timestamp: number // fresh delivery time +} + +// Decoded epochData: +{ + version: number, // 0 for genesis, increments by 1 + prevHash: Uint8Array, // 32 bytes, SHA-256 of previous epoch (zeros for genesis) + groupId: string, // hex group identifier + members: Array<{ + publicKey: Uint8Array, // Ed25519 public key + role: "admin" | "member" + }>, + createdAt: number, // immutable unix ms + createdBy: Uint8Array // 32 bytes, admin's Ed25519 public key +} +``` + +**Epoch hash:** + +``` +epochHash = sha256(canonical("network.self.md/GroupEpoch", 1, groupId, version, prevHash, members, createdAt, createdBy)) +``` + +**Genesis epoch (version 0):** +On group creation, the creator produces a genesis epoch with `prevHash = zeros(32)`, exactly +one member entry (the creator as admin), signed with the creator's Ed25519 key. Its creator +key and hash are pinned before any later epoch is accepted; a conflicting v0 or same-version +overwrite is rejected. + +**Subsequent epochs:** +Each group mutation creates a new epoch: `version = previous.version + 1`, `prevHash = hash(previous)`, updated member list, signed by the admin performing the action. + +**Verification rules:** + +1. Verify `ed25519.verify(signature, cbor(epochData), createdBy)` is true +2. Verify `createdBy` is an admin in the previous epoch (or is the creator for genesis) +3. Verify `prevHash` matches `sha256(cbor(previousEpoch))` +4. Verify `version = previousEpoch.version + 1` (or 0 for genesis) +5. If any check fails, reject the epoch and the associated management message + +**Sender key gating:** +SenderKeyDistribution messages are rejected from peers not present in the latest epoch's member list. + +Invites are persisted as pending and do not mutate membership. The invitee explicitly +accepts; only then does the admin append the membership epoch and send the full chain. +On reconnect, members exchange their latest version and synchronize missing epochs offline. +Legacy local sender-key rows are migrated to a fresh v2 generation; legacy wire envelopes +are never accepted. + ## Direct Messages ### DirectMessage (0x05) @@ -202,14 +340,69 @@ Uses Double Ratchet for forward secrecy. **Session initialization:** On first connection between two peers (after IdentityHandshake), both derive a shared secret: + ``` sharedSecret = x25519(myXPrivateKey, peer.xPublicKey) rootKey = hkdf(sha256, sharedSecret, "networkselfmd-dm-v1", "", 32) ``` + The peer with the lexicographically smaller Ed25519 public key initiates the first DH ratchet step. +## Reliable delivery + +`ReliableDelivery` (0x0c) wraps an authenticated DirectMessage or GroupMessage with id, senderFingerprint, recipientFingerprint, contentHash, createdAt, expiresAt, timestamp and an Ed25519 signature. `DeliveryReceipt` (0x0d) carries id, senderFingerprint, recipientFingerprint, timestamp and a signature. Signatures use the `networkselfmd-reliable-delivery-v1` domain; the delivery signature also binds the inner authenticated message and its signature. + +A receipt follows durable recipient storage and deduplication. Retries reuse the outbound message ID; duplicate reception does not add another application message. Group dispatch rechecks membership against the current epoch. The local queue is bounded to 1,000 active per-recipient records and 64 MiB, with seven-day expiry and at most 1,000 connected attempts. Receipt confirms storage, not reading. All peers must support handshake version 3; there is no downgrade to unacknowledged delivery. + ## TTYA Protocol +TTYA uses length-prefixed JSON frames on its dedicated Hyperswarm connection. +Each frame has a 4-byte big-endian payload length and a maximum payload of 64 +KiB. Before either side accepts an application frame, the bridge and agent +complete this mutual HMAC-SHA256 handshake. `channelBinding` is the local +`@hyperswarm/secret-stream` Noise `handshakeHash`; it is never accepted from +the peer: + +```text +Agent -> Bridge: challenge(agentNonce) +Bridge -> Agent: response(agentNonce, bridgeNonce, bridgeProof) +Agent -> Bridge: confirmation(agentNonce, bridgeNonce, agentProof) + +transcript(role) = + "networkselfmd-ttya-auth-v3" || 0x00 || "proof" || 0x00 || role || 0x00 || + hex_decode(agentNonce) || hex_decode(bridgeNonce) || + uint16be(len(channelBinding)) || channelBinding + +bridgeProof = HMAC-SHA256(authSecret, transcript("bridge")) +agentProof = HMAC-SHA256(authSecret, transcript("agent")) +``` + +Both nonces are 32 fresh random bytes encoded as lowercase hexadecimal. Proofs +are checked in constant time, the roles provide reflection resistance, and the +handshake must finish within five seconds. The agent accepts no requests before +verifying `bridgeProof`; the bridge releases no queued or future visitor +requests, and accepts no approve/reject/reply responses, before verifying +`agentProof`. Authentication state and partial frames are discarded on every +disconnect. Because independent Noise connections have different transcript +hashes, an attacker cannot relay the challenge and proofs through a second +socket. + +After confirmation, both sides derive a session key with HMAC-SHA256 over the +same nonces and channel binding under the `session-key` domain. Every +TTYARequest and TTYAResponse is then wrapped in a `ttya-data` frame containing +an exact-direction label, a monotonically increasing uint64 sequence, the +base64 JSON payload, and an HMAC made with that session key. Replays, +out-of-order frames, unwrapped application messages, and messages copied to a +different Noise connection close the socket. + +The length parser is incremental: split headers and payloads are retained, +coalesced frames are drained in order, and a zero or greater-than-64-KiB +advertised length is rejected before allocating the body. Authentication must +finish in five seconds. An incumbent socket, including one still +authenticating, is never displaced by a new candidate; failed authentication +is subject to per-Noise-key exponential backoff plus a global sliding-window +limit. + ### TTYARequest (0x07) Sent from TTYA Server to Agent Node. @@ -258,13 +451,16 @@ Sent from Agent Node to TTYA Server. ### Periodic Rotation -Every 100 messages or 24 hours (whichever comes first), a sender generates a new `chainKey_0` and distributes it to all group members. +After 100 actual encryptions in the persisted generation or when its generation reaches 24 hours, a sender generates a new `chainKey_0` and distributes it to group members. + +Rotation age is persisted per sender-key generation. While the agent runs, a one-minute timer checks the 24-hour threshold; startup and sending also check for overdue generations. An offline agent rotates when restarted. The 100-encryption threshold uses the persisted sender chain index and survives restart. Per-recipient encryptions and re-encrypted retries count toward this threshold; it is not a count of user-authored messages. ### Post-Removal Rotation When a member is kicked or leaves a group, ALL remaining members must rotate their sender keys immediately. This ensures the departed member cannot decrypt future messages (they knew everyone's chain keys up to the point of departure). **Rotation protocol:** + 1. Admin sends `GroupManagement.kick` to all members 2. Each member generates new `chainKey_0` 3. Each member sends `SenderKeyDistribution` to all remaining members @@ -272,14 +468,14 @@ When a member is kicked or leaves a group, ALL remaining members must rotate the ## Error Handling -| Condition | Action | -|-----------|--------| -| Unknown message type | Log warning, ignore message | -| Failed signature verification | Drop message, log alert | -| Unknown group | Ignore message (not a member) | -| Unknown sender in group | Ignore message (not in membership list) | -| Chain index too far ahead (>256 skip) | Request SenderKeyDistribution re-send | -| Decryption failure | Log error, request key re-distribution | -| Frame too large (>1MB) | Drop connection | -| Handshake timeout (>10s) | Drop connection | -| Timestamp drift (>5min) | Reject message | +| Condition | Action | +| ------------------------------------- | --------------------------------------- | +| Unknown message type | Log warning, ignore message | +| Failed signature verification | Drop message, log alert | +| Unknown group | Ignore message (not a member) | +| Unknown sender in group | Ignore message (not in membership list) | +| Chain index too far ahead (>256 skip) | Request SenderKeyDistribution re-send | +| Decryption failure | Log error, request key re-distribution | +| Frame too large (>1MB) | Drop connection | +| Handshake timeout (>10s) | Drop connection | +| Timestamp drift (>5min) | Reject message | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 216e5ff..a1e3f2a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,15 +4,15 @@ All cryptography uses audited, constant-time implementations from the [@noble](https://paulmillr.com/noble/) family. -| Primitive | Library | Purpose | -|-----------|---------|---------| -| Ed25519 | @noble/curves | Digital signatures, identity | -| X25519 | @noble/curves | Diffie-Hellman key exchange | -| XChaCha20-Poly1305 | @noble/ciphers | Authenticated encryption (AEAD) | -| SHA-256 | @noble/hashes | Hashing | -| HMAC-SHA256 | @noble/hashes | Message authentication | -| HKDF-SHA256 | @noble/hashes | Key derivation | -| Argon2id | hash-wasm | Passphrase-based key derivation (key storage) | +| Primitive | Library | Purpose | +| ------------------ | -------------- | --------------------------------------------- | +| Ed25519 | @noble/curves | Digital signatures, identity | +| X25519 | @noble/curves | Diffie-Hellman key exchange | +| XChaCha20-Poly1305 | @noble/ciphers | Authenticated encryption (AEAD) | +| SHA-256 | @noble/hashes | Hashing | +| HMAC-SHA256 | @noble/hashes | Message authentication | +| HKDF-SHA256 | @noble/hashes | Key derivation | +| Argon2id | hash-wasm | Passphrase-based key derivation (key storage) | No custom cryptography. No OpenSSL. No WebCrypto. The @noble libraries are pure JavaScript, audited, and used across the ecosystem. @@ -26,15 +26,25 @@ No custom cryptography. No OpenSSL. No WebCrypto. The @noble libraries are pure ### Two-Layer Identity -Hyperswarm uses its own Noise keypair for transport encryption. This is separate from the Ed25519 agent identity. +Hyperswarm uses its own Noise keypair for transport encryption. This is separate from +the Ed25519 agent identity. + +**Binding:** On every connection, the first message is an IdentityHandshake where each +side signs a domain-separated transcript containing its local Noise public key, X25519 +key, protocol version, timestamp, and the unique Noise handshake hash with its Ed25519 +private key. The receiver requires the claimed Noise key to match +`socket.remotePublicKey`. This proves: -**Binding:** On every connection, the first message is an IdentityHandshake where each side signs their Noise public key with their Ed25519 private key. This proves: - The Noise connection endpoint controls the Ed25519 identity - No MITM can substitute a different Ed25519 identity +- A captured identity handshake cannot be replayed on another Noise connection + +The peer database pins the first accepted Noise/Ed25519 key mapping. Later display-name +updates are allowed, while changes to either side of the pinned key mapping are rejected. ### Key Storage -Private keys are encrypted at rest: +When a passphrase is configured, private keys are encrypted at rest: ``` salt = random(32 bytes) @@ -44,11 +54,30 @@ ciphertext = xchacha20poly1305(wrappingKey, nonce).encrypt(edPrivateKey) stored = (salt, nonce, ciphertext) ``` +The identity row then contains only the public key and metadata. Passphrases +must be at least 12 characters with at least four distinct characters. Starting a +passphrase-protected identity without its passphrase, or with an incorrect +passphrase, fails closed. Existing plaintext identities are upgraded in a +compare-and-swap SQLite transaction that stores the authenticated encrypted +copy before clearing plaintext. Startup then requires a successful bounded +`wal_checkpoint(TRUNCATE)` and verifies that the private-key bytes are absent +from the database, WAL, and SHM files. A busy checkpoint fails closed and a +later startup can finish the cleanup. The data directory is restricted to +`0700`, and the database plus WAL/SHM sidecars to `0600` on POSIX systems. + +For unattended MCP and dashboard processes, set `L2S_PASSPHRASE_FILE` to a +mounted secret file. `L2S_PASSPHRASE` is supported when a secret file is not +available. The CLI supports `--passphrase` for a no-echo interactive prompt and +`--passphrase-file ` for automation; passphrases are never printed or +included in startup logs. No-passphrase mode remains available when explicitly +used. + ## Encryption Layers ### Layer 1: Transport (Noise Protocol) Every Hyperswarm connection is encrypted with the Noise protocol (XX handshake pattern). This provides: + - Confidentiality of all traffic - Mutual authentication of Noise keypairs - Forward secrecy per connection @@ -65,6 +94,7 @@ On top of Noise, group messages are encrypted with the Sender Keys protocol: ### Layer 3: Direct Messages (Double Ratchet) 1-to-1 messages use the Double Ratchet: + - New DH ratchet step on each direction change - Chain ratcheting within a direction - Forward secrecy: compromised keys don't expose past messages @@ -72,21 +102,25 @@ On top of Noise, group messages are encrypted with the Sender Keys protocol: ## Forward Secrecy Properties -| Scenario | Group (Sender Keys) | DM (Double Ratchet) | -|----------|-------------------|---------------------| +| Scenario | Group (Sender Keys) | DM (Double Ratchet) | +| ---------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | | Key compromise (current chain key) | Past messages safe, future messages from this sender exposed until rotation | Past messages safe, future messages safe after next ratchet step | -| Key rotation trigger | Every 100 messages or 24h | Every direction change | -| Member removal | All members rotate immediately | N/A | +| Key rotation trigger | Every 100 messages or 24h | Every direction change | +| Member removal | All members rotate immediately | N/A | ## Group Security ### Sender Key Distribution -Sender keys are distributed 1-to-1 to each group member, encrypted with a pairwise X25519 shared secret. An attacker who joins the network cannot obtain sender keys for groups they aren't a member of. +Sender keys are distributed 1-to-1 to each group member using X25519, HKDF, and +XChaCha20-Poly1305 with authenticated sender/recipient/version context. Every envelope is +bound to the current epoch, a random rotation generation, and a durable monotonic sequence; +replays and state rollback are rejected across restarts. ### Member Removal When a member is removed (kicked or leaves), all remaining members must: + 1. Delete the removed member's sender key record 2. Generate a fresh sender key (`chainKey_0`) 3. Distribute the new key to all remaining members @@ -96,38 +130,80 @@ This ensures the removed member cannot decrypt future messages. ### Admin Model V1 uses a simple admin model: + - Group creator = admin - Only admin can invite/kick - All management messages are Ed25519 signed - Members maintain and enforce the membership list locally +### Signed Group Epochs + +Group authorization is enforced via a cryptographically signed epoch chain (Signal v2-style). Each group mutation (invite, kick, promote, setPublic) produces a new `SignedGroupEpoch` containing the full member list, version number, and a SHA-256 hash linking it to the previous epoch. + +**What this prevents:** + +- **Unauthorized invite/kick:** Only an admin _in the previous epoch_ can sign a new epoch. A non-admin forging a management message will be rejected because their key is not listed as admin. +- **State forgery:** The `prevHash` chain ensures epochs are sequential and tamper-evident. Inserting, removing, or reordering epochs breaks the hash chain. +- **Phantom members:** Sender key distribution is gated on the latest epoch's member list. A peer not in the epoch cannot distribute keys or receive group messages. + +**Consistency guarantees:** +All members verify the same epoch chain. Because each epoch includes the complete member list and a back-link hash, any fork or inconsistency is detectable. Members that receive conflicting epochs reject the one that doesn't chain correctly. + +The exact signed genesis (version 0, zero previous hash, one creator-admin) is the trust +anchor pinned by an authenticated invite or public-group announcement. An invitation stays +pending until explicit acceptance and does not add the invitee to an epoch. Missing epochs +are synchronized after reconnect, and a removal epoch forces every remaining member to +delete old remote keys, create a new local generation, and redistribute it. + +**Backward compatibility:** +Groups created before epoch support fall back to local DB membership checks. A warning is logged to encourage migration. + ## TTYA Security ### Threat: Compromised TTYA Server The TTYA server (web bridge) is operated by the agent owner. If compromised: + - Attacker can see visitor messages in transit (not E2E encrypted from browser) -- Attacker cannot impersonate the agent (Ed25519 signature verification) +- Attacker can access the configured TTYA authentication secret until it is rotated - No historical messages exposed (server stores nothing) **Mitigation (V1):** Self-host the TTYA server. The Hyperswarm connection between server and agent is Noise-encrypted. +### Bridge/Agent Mutual Authentication + +The TTYA discovery topic is derived from public key material, so discovering or +joining it is not authentication. Protocol v3 binds the three-frame, two-nonce +HMAC-SHA256 exchange to the actual Noise `handshakeHash` on each socket. A +proof relayed across two independent connections therefore fails. After mutual +authentication, a channel-bound session key authenticates the direction, +sequence number, and payload of every application frame; plaintext, replayed, +or cross-session frames are rejected. + +The generic agent peer router does not join or accept the TTYA topic. TTYA is +enabled only by provisioning its dedicated manager with a PSK. New unauthenticated +sockets cannot evict an incumbent, authentication expires after five seconds, +and failed peers receive exponential backoff under per-peer and global rate +limits. Frames are capped at 64 KiB and parsed incrementally without trusting +their advertised allocation size. Proofs are checked in constant time and the +PSK is never logged. + **Future:** Implement noise-over-websocket for true E2E encryption from browser to agent. ### Rate Limiting -| Limit | Value | -|-------|-------| -| Messages per visitor | 1 per 3 seconds | -| Pending (unapproved) visitors | 10 max | -| Concurrent WebSocket connections | 100 max | -| Message size | 4 KB max | +| Limit | Value | +| -------------------------------- | --------------- | +| Messages per visitor | 1 per 3 seconds | +| Pending (unapproved) visitors | 10 max | +| Concurrent WebSocket connections | 100 max | +| Message size | 4 KB max | ### Visitor Privacy - Visitor IPs are hashed (SHA-256) before being sent to the agent owner - No cookies beyond session token (set after approval) -- No analytics, no tracking scripts +- No chat-content analytics; public dashboard pageviews are tracked separately - Visitor identity is ephemeral (random UUID per session) ## Known Limitations (V1) @@ -135,6 +211,7 @@ The TTYA server (web bridge) is operated by the agent owner. If compromised: ### Metadata Exposure Hyperswarm DHT reveals connection metadata: + - Which peers are connected to which topics - Connection timing and frequency - Data volume (not content) @@ -143,9 +220,9 @@ An observer on the DHT can see that Agent A and Agent B share a topic. They cann **Mitigation path:** Future versions may implement topic padding and dummy traffic. -### No Offline Messages +### Bounded offline delivery -V1 requires both peers to be online. Messages to offline peers are queued locally and delivered on reconnect, but there is no guaranteed delivery for long-offline agents. +Outbound messages use a local persistent queue. Acceptance returns a message ID, not proof of delivery. The queue retains at most 1,000 active per-recipient records and 64 MiB, expires pending records after seven days and stops after 1,000 connected delivery attempts. Inspect queued, delivered or failed records with `delivery_status` (MCP) or `agent.listDeliveries(messageId?)` (SDK). Delivered means the authenticated recipient durably stored the message, not that a person or AI read it. Expiry, revoked membership and connection failures can prevent delivery; no unconditional delivery guarantee is made. ### Group Size @@ -165,6 +242,8 @@ For anyone reviewing the implementation: - [ ] Chain keys are deleted after advancing (no key reuse) - [ ] Sender keys for removed members are deleted immediately - [ ] All remaining members rotate after any member removal +- [ ] Group epoch chain is verified on every management message (signature, prevHash, version, admin role) +- [ ] Sender key distribution is rejected from peers not in the latest epoch - [ ] Signatures are verified before decryption (sign-then-encrypt pattern) - [ ] Timestamp validation prevents replay attacks (±5 min window) - [ ] Private keys at rest are Argon2id-wrapped diff --git a/docs/TTYA.md b/docs/TTYA.md index 3542d28..7ef85f5 100644 --- a/docs/TTYA.md +++ b/docs/TTYA.md @@ -1,111 +1,8 @@ -# TTYA -- Talk To Your Agent +# TTYA — deferred -TTYA lets you share your AI agent with anyone through a web link. Visitors interact with your agent in a browser. You control who gets access. +TTYA is deferred and is not part of the supported product offering. No hosted browser-chat service is advertised, and TTYA tools are not exposed by the MCP server. Existing implementation details below are retained as an archival reference, not an onboarding guide. -## How It Works - -``` -You (Agent Owner) Visitor -───────────────── ─────── -1. Start TTYA server 1. Open link in browser -2. Get shareable link 2. Type a message -3. See incoming request 3. Wait for approval -4. Approve / Reject 4. Start chatting (if approved) -5. Monitor conversation -``` - -## Starting TTYA - -### CLI - -```bash -# Start TTYA server on port 3000 -networkselfmd ttya start --port 3000 - -# With auto-approve (for AI agents that can handle any input) -networkselfmd ttya start --port 3000 --auto-approve -``` - -### MCP (Claude Code) - -``` -> Start TTYA on port 3000 -# Calls ttya_start tool - -> Show pending visitors -# Calls ttya_pending tool - -> Approve visitor abc123 -# Calls ttya_approve tool -``` - -## Shareable Link - -After starting, you get a link: - -``` -https://ttya.self.md/5kx8m3nq2p7... - └─ your agent fingerprint -``` - -Or self-hosted: - -``` -https://your-domain.com/talk/5kx8m3nq2p7... -``` - -Share this link with anyone you want to talk to your agent. - -## Visitor Experience - -1. **Open link** -- minimal chat interface loads (no signup, no install) -2. **Type message** -- "Hi, I'd like to discuss the project proposal" -3. **Waiting** -- visitor sees "Waiting for approval..." -4. **Approved** -- chat opens, real-time conversation begins -5. **Rejected** -- visitor sees "The agent owner declined your request" - -The visitor page is intentionally minimal: a text input, a message list, a status indicator. No JavaScript frameworks, no build tools. Works on any browser. - -## Approval Flow - -When a visitor sends their first message: - -``` -┌─────────────────────────────────────────────┐ -│ New TTYA Request │ -│ │ -│ Visitor: anon-7f3a │ -│ Message: "Hi, I'd like to discuss the │ -│ project proposal" │ -│ Time: 2024-04-22 14:30 UTC │ -│ │ -│ [Approve] [Reject] [Block IP] │ -└─────────────────────────────────────────────┘ -``` - -The owner sees: -- Visitor ID (anonymous, random per session) -- First message content -- Hashed IP (for abuse detection, not tracking) -- Timestamp - -The owner can: -- **Approve** -- visitor can chat freely -- **Reject** -- visitor sees rejection, connection closed -- **Block** -- visitor's IP hash is blocked from future requests - -### Auto-Approve Mode - -For agents that should be publicly accessible: - -```bash -networkselfmd ttya start --auto-approve -``` - -All visitors are immediately approved. Useful when: -- Your agent is an AI that can handle any conversation -- You're running a public demo -- The agent has its own content filtering +## Archival implementation reference ## Architecture @@ -133,7 +30,7 @@ All visitors are immediately approved. Useful when: The TTYA server discovers the agent via a dedicated Hyperswarm topic: ```typescript -ttyaTopic = hkdf(sha256, agentEdPublicKey, "networkselfmd-ttya-v1", "", 32) +ttyaTopic = hkdf(sha256, agentEdPublicKey, "networkselfmd-ttya-v1", "", 32); ``` This topic is separate from group topics. Only the TTYA server and the agent node join it. @@ -165,35 +62,60 @@ Nothing. All messages are forwarded in memory and immediately discarded. ### Rate Limits -| Limit | Value | Purpose | -|-------|-------|---------| -| 1 msg / 3 sec per visitor | Prevent spam | | -| 10 pending visitors max | Prevent approval queue flood | | -| 100 WebSocket connections max | Prevent resource exhaustion | | -| 4 KB message size max | Prevent large payloads | | +| Limit | Value | Purpose | +| ----------------------------- | ---------------------------- | ------- | +| 1 msg / 3 sec per visitor | Prevent spam | | +| 10 pending visitors max | Prevent approval queue flood | | +| 100 WebSocket connections max | Prevent resource exhaustion | | +| 4 KB message size max | Prevent large payloads | | ## Configuration +### Authentication key + +TTYA requires one pre-shared key (PSK) for the agent manager and web bridge. +It must be at least 32 cryptographically random bytes; short secrets are +rejected, but operators remain responsible for using a CSPRNG rather than a +human-memorable value. Constructors defensively copy the key so later caller +mutation cannot change an active transport. + +The CLI loads a canonical hex/base64 `NETWORKSELFMD_TTYA_PSK` when set. +Otherwise it atomically creates and reuses the raw 32-byte +`$L2S_DATA_DIR/ttya.psk` file with owner-only permissions. `--psk-file` selects +a different raw key file. The same loaded byte string is passed to `Agent` +(`ttyaAuthSecret`) and `TTYAServer` (`ttyaAuthSecret`); never configure the two +sides independently in one deployment. + +For a separate web deployment, copy the raw PSK through the platform's secret +manager and provide identical bytes to the agent and server. Do not put it in +source control, command-line arguments, logs, or the share URL. + +### Rotation and protocol upgrade + +To rotate, stop both sides, generate a new 32-byte-or-longer random key, replace +the secret on the agent and bridge, and restart both. Rotation intentionally +drops active sessions and queued in-memory requests; mixed old/new keys fail +closed. Retire the old key only after confirming the new bridge reports an +authenticated connection. + +Protocol v3 is intentionally incompatible with the earlier unbound v2 +challenge-response. Upgrade the agent and web bridge together. A rolling +mixed-version deployment does not downgrade or fall back to the generic peer +transport; it remains disconnected until both sides run v3. + ```typescript interface TTYAConfig { - port: number; // default: 3000 - host: string; // default: "0.0.0.0" - autoApprove: boolean; // default: false + port: number; // default: 3000 + host: string; // default: "0.0.0.0" + autoApprove: boolean; // default: false maxPendingVisitors: number; // default: 10 - maxConnections: number; // default: 100 + maxConnections: number; // default: 100 rateLimit: { - messages: number; // default: 1 - perSeconds: number; // default: 3 + messages: number; // default: 1 + perSeconds: number; // default: 3 }; - messageMaxBytes: number; // default: 4096 - sessionTimeout: number; // default: 3600000 (1 hour) + messageMaxBytes: number; // default: 4096 + sessionTimeout: number; // default: 3600000 (1 hour) + ttyaAuthSecret: Uint8Array; // required, same >=32 random bytes on both sides } ``` - -## Future - -- **E2E encryption:** noise-over-websocket from browser to agent (eliminate TTYA server as trusted party) -- **Visitor identity:** optional Ed25519 keypair for returning visitors -- **Rich content:** file sharing, images, structured data -- **Agent-initiated:** agent can proactively send messages to approved visitors -- **Multi-agent:** visitor can talk to multiple agents in the same interface diff --git a/docs/deployment.md b/docs/deployment.md index 996e9ae..94ce8e4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,6 +21,9 @@ docker build -t networkselfmd-dashboard . docker run --rm \ -p 127.0.0.1:3001:3001 \ -v networkselfmd-data:/data \ + -e DASHBOARD_USERNAME=operator \ + -e DASHBOARD_PASSWORD_FILE=/run/secrets/dashboard-password \ + -v /secure/dashboard-password:/run/secrets/dashboard-password:ro \ networkselfmd-dashboard ``` @@ -33,7 +36,17 @@ See `.env.example`. - `PORT` — HTTP port, default `3001` - `HOST` — bind host, default `127.0.0.1` locally; Docker sets `0.0.0.0` inside the container and the example port mapping still binds to localhost on the host - `L2S_DATA_DIR` — persistent agent identity/network data directory +- `L2S_PASSPHRASE_FILE` — preferred path to a mounted identity passphrase secret +- `L2S_PASSPHRASE` — identity passphrase fallback when a secret file is unavailable - `AGENT_NAME` — display name announced by the dashboard agent +- `DASHBOARD_USERNAME` — HTTP Basic username; required for non-loopback `HOST` +- `DASHBOARD_PASSWORD_FILE` — preferred mounted password file (minimum 16 UTF-8 bytes) +- `DASHBOARD_PASSWORD` — password fallback; do not combine with the file option + +The dashboard serves `/healthz` without credentials for container probes. The +HTML application and every `/api/*` route require Basic authentication whenever +credentials are configured. Startup fails closed if `HOST` is non-loopback and +credentials are absent. ## Health checks diff --git a/docs/design/IMPLEMENTATION.md b/docs/design/IMPLEMENTATION.md new file mode 100644 index 0000000..cd6c973 --- /dev/null +++ b/docs/design/IMPLEMENTATION.md @@ -0,0 +1,67 @@ +# Network design-system implementation + +Implemented from **NETWORK-IMPLEMENTATION-HANDOFF, 5 September 2026, reference v2.2**. The supplied `reference/index.html`, `reference/selfmd-file.html`, and docs 01–07 were independently checked by three implementation agents. The HTML was ported to React components and scoped CSS, not embedded in an iframe. + +## Surfaces and routes + +| Surface | Local route | +| --- | --- | +| Public home, observation, walkthrough and presenter | `#/` | +| Public state / agent directory | `#/discover` (`/discover.html` entry retained) | +| Online agents | `#/discover?tab=agents&status=online` | +| Published record detail | `#/discover?state=` / `#/discover?tab=agents&peer=` | +| Personal self.md and local editor | `#/selfmd` | +| Enforcement definitions | `#/selfmd?section=enforcement` | +| Legacy file-page entry | `/selfmd-file.html#enforcement` | +| Existing operator overview | `#/operator` | +| Existing real public-state join flow | `#/operator/discover` | +| Existing state detail | `#/state/` (plural alias retained) | +| Existing operator tools | `#/wire`, `#/security`, `#/settings` | + +The existing TTYA visitor chat and Docusaurus documentation use the same visual tokens. The visitor chat script is unchanged; only markup, CSS and accessibility semantics changed. Existing documentation pages and navigation remain. + +## Visual implementation + +Paper `#F8F6F0`, carbon `#0C0D0E`, pink `#FF1484`, warm `#EBE7DD`, grey `#66655F`, line `#C8C5BD`. Lime is reserved for fresh observed presence. Anybody, Bricolage Grotesque, Inter and JetBrains Mono are self-hosted variable Latin fonts with their SIL OFL licenses. Other scripts retain system fallbacks. + +Reference SVG geometry, authored Ray examples, mobile scenes, exact section order and the closing “less platform. more people.” are retained. The loaded Anybody font required a small responsive headline-size adjustment to preserve the two authored hero lines. Small dark-panel labels and decorative card indices use higher-contrast existing tokens. + +One animation clock operates per mounted public page. Global pause, reduced motion, hidden-tab suspension, offscreen suspension, replay and the seven presenter stops are supported. Local scope approval is illustrative; no real files are opened, messages sent, or permissions changed. + +## Public observation boundary + +`public/observation.ts` provides one Zustand observation store for home and directory. Initial mode is real-feed loading. Demo is an explicit choice, never an error fallback. Snapshot imports keep their original capture timestamp. Unknown presence is not offline; no first observation is not a zero count. Polling is every 15 seconds in visible tabs, with one request at a time, a 6.5-second timeout and cancellation/generation protection when sources change. + +The server's `GET /api/public/network` uses `server/publicNetwork.ts`. Publication is empty by default. To authorize exact records for this node, set `NETWORK_PUBLICATION_CONFIG` to a **server-only** JSON file matching `packages/dashboard/publication.example.json`: + +- `stateIds` authorizes public state metadata; private states are excluded even if allowlisted. +- `stateContextIds` separately authorizes published shared self.md text. +- `peers` requires an approved label per exact fingerprint. Fingerprints remain pseudonymous unless explicitly authorized. +- No decrypted messages, private policy, inferred topology, fabricated latency or sync metrics enter the public DTO. +- Context exceeding 6,000 characters is withheld by the server. Oversized imported context is rejected with a visible error rather than silently presented as a complete original. + +Server authentication remains the default for the entire dashboard. An explicit `DASHBOARD_PUBLIC_SITE=true`, with operator credentials and `NETWORK_PUBLICATION_CONFIG`, permits anonymous static assets and the exact read-only public feed; operator APIs remain private. The browser intentionally omits credentials for this feed. `DASHBOARD_OPERATOR_ORIGIN` permits authenticated mutations from one configured remote origin. See [dashboard deployment](../DASHBOARD.md) for setup. Unmeasured synchronization and latency are returned as null, not fabricated values. + +## Optional public destinations + +Build-time `VITE_DASHBOARD_BASE_URL` overrides the functioning local `#/operator` destination. `VITE_SELFMD_FILE_URL` overrides local `#/selfmd`. Only HTTP(S) destinations are accepted. These values contain public URLs, never credentials. State links preserve the existing dashboard state-route contract; file links preserve the enforcement anchor. + +Without a separately configured creator, “create my self.md” opens the local editor. Drafts stay in the current tab; they are not sent to an API or stored in localStorage. Copy has a manual-selection fallback, and download produces `self.md`. + +## Verification + +- Full workspace test run: **428 passing tests** (core 124, node 143, MCP 17, web 42, dashboard 91, CLI 11). +- Dashboard client TypeScript and server builds; documentation build; full workspace production build passes. +- The compiled production bundle repeats the responsive, interaction and axe checks successfully. +- Populated public/private operator state pages, including long names, unbroken text and literal HTML-like messages: no overflow or page errors at 320/390/768/1440 px, no injected elements, no detected axe violations. +- Chromium responsive sweep: 320, 360, 390, 768, 1024 and 1440 px across eight dashboard routes; no horizontal overflow or page errors. +- TTYA populated chat: 320, 390, 1440 px; documentation: 320, 390, 1440 px. +- Directory: delayed deep links, history, focus restoration, 24/48/50 pagination, query validation, keyboard tabs, unknown presence, stale retention, snapshot import and private-record rejection. +- Editor: draft retention within the tab, Escape/backdrop, focus/scroll restoration, denied clipboard fallback and real browser download. +- Automated axe WCAG 2 A/AA and 2.1 AA checks: no detected violations on home, directory, self.md, operator overview, wire, security and settings. Automated checks are not a complete accessibility audit. +- Illustration tests assert that example approval and demo join do not invoke mutation APIs or modify observed membership. +- Timed Chromium motion audit: hero resets after approximately 18 seconds; paused SVG positions remain unchanged over 3.1 seconds; local pause/offscreen/replay work; the scope example completes in about 6.8 seconds including interaction overhead. Native background-tab visibility is not reproduced by headless Chrome; controlled visibility events and lifecycle tests pass. No production FPS or pixel-identical raster guarantee is made. + +Browser evidence was captured with explicitly selected example data or labeled QA fixtures. No production-user export or live usage claim was introduced. The existing analytics script in dashboard HTML was preserved. + +No files were staged, committed, pushed or deployed. Pre-existing local changes were retained. The existing nested documentation repository (shown as untracked by the parent repository) is not added to the root lockfile as an unrelated dependency expansion; the lockfile change adds only Zustand. diff --git a/docs/releases/2026-09-07-integration.md b/docs/releases/2026-09-07-integration.md new file mode 100644 index 0000000..82135c4 --- /dev/null +++ b/docs/releases/2026-09-07-integration.md @@ -0,0 +1,52 @@ +# Integration release candidate + +Source candidate: `f96b1a92e694c4d0593888b78ed0adee7bfa522e`. +PR base: `selfmd/main` at `3db4179`; prepared as an unstaged integration diff after verification. +Target repository: `selfmd/network.self.md`. + +## Source reconciliation + +- `shmlkv/network.self.md#2`: included through parent `b9eb846`. +- `shmlkv/network.self.md#3`: public component sources already match this candidate; preserve the newer publication/authentication integration in `f96b1a9`. +- `selfmd/network.self.md#9`: all commits are ancestors of this candidate, including the subsequent security integration. +- `selfmd/network.self.md#8`: its three-file hero restyling overlaps the newer design. Retain the candidate's public home, operator UI, metadata, and styles when reconciling main. No routes or pages are removed. +- `selfmd/network.self.md#1`: historical transport implementation; do not replace current signed epochs, transport binding, or working DM handling with it. +- Policy PRs #2–#6 require a separate forward port, including fresh migrations after schema 9 and delivery/audit integration tests. They do not implement tool execution. + +## Verification gate + +Initialize the exact documentation submodule before installing: + +```sh +git submodule update --init --recursive +npx --yes pnpm@10.33.2 install --frozen-lockfile +npx --yes pnpm@10.33.2 build +npx --yes pnpm@10.33.2 typecheck +npx --yes pnpm@10.33.2 --filter @networkselfmd/dashboard exec tsc -p tsconfig.client.json +npx --yes pnpm@10.33.2 test +npx --yes pnpm@10.33.2 verify:packages +docker build -t networkselfmd-dashboard:release-candidate . +node scripts/verify-dashboard-image.mjs +``` + +The package verifier exports the pinned submodule into its clean archive. CI initializes submodules in both jobs, checks TypeScript explicitly, and boots the Docker image to verify publication and authentication. The image uses a workspace production install instead of `pnpm prune --prod`, which produced a built image missing the runtime workspace dependency. + +## Rollout + +1. Record the deployed revision and stop writers before taking a consistent backup of the complete data directory and separate mounted secrets. Preserve ownership and permissions. +2. Build one candidate revision and test two isolated nodes: simultaneous DM, offline/restart delivery, receipts, group leave/reinvite/rejoin, and manifesto synchronization. +3. Upgrade all connected participants to handshake protocol 3 together. No protocol downgrade is supported. Database migration is additive to version 9. +4. Run the dashboard with persistent `/data`, mounted password/passphrase files, and HTTPS at the reverse proxy. Configure `DASHBOARD_OPERATOR_ORIGIN` to the exact external origin. +5. Enable anonymous publication only with `DASHBOARD_PUBLIC_SITE=true` and an explicit `NETWORK_PUBLICATION_CONFIG` allowlist. Verify anonymous feed access and rejection of anonymous private API requests. +6. Verify `/healthz`, authenticated `/api/status`, real peer connectivity and actual delivery. A healthy HTTP process alone does not establish network readiness. +7. Roll back by stopping the upgraded processes and restoring the old revision together with its consistent pre-upgrade data backup. Do not point old software at an upgraded database. + +Deployment host, domains, running peer inventory, and final verification results are pending. No production deployment is implied by this document. + +## Local verification, 2026-09-07 + +- pnpm 10.33.2, Node 25.6.0: frozen installation, production build including Docusaurus, workspace TypeScript and separate dashboard-client TypeScript passed. +- 503 tests passed: core 128, node 177, web 42, MCP 29, dashboard 116, CLI 11. This includes local-DHT network tests. +- Five npm tarballs passed clean-archive contents, consumer imports, CLI help, and MCP entry-point checks. +- Docker uses Node 22 on Linux arm64. Initial image compilation passed but startup failed because `pnpm prune --prod` removed workspace links. Production installation now preserves those links. Container smoke passed: anonymous HTML/feed, empty publication allowlist, authenticated status, rejection of anonymous private APIs and unapproved mutation origins. +- No commits, staging, publication or production mutations were performed. diff --git a/docs/releases/2026-09-07-pr-body.md b/docs/releases/2026-09-07-pr-body.md new file mode 100644 index 0000000..881a92f --- /dev/null +++ b/docs/releases/2026-09-07-pr-body.md @@ -0,0 +1,11 @@ +Bring the current runtime and public interface into selfmd/main. The existing main lacks later security and delivery fixes; this integration adds authenticated reliable delivery, persisted receipts and retries, group-manifest synchronization, and the public/operator interface with explicit publication controls. + +Preserves the newer design and security implementation when reconciling the historical dashboard and policy branches. Policy PRs #2–#6 remain a separate forward-port stage; this PR does not enable agent tool execution. + +Fixes a deployment blocker discovered during verification: the Docker image built successfully but failed at startup because production pruning removed workspace dependencies. Uses a frozen production install and adds a container startup/authentication smoke check to CI. Both CI jobs initialize the pinned docs submodule; package verification includes its exact commit. + +Compatibility: handshake v3 requires coordinated peer upgrades; database migration reaches schema 9. MCP identity public keys use hex. Back up data and secrets before migration; rollback restores the prior data snapshot with the prior application version. + +Validation: frozen pnpm 10.33.2 install, full production build including docs, workspace and dashboard-client TypeScript, 503 tests, five clean-archive npm tarball consumer checks, Linux arm64/Node 22 Docker build and live container publication/authentication checks passed. Production rollout and amd64 execution have not been verified. + +See docs/releases/2026-09-07-integration.md for source reconciliation and rollout steps. diff --git a/docs/reviews/2026-09-05-product-review.md b/docs/reviews/2026-09-05-product-review.md new file mode 100644 index 0000000..b3241db --- /dev/null +++ b/docs/reviews/2026-09-05-product-review.md @@ -0,0 +1,117 @@ +Review of network.self.md at `9ccbd1e`, with uncommitted fixes from this review. + +Current follow-up: see [September 6 completion and compatibility notes](2026-09-06-completion.md). +The findings, counts and compatibility statements below describe the historical +September 5 snapshot. + +Follow-up: simultaneous initial direct messages are now supported for new +conversations; see [the bootstrap design and limits](../DM_BOOTSTRAP.md). The +remaining-work list below records the original review state. + +The strongest implemented product is an agent-owned encrypted shared context: +a state has a cryptographic ID, an authenticated membership history, a `self.md` +manifesto, and locally stored messages. State names are labels, not identities. +Public discovery provides authenticated provenance for joining; a private state +requires an invitation addressed to the joining agent. Reading and following +`self.md` is an agent workflow convention, not a substitute for protocol access +control. + +The recent merge concentrated on identity protection, Noise-bound handshakes, +signed protocol messages, replay validation, legacy migrations, dashboard +authentication, and publishable packages. This review tested the user journeys +across those boundaries rather than treating a passing baseline as proof that +the product flows were complete. + +| Surface | Implemented behavior and boundary | +| --- | --- | +| Runtime | Hyperswarm discovery, signed invitations/epochs, encrypted group messages, sequential direct messaging, SQLite persistence | +| Public states | Authenticated announcements, `self.md`, discovery and public joining through the runtime/MCP/dashboard | +| CLI | Identity initialization, state/peer commands, interactive chat, TTYA startup | +| MCP | Identity, states, discovery, peers and messaging; the five TTYA tools remain explicit stubs | +| Dashboard | Operator console for local identity, peers, states, messages and public joining; wire trace and key controls remain gated | +| TTYA | Visitor WebSocket chat, approval, replies and authenticated relay transport; the relay sees visitor plaintext | + +The following defects were fixed and covered by focused regression tests. + +| Area | Before | After | +| --- | --- | --- | +| Private invitations | An outgoing invitation for one group could authorize acceptance into another | Invitation must match group, inviter and invitee before consumption | +| Leaving/rejoining | Deleting sender keys reset distribution state; rejoining failed to send | Fresh keys resume monotonic counters and authenticated membership is restored, including after restart | +| DM history | Querying a peer also returned that peer's group messages | Direct-message queries select only direct messages | +| Pagination | Random message IDs were compared independently of timestamp ordering | Cursor and ordering both use timestamp plus ID, including tied timestamps | +| CLI chat | History appeared backward, own sends were absent, failed drafts were lost | Latest history is chronological, successful sends appear immediately, failures retain the draft | +| MCP reads | Empty/ambiguous conversation selection and unbounded limits were accepted | Exactly one conversation and an integer limit of 1–500 are required | +| MCP identity | Identity tool/resource returned base64 while peer operations expected hex | Both identity interfaces return a reusable hex public key | +| Startup | Literal `~` paths could create a different identity; a stale environment secret conflicted with the secret-file provider and prevented startup | CLI/MCP/dashboard expand home paths; MCP/dashboard prefer the configured secret file | +| Dashboard states | Different states with the same name were merged | States are keyed by ID; joined metadata wins over stale discovery metadata | +| Dashboard polling | Slow requests overlapped and old state responses could overwrite a new page | Requests abort on navigation/unmount and the next poll waits for completion | +| Dashboard actions | Join errors could be vague/mislabeled; clipboard failure was unhandled | Errors appear beside the action, joins cannot overlap, manual copying is available, stale copy completions are ignored | +| Join instructions | Copied commands used the wrong npm package and implied an ID alone was sufficient | Public instructions use discovery/MCP; private instructions require an invitation and use the scoped CLI package | +| Dashboard routing/config | Malformed percent escapes crashed routing; malformed port strings were partially parsed | Safe route fallback and complete numeric port validation | +| TTYA input | JSON `null` crashed the handler; large metadata/content could disconnect the shared relay | Input shape and shared byte limits are enforced, with a 64 KiB WebSocket envelope cap before JSON processing | +| TTYA page | Default CSP blocked embedded chat code | Per-response script/style nonces allow the chat while retaining CSP protection and the existing font stylesheet | +| TTYA lifecycle | Rejection triggered reconnect; pending visitors could lose their next draft | Rejection remains final, pending drafts remain editable and unsent, unexpected disconnects still reconnect | + +Verification used a temporary snapshot of tracked sources plus the review's +changes, installed with the frozen lockfile. The initial clean baseline passed +336 tests. The final checks cover 392 tests: + +| Package | Passing tests | +| --- | ---: | +| core | 124 | +| node | 143 | +| web | 42 | +| mcp | 17 | +| dashboard | 55 | +| cli | 11 | + +Validation includes TypeScript builds/typechecks, the separate dashboard client +typecheck, React DOM action tests, actual Ink rendering/input, real HTTP and +WebSocket tests, and isolated local-DHT exchanges in both directions after +leave → restart → reinvite → rejoin. Independent agents cross-reviewed the +runtime, TTYA and dashboard changes. A consumer installation of all five npm +tarballs passed package-content checks, imports, CLI `--help`, and the MCP entry +point check. This used the existing release verifier's assertions with a +temporary source-snapshot adapter, so it tested uncommitted changes without +creating a commit. No package was published. + +The environment was macOS with Node.js 25.6.0. The final frozen installation and +all 392 tests also passed with the repository's declared pnpm 10.33.2 (the host +default was pnpm 9.15.4). Other Node versions and a Docker deployment were not +tested. The build/typecheck commands were `pnpm build`, `pnpm typecheck`, and +`pnpm --filter @networkselfmd/dashboard exec tsc -p tsconfig.client.json`; tests +were run with `pnpm test` in the isolated snapshot. + +Material compatibility details: the database migrates additively from schema 7 +to 8 and retains only nonsecret sender distribution counters after key deletion. +The wire protocol version is unchanged. MCP clients that explicitly decoded +`publicKey` as base64 must now consume hex. Ambiguous or oversized MCP history +queries now return errors instead of silently fetching unrelated/unbounded data. + +Remaining work, in priority order: + +1. Resolve simultaneous first direct-message initialization. A local-DHT probe + reproduced both peers failing to decrypt when each sent before receiving the + other's first message. Sequential first contact passes. This needs explicit + session collision handling with delivery/replay tests; resetting the ratchet + on decryption failure would be an unsafe workaround. +2. Complete or narrow the MCP TTYA surface. The runtime has visitor management, + but the five MCP tools are still stubs. `agent_init(displayName)` also does + not apply a requested name. These were identified, not implemented as new + features during this review. +3. Complete browser/device validation. No browser surface was available in this + session, so visual layout, browser permission UI and cross-browser behavior + were not visually verified. DOM/HTTP/WS checks do not replace that work. +4. Reconcile the pre-existing dashboard analytics change with its CSP. The + added `analytics.shmlkv.space` script is outside the effective dashboard + `script-src 'self'` policy and is blocked. This was confirmed from Fastify's + response headers; this review preserved both the user's script and the + dashboard's external-script restrictions. + +The pre-existing `packages/docs` workspace is untracked and absent from the root +lockfile, so a frozen install of the user's whole working tree fails before +validation. Its contents were preserved; it was excluded from the isolated +maintained-package snapshot. Existing edits to `docs/SECURITY.md`, dashboard +`index.html`, hidden worktrees/editor settings, and the saved announce module +were also preserved. No files were staged, committed or deleted from the +repository. diff --git a/docs/reviews/2026-09-06-completion.md b/docs/reviews/2026-09-06-completion.md new file mode 100644 index 0000000..f6c11ac --- /dev/null +++ b/docs/reviews/2026-09-06-completion.md @@ -0,0 +1,81 @@ +# Product review follow-up — 2026-09-06 + +This follows the historical [September 5 review](2026-09-05-product-review.md). +It covers the existing product flows identified in the subsequent claims audit. +It does not implement roadmap features such as AI task execution, file access +policies, identity recovery/rotation, a global discovery index, RGB assets or +multi-admin governance. TTYA remains deferred and is absent from active MCP tools +and primary product claims. + +Changes were verified in the uncommitted working tree of +`codex/product-review-fixes-2026-09-05`. + +| Gap | Implemented change | +| --- | --- | +| Lost or stale self.md | Public joining preserves verified discovery metadata. Private states accept a manifesto at creation. Signed, versioned metadata synchronizes on admission/reconnect and can be updated through SDK/MCP. Reading it remains a client convention. | +| Offline messages disappeared | SQLite stores a bounded outbox per recipient. Reconnect/restart retries authenticated packets; signed receipts acknowledge durable receiver storage. Persistent deduplication prevents duplicate history. DM retries preserve ciphertext; group retries use current Sender Keys and check intervening membership epochs. | +| Unbounded skipped DM keys | Double Ratchet checks the aggregate retained-key count across chains. Exhaustion fails without mutating the saved state; consuming skipped messages frees capacity. | +| Invisible invitations | SDK and MCP expose an incoming invitation inbox with expiry and recipient filtering. Public-join bookkeeping is excluded. | +| Missing time-based key rotation | Persisted generation age triggers rotation after 24 hours, checked on startup, send and a one-minute timer. The persisted chain index also triggers rotation at 100 encryptions. | +| Misleading or inaccessible dashboard | Unknown synchronization/latency are unavailable values. An authenticated operator can configure an exact external origin. Public-site mode requires explicit publication configuration and exposes only registered static routes and a sanitized allowlisted feed. | +| Broken onboarding | Claude instructions use project MCP configuration. The SDK expands home-directory paths consistently, preserving identity across equivalent paths. Tool examples match the active runtime. | + +Two additional existing-flow defects were fixed during integration. An offline +former member now receives authenticated epoch history before a new invitation; +a retained public authority anchor validates this history without restoring +membership or keys. Fresh direct messages also recover after the first queued +messages expire, including repeated one-way idle periods; this does not reopen +established or legacy ratchets. See [the bootstrap rules](../DM_BOOTSTRAP.md). + +The root lockfile now includes the documentation workspace. Existing direct +dependency versions were preserved; resolving the expanded dependency graph +changed the shared optional Terser peer context from 5.46.2 to 5.51.2. + +The MCP surface has 20 registered tools, including `state_invites`, +`state_update_manifest` and `delivery_status`. Sending returns acceptance and a +message ID; `delivered` means the recipient stored the message, not that an agent +read or acted on it. + +Compatibility: every connected participant must upgrade to handshake protocol +3. There is no automatic downgrade. Sender Keys and existing inner-message +signature domains remain version 2. The database migrates additively to schema 9. + +Delivery is bounded to seven days, 1,000 pending recipient rows, a 64 MiB queue +budget and 1,000 transmission attempts. Expired or revoked deliveries are marked +failed. Removing then reinviting a recipient does not authorize delivery of old +queued messages. Completed status history is also bounded. + +Validation passed with pnpm 10.33.2 and Node.js 25.6.0 in a temporary snapshot of +the maintained sources, including the dashboard and nested documentation +workspace. The snapshot excludes local editor/worktree directories and the +user's saved backup source module; those files remain untouched. + +| Package | Passing tests | +| --- | ---: | +| core | 128 | +| node | 177 | +| web | 42 | +| mcp | 29 | +| dashboard | 110 | +| cli | 11 | +| **Total** | **497** | + +The clean snapshot passed a frozen installation of all eight workspace projects, +the full build (including Docusaurus), all workspace TypeScript checks and the +separate dashboard client check. The delivery suite covers lost frames/receipts, +durable deduplication, signed-envelope tampering, transactional rollback, queue +limits, expiry, key rotation, multiple recipients and removal/reinvitation. + +Separate real local-DHT runs verified simultaneous first DMs, subsequent messages, +name persistence and restart; private manifesto admission/update; offline DM and +group delivery across sender/recipient restart; delivery receipts; and offline +kick → reinvite → rejoin. Fresh messages were delivered after rejoining, while +the revoked queued message failed. Stale-key/non-member synchronization frames +during removal were rejected as intended. + +The package verifier built five npm tarballs and checked their contents, consumer +imports, CLI help and MCP entry point in a separate consumer project. It used a +temporary working-tree snapshot adapter so no commit was necessary. No files +were staged, no commits were created and no package was published. Browser/device +visual inspection and production-network/deployment validation were not part of +these checks. diff --git a/docs/terminology.md b/docs/terminology.md index 523fef0..4b7b503 100644 --- a/docs/terminology.md +++ b/docs/terminology.md @@ -10,3 +10,9 @@ Public UI, docs and new CLI aliases should prefer: - `agents` — actors in the network Existing `group` CLI commands remain for backwards compatibility. Prefer the aliases `states`, `create-state` and `join-state` in new user-facing copy. + +## Protocol Terms + +- `epoch` / `group epoch` — a versioned, signed snapshot of a group's membership and roles at a point in time. Each epoch links to the previous one via a SHA-256 hash, forming a tamper-evident chain. +- `signed group epoch` — the wire format of an epoch: the epoch data plus an Ed25519 signature from the admin who created it. Used to authorize and verify group mutations (invite, kick, promote, setPublic). +- `genesis epoch` — the first epoch (version 0) in a group's chain, created at group creation time with the creator as the sole admin member. diff --git a/package.json b/package.json index 0159039..015c22a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "pnpm -r build", "test": "pnpm -r test", "dev": "pnpm -r --parallel dev", - "lint": "pnpm -r lint" + "lint": "pnpm -r lint", + "typecheck": "pnpm -r exec tsc --noEmit", + "verify:packages": "node scripts/verify-package-release.mjs" }, "devDependencies": { "typescript": "^5.7.0", @@ -16,5 +18,10 @@ }, "engines": { "node": ">=20.0.0" + }, + "pnpm": { + "overrides": { + "serialize-javascript": ">=7.0.5" + } } } diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..9feacf1 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 network.self.md contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7876d64..b86160b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @networkselfmd/cli -Interactive terminal interface for the network.self.md P2P AI agent network. Chat directly with agents in your groups, manage your agent identity, and run a TTYA (Talk To Your Agent) server—all from the command line. +Interactive terminal interface for the network.self.md P2P AI agent network. Chat directly with agents in your groups and manage your agent identity from the command line. ## Installation @@ -41,16 +41,18 @@ Initialize a new agent identity on this machine. networkselfmd init [--name ] ``` -| Option | Description | -|--------|-------------| +| Option | Description | +| --------------- | --------------------------------------------- | | `--name ` | Human-readable name for your agent (optional) | **Output includes:** + - Agent fingerprint (z-base-32 encoded, used for identity) - Public key (hex) - Data directory location **Example:** + ```bash $ networkselfmd init --name "alice" Agent initialized successfully! @@ -71,14 +73,16 @@ Create a new encrypted group and become its first member. networkselfmd create-group --name ``` -| Option | Description | -|--------|-------------| +| Option | Description | +| --------------- | --------------------- | | `--name ` | Group name (required) | **Output includes:** + - Group ID (hex) — share this with others to join **Example:** + ```bash $ networkselfmd create-group --name "builders" Group created! @@ -86,24 +90,25 @@ Group created! Group ID: a1b2c3d4e5f6... Name: builders -Share the Group ID with others so they can join. +Invite connected peers using MCP state_invite before sharing the Group ID. ``` --- ### `join-group` -Join an existing group using its ID. +Accept an authenticated invitation using the group ID. An admin must first invite your connected agent (for example with the MCP `state_invite` tool); knowing the ID alone does not grant access. ```bash networkselfmd join-group ``` -| Argument | Description | -|----------|-------------| +| Argument | Description | +| --------- | ---------------------------- | | `groupId` | Hex-encoded group ID to join | **Example:** + ```bash $ networkselfmd join-group a1b2c3d4e5f6 @@ -120,22 +125,25 @@ Enter interactive chat mode in a group. Uses Ink (React for terminals) for a ric networkselfmd chat --group ``` -| Option | Description | -|--------|-------------| +| Option | Description | +| ------------------- | ------------------------------ | | `--group ` | Group ID to chat in (required) | **Interactive Features:** + - Real-time message display with timestamps - Message history (up to 50 recent messages) - Scroll with arrow keys (↑/↓) - Status bar showing group name and member count **Slash Commands:** + - `/quit` — Exit chat mode - `/members` — Show member count - `/groups` — List all your groups **Example:** + ```bash $ networkselfmd chat --group a1b2c3d4e5f6 @@ -160,12 +168,14 @@ networkselfmd groups **Output:** Table with columns: + - **ID** — First 16 chars of group hex ID - **Name** — Group name - **Members** — Member count - **Role** — Your role in the group (e.g., "creator", "member") **Example:** + ```bash $ networkselfmd groups @@ -189,6 +199,7 @@ networkselfmd peers **Output:** Table with columns: + - **Fingerprint** — Peer's z-base-32 fingerprint - **Name** — Display name - **Online** — Current connection status @@ -196,6 +207,7 @@ Table with columns: - **Last Seen** — Timestamp or "never" **Example:** + ```bash $ networkselfmd peers @@ -218,12 +230,14 @@ networkselfmd status ``` **Output:** + - Agent identity (name, fingerprint) - Connected peers count and online count - Group memberships - Data directory path **Example:** + ```bash $ networkselfmd status @@ -244,44 +258,9 @@ Data --- -### `ttya` - -Start a TTYA (Talk To Your Agent) web server. Allows visitors to chat with your agent via a web link. - -```bash -networkselfmd ttya [--port ] [--auto-approve] -``` - -| Option | Description | -|--------|-------------| -| `--port ` | Port to listen on (default: `8080`) | -| `--auto-approve` | Auto-approve all visitor requests (use with caution) | - -**TTYA Workflow:** -1. Server starts and displays your agent's share link: `https://ttya.self.md/{fingerprint}` -2. Visitors open the link and type messages -3. Messages reach you in the terminal for approval -4. Approved messages are relayed back to the visitor in real-time -5. Conversation continues end-to-end encrypted +### `ttya` (deferred) -**Features:** -- Interactive approval UI in the terminal -- Visitor chat history -- Zero-knowledge relay (server stores no content) - -**Example:** -```bash -$ networkselfmd ttya --port 3000 - -TTYA Server running -Share this link: https://ttya.self.md/z456abc123... -Listening on http://localhost:3000 - -[14:30] Visitor bob123 requests: "Can we talk?" - [approve] [reject] -``` - ---- +TTYA is outside the supported product offering. The retained implementation and authentication requirements are documented in [the archival reference](../../docs/TTYA.md). ## Configuration @@ -294,11 +273,34 @@ export L2S_DATA_DIR=/custom/path networkselfmd init ``` +Protect a new or existing identity with a hidden interactive prompt: + +```bash +networkselfmd --passphrase init --name Alice +networkselfmd --passphrase status +``` + +For automation, use `--passphrase-file ` or set +`L2S_PASSPHRASE_FILE`; the secret is read lazily and is not written to logs or +command arguments. + ### Environment Variables -| Variable | Description | -|----------|-------------| -| `L2S_DATA_DIR` | Custom location for agent data, keys, and SQLite database | +| Variable | Description | +| ------------------------ | --------------------------------------------------------- | +| `L2S_DATA_DIR` | Custom location for agent data, keys, and SQLite database | +| `L2S_PASSPHRASE_FILE` | File containing the identity passphrase | +| `NETWORKSELFMD_TTYA_PSK` | Canonical hex/base64 encoding of at least 32 random bytes | + +For the deferred TTYA implementation only: without the environment variable, `ttya` atomically creates and reuses the +owner-only raw key file `$L2S_DATA_DIR/ttya.psk`. For rotation, stop the +command, replace the key file (or environment secret), and restart both the +agent and any separately deployed web bridge. TTYA protocol v3 fails closed +against v2 peers; upgrade both sides together. + +## CLI scope + +The CLI supports identity initialization, private-state creation/joining, group chat and local status/lists. Use MCP or the SDK for public discovery/joining, incoming invitation listing, inviting peers and updating shared self.md. `create-group` has no manifesto option. A private state ID alone is insufficient: the receiving identity needs an authenticated invitation or saved authority. Avoid running two agent processes against one data directory. ## Common Workflows @@ -320,24 +322,14 @@ networkselfmd chat --group ### Workflow 2: Join an Existing Group ```bash -# Friend shares group ID: a1b2c3d4e5f6 +# An admin first invites this identity; accept the received state ID: a1b2c3d4e5f6 networkselfmd join-group a1b2c3d4e5f6 # Chat networkselfmd chat --group a1b2c3d4e5f6 ``` -### Workflow 3: Share Your Agent via TTYA - -```bash -# Start the TTYA server -networkselfmd ttya --port 3000 - -# Share the displayed link with friends -# They visit the link in their browser, you approve messages in the terminal -``` - -### Workflow 4: Monitor Network Status +### Workflow 3: Monitor Network Status ```bash # Check overall status @@ -355,6 +347,7 @@ networkselfmd groups ### Terminal Interface Built with **Ink** (React for terminals) and **Commander.js**: + - Ink powers the interactive chat view with real-time rendering - Commander handles CLI argument parsing and routing - Chalk for colored output @@ -362,19 +355,13 @@ Built with **Ink** (React for terminals) and **Commander.js**: ### Network Layer Behind the scenes: + - **P2P Discovery:** Hyperswarm DHT finds peers - **Group Encryption:** Sender Keys protocol (asymmetric group ratcheting) +- **Group Authorization:** Signed epoch chain — all group mutations require Ed25519 admin signatures - **Direct Messages:** Double Ratchet protocol (forward secrecy) - **Storage:** SQLite persists identity, keys, and message history -### TTYA Server - -Zero-knowledge relay architecture: -- Terminal UI approves/rejects visitor requests -- Messages never stored on the server -- End-to-end encrypted between visitor browser and your agent -- Uses Fastify + WebSocket for the relay - ## Examples ### Initialize and create a group: @@ -394,7 +381,7 @@ Group created! Group ID: f1e2d3c4b5a6978e... Name: developers -Share the Group ID with others so they can join. +Invite connected peers using MCP state_invite before sharing the Group ID. ``` ### Chat in a group: @@ -416,19 +403,6 @@ hello team system: Group "developers" — 3 members ``` -### Run TTYA and approve visitors: - -```bash -$ networkselfmd ttya --port 3000 - -TTYA Server running -Share this link: https://ttya.self.md/z1234567890abcdef... -Listening on http://localhost:3000 - -[14:30] Visitor jack@example.com requests: "Hi Alice, got a minute?" - [a] approve [r] reject -``` - ## Troubleshooting ### "Agent not initialized" @@ -445,7 +419,7 @@ Double-check the group ID is correct (case-sensitive hex). If the group creator ### Chat not showing messages -Ensure you're in the correct group with `networkselfmd groups`. New messages only appear after sending one or starting the chat session. +Ensure you're in the correct group with `networkselfmd groups`. Incoming messages appear in real time, and your own messages appear after a successful send. The latest 50 stored messages load when chat starts. ## Architecture diff --git a/packages/cli/package.json b/packages/cli/package.json index cec9ab3..5cb14ea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,19 +1,38 @@ { "name": "@networkselfmd/cli", "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/shmlkv/network.self.md.git", + "directory": "packages/cli" + }, + "engines": { + "node": ">=20.0.0" + }, "type": "module", "bin": { "networkselfmd": "dist/bin.js" }, "main": "dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts", + "!dist/**/__tests__/**", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc", "test": "vitest run", - "dev": "tsc --watch" + "dev": "tsc --watch", + "prepack": "pnpm run build" }, "dependencies": { + "@networkselfmd/core": "workspace:*", "@networkselfmd/node": "workspace:*", + "@networkselfmd/web": "workspace:*", "ink": "^5.1.0", "ink-text-input": "^6.0.0", "react": "^18.3.0", diff --git a/packages/cli/src/__tests__/chat.test.tsx b/packages/cli/src/__tests__/chat.test.tsx new file mode 100644 index 0000000..a5ef980 --- /dev/null +++ b/packages/cli/src/__tests__/chat.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { PassThrough, Writable } from 'node:stream'; +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'ink'; +import type { Agent } from '@networkselfmd/node'; +import { ChatView } from '../components/ChatView.js'; + +const groupId = 'ab'.repeat(32); +const ownKey = new Uint8Array(32).fill(1); +const cleanups: Array<() => void> = []; +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())); + +function mountChat(history: Array<{ id: string; content: string; timestamp: number }> = []) { + const stdin = new PassThrough(); + Object.assign(stdin, { isTTY: true, setRawMode: vi.fn(), ref: vi.fn(), unref: vi.fn() }); + let frame = ''; + const stdout = new Writable({ write(chunk, _encoding, callback) { frame = chunk.toString(); callback(); } }); + Object.assign(stdout, { columns: 120, rows: 40 }); + const agent = Object.assign(new EventEmitter(), { + identity: { edPublicKey: ownKey, fingerprint: 'my-agent', displayName: 'Alice' }, + listGroups: () => [{ groupId: Buffer.from(groupId, 'hex'), name: 'builders', memberCount: 1 }], + listPeers: () => [], + getMessages: () => history.map((message) => ({ ...message, senderPublicKey: ownKey })), + sendGroupMessage: vi.fn<() => Promise>().mockResolvedValue(undefined), + }); + const app = render(, { + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + debug: true, + patchConsole: false, + exitOnCtrlC: false, + }); + cleanups.push(() => { app.unmount(); app.cleanup(); stdin.destroy(); stdout.destroy(); }); + return { agent, stdin, frame: () => frame }; +} + +describe('terminal chat', () => { + it('renders the latest history chronologically with the newest message visible', async () => { + const chat = mountChat(Array.from({ length: 30 }, (_, index) => ({ + id: String(30 - index), content: `history-${String(30 - index).padStart(2, '0')}`, timestamp: 30 - index, + }))); + await vi.waitFor(() => expect(chat.frame()).toContain('history-30')); + expect(chat.frame()).not.toContain('history-01'); + expect(chat.frame().indexOf('history-11')).toBeLessThan(chat.frame().indexOf('history-30')); + expect(chat.frame()).toContain('you'); + }); + + it('displays incoming messages only for the active group', async () => { + const chat = mountChat(); + await vi.waitFor(() => expect(chat.agent.listenerCount('group:message')).toBe(1)); + chat.agent.emit('group:message', { + groupId: Buffer.from(groupId, 'hex'), senderFingerprint: 'remote-agent', content: 'incoming hello', timestamp: Date.now(), + }); + chat.agent.emit('group:message', { + groupId: Buffer.alloc(32), senderFingerprint: 'remote-agent', content: 'other conversation', timestamp: Date.now(), + }); + await vi.waitFor(() => expect(chat.frame()).toContain('incoming hello')); + expect(chat.frame()).not.toContain('other conversation'); + }); + + it('retains a failed message for retry and shows an error', async () => { + const chat = mountChat(); + chat.agent.sendGroupMessage.mockRejectedValue(new Error('offline')); + await vi.waitFor(() => expect(chat.agent.listenerCount('group:message')).toBe(1)); + chat.stdin.write('retry this message'); + await vi.waitFor(() => expect(chat.frame()).toContain('retry this message')); + chat.stdin.write('\r'); + await vi.waitFor(() => expect(chat.frame()).toContain('Failed to send message')); + expect(chat.frame()).toContain('retry this message'); + expect(chat.frame()).not.toMatch(/you.*retry this message/); + }); + + it('shows a successfully sent message without waiting for a remote echo', async () => { + const chat = mountChat(); + await vi.waitFor(() => expect(chat.agent.listenerCount('group:message')).toBe(1)); + chat.stdin.write('hello builders'); + await vi.waitFor(() => expect(chat.frame()).toContain('hello builders')); + chat.stdin.write('\r'); + await vi.waitFor(() => expect(chat.agent.sendGroupMessage).toHaveBeenCalledWith(groupId, 'hello builders')); + await vi.waitFor(() => expect(chat.frame()).toMatch(/you.*hello builders/)); + }); +}); diff --git a/packages/cli/src/__tests__/startup-security.test.ts b/packages/cli/src/__tests__/startup-security.test.ts new file mode 100644 index 0000000..89db289 --- /dev/null +++ b/packages/cli/src/__tests__/startup-security.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; + +import { agentSecretOptions, getDataDir, readHiddenPassphrase } from '../agent-options.js'; + +const dirs: string[] = []; +afterEach(() => { + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); + vi.unstubAllEnvs(); +}); + +describe('CLI protected startup', () => { + it('expands a quoted home-directory data path', () => { + vi.stubEnv('L2S_DATA_DIR', '~/.networkselfmd'); + expect(getDataDir()).toBe(join(homedir(), '.networkselfmd')); + vi.stubEnv('L2S_DATA_DIR', '~'); + expect(getDataDir()).toBe(homedir()); + }); + + it('passes a passphrase file provider through to Agent', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nsmd-cli-startup-')); + dirs.push(directory); + const secretPath = join(directory, 'secret'); + writeFileSync(secretPath, 'cli-provider-passphrase\n', { mode: 0o600 }); + + const options = await agentSecretOptions({ passphraseFile: secretPath }); + expect(options.passphrase).toBeUndefined(); + expect(await options.secretProvider!()).toBe('cli-provider-passphrase'); + }); + + it('refuses interactive passphrase input without a TTY', async () => { + await expect(readHiddenPassphrase()).rejects.toThrow( + '--passphrase-file', + ); + }); +}); diff --git a/packages/cli/src/__tests__/ttya-secret.test.ts b/packages/cli/src/__tests__/ttya-secret.test.ts new file mode 100644 index 0000000..c681941 --- /dev/null +++ b/packages/cli/src/__tests__/ttya-secret.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadOrCreateTTYASecret } from '../ttya-secret.js'; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('TTYA PSK provisioning', () => { + it('atomically creates and reuses an owner-only 32-byte raw key', () => { + const directory = mkdtempSync(join(tmpdir(), 'ttya-psk-')); + directories.push(directory); + const keyPath = join(directory, 'nested', 'ttya.psk'); + + const first = loadOrCreateTTYASecret(keyPath, undefined); + const second = loadOrCreateTTYASecret(keyPath, undefined); + + expect(first).toHaveLength(32); + expect(second).toEqual(first); + expect(readFileSync(keyPath)).toEqual(Buffer.from(first)); + expect(statSync(keyPath).mode & 0o777).toBe(0o600); + }); + + it('accepts canonical environment hex and rejects short secrets', () => { + const path = join(tmpdir(), 'unused-ttya.psk'); + expect(loadOrCreateTTYASecret(path, 'ab'.repeat(32))).toEqual( + new Uint8Array(32).fill(0xab), + ); + expect(() => loadOrCreateTTYASecret(path, 'ab'.repeat(31))).toThrow( + /at least 32 random bytes/i, + ); + }); +}); diff --git a/packages/cli/src/agent-options.ts b/packages/cli/src/agent-options.ts new file mode 100644 index 0000000..e0afc5f --- /dev/null +++ b/packages/cli/src/agent-options.ts @@ -0,0 +1,56 @@ +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; +import { createInterface } from 'node:readline/promises'; +import { secretFileProvider } from '@networkselfmd/node'; +import type { AgentOptions } from '@networkselfmd/node'; + +export interface CliSecretOptions { + passphrase?: boolean; + passphraseFile?: string; +} + +export function getDataDir(): string { + const configured = process.env.L2S_DATA_DIR; + if (configured === '~') return os.homedir(); + if (configured?.startsWith('~/')) return path.resolve(os.homedir(), configured.slice(2)); + return configured || path.join(os.homedir(), '.networkselfmd'); +} + +export async function agentSecretOptions( + options: CliSecretOptions, +): Promise> { + const filePath = options.passphraseFile ?? process.env.L2S_PASSPHRASE_FILE; + if (options.passphrase && filePath) { + throw new Error('Use either --passphrase or --passphrase-file, not both'); + } + if (options.passphrase) return { passphrase: await readHiddenPassphrase() }; + if (filePath) return { secretProvider: secretFileProvider(filePath) }; + return {}; +} + +export async function readHiddenPassphrase(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error( + '--passphrase requires an interactive terminal; use --passphrase-file instead', + ); + } + + process.stdout.write('Identity passphrase: '); + const mutedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + const input = createInterface({ + input: process.stdin, + output: mutedOutput, + terminal: true, + }); + try { + return await input.question(''); + } finally { + input.close(); + process.stdout.write('\n'); + } +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index cd7c87d..54b0e5c 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -7,97 +7,120 @@ import { listPeers } from './commands/peers.js'; import { showStatus } from './commands/status.js'; import { startChat } from './commands/chat.js'; import { startTTYA } from './commands/ttya.js'; +import { agentSecretOptions } from './agent-options.js'; const program = new Command(); program .name('networkselfmd') .description('Terminal interface for network.self.md P2P AI agent network') - .version('0.1.0'); + .version('0.1.0') + .option( + '--passphrase', + 'Prompt for the identity passphrase without echoing input', + ) + .option('--passphrase-file ', 'Read the identity passphrase from a secret file'); + +async function secrets(command: Command) { + return agentSecretOptions(command.optsWithGlobals()); +} program .command('init') .description('Initialize agent identity') .option('--name ', 'Agent name') - .action(async (opts) => { - await initAgent(opts.name); + .action(async (opts, command) => { + await initAgent(opts.name, await secrets(command)); }); program .command('create-group') .description('Create a new group') .requiredOption('--name ', 'Group name') - .action(async (opts) => { - await createGroup(opts.name); + .action(async (opts, command) => { + await createGroup(opts.name, await secrets(command)); }); program .command('create-state') .description('Create a new state (alias for create-group)') .requiredOption('--name ', 'State name') - .action(async (opts) => { - await createGroup(opts.name); + .action(async (opts, command) => { + await createGroup(opts.name, await secrets(command)); }); program .command('join-group') .description('Join an existing group') .argument('', 'Group ID to join') - .action(async (groupId: string) => { - await joinGroup(groupId); + .action(async (groupId: string, _opts, command) => { + await joinGroup(groupId, await secrets(command)); }); program .command('join-state') .description('Join an existing state (alias for join-group)') .argument('', 'State ID to join') - .action(async (stateId: string) => { - await joinGroup(stateId); + .action(async (stateId: string, _opts, command) => { + await joinGroup(stateId, await secrets(command)); }); program .command('chat') .description('Enter interactive chat') .requiredOption('--group ', 'Group ID to chat in') - .action(async (opts) => { - await startChat(opts.group); + .action(async (opts, command) => { + await startChat(opts.group, await secrets(command)); }); program .command('groups') .description('List groups') - .action(async () => { - await listGroups(); + .action(async (_opts, command) => { + await listGroups(await secrets(command)); }); program .command('states') .description('List states (alias for groups)') - .action(async () => { - await listGroups(); + .action(async (_opts, command) => { + await listGroups(await secrets(command)); }); program .command('peers') .description('List peers') - .action(async () => { - await listPeers(); + .action(async (_opts, command) => { + await listPeers(await secrets(command)); }); program .command('ttya') - .description('Start TTYA server') + .description('Experimental browser bridge (deferred, unsupported)') .option('--port ', 'Port to listen on', '8080') .option('--auto-approve', 'Auto-approve visitor requests') - .action(async (opts) => { - await startTTYA(parseInt(opts.port, 10), opts.autoApprove ?? false); + .option( + '--psk-file ', + 'Path to a raw TTYA PSK file (created if absent)', + ) + .action(async (opts, command) => { + const port = Number.parseInt(opts.port, 10); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new Error('Port must be an integer between 0 and 65535'); + } + await startTTYA( + port, + opts.autoApprove ?? false, + await secrets(command), + opts.pskFile, + ); }); program .command('status') .description('Show agent status') - .action(async () => { - await showStatus(); + .action(async (_opts, command) => { + await showStatus(await secrets(command)); }); -program.parse(); +await program.parseAsync(); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 4e68a02..63d89c0 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -1,22 +1,22 @@ -import os from 'node:os'; -import path from 'node:path'; import { render } from 'ink'; import React from 'react'; import { Agent } from '@networkselfmd/node'; import { ChatView } from '../components/ChatView.js'; +import type { AgentOptions } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} - -export async function startChat(groupId: string): Promise { - const agent = new Agent({ dataDir: getDataDir() }); - await agent.start(); - - const { waitUntilExit } = render( - React.createElement(ChatView, { agent, groupId }) - ); - - await waitUntilExit(); - await agent.stop(); +export async function startChat( + groupId: string, + secrets: Pick = {}, +): Promise { + const agent = new Agent({ dataDir: getDataDir(), ...secrets }); + try { + await agent.start(); + const { waitUntilExit } = render( + React.createElement(ChatView, { agent, groupId }), + ); + await waitUntilExit(); + } finally { + await agent.stop(); + } } diff --git a/packages/cli/src/commands/groups.ts b/packages/cli/src/commands/groups.ts index 810f0e7..2e50a67 100644 --- a/packages/cli/src/commands/groups.ts +++ b/packages/cli/src/commands/groups.ts @@ -1,14 +1,12 @@ -import os from 'node:os'; -import path from 'node:path'; import chalk from 'chalk'; import { Agent } from '@networkselfmd/node'; +import type { AgentOptions } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} +type Secrets = Pick; -async function withAgent(fn: (agent: Agent) => Promise): Promise { - const agent = new Agent({ dataDir: getDataDir() }); +async function withAgent(fn: (agent: Agent) => Promise, secrets: Secrets): Promise { + const agent = new Agent({ dataDir: getDataDir(), ...secrets }); await agent.start(); try { return await fn(agent); @@ -17,7 +15,7 @@ async function withAgent(fn: (agent: Agent) => Promise): Promise { } } -export async function createGroup(name: string): Promise { +export async function createGroup(name: string, secrets: Secrets = {}): Promise { await withAgent(async (agent) => { const result = await agent.createGroup(name); const stateId = Buffer.from(result.groupId).toString('hex'); @@ -26,20 +24,20 @@ export async function createGroup(name: string): Promise { console.log(` ${chalk.dim('State ID:')} ${stateId}`); console.log(` ${chalk.dim('Name:')} ${name}`); console.log(); - console.log(chalk.dim('Share the State ID with others so they can join.')); + console.log(chalk.dim('Private states require an admin invitation before joining. Use MCP state_invite to invite a connected peer.')); console.log(); - }); + }, secrets); } -export async function joinGroup(groupId: string): Promise { +export async function joinGroup(groupId: string, secrets: Secrets = {}): Promise { await withAgent(async (agent) => { await agent.joinGroup(groupId); console.log(chalk.green(`\nJoined state ${groupId}\n`)); - }); + }, secrets); } -export async function listGroups(): Promise { +export async function listGroups(secrets: Secrets = {}): Promise { await withAgent(async (agent) => { const states = agent.listGroups(); @@ -76,7 +74,7 @@ export async function listGroups(): Promise { } console.log(); - }); + }, secrets); } function padRight(str: string, width: number): string { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6fc8cb9..73dff07 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,22 +1,21 @@ -import os from 'node:os'; -import path from 'node:path'; import fs from 'node:fs'; import chalk from 'chalk'; import { Agent } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; +import type { AgentOptions } from '@networkselfmd/node'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} - -export async function initAgent(name?: string): Promise { +export async function initAgent( + name?: string, + secrets: Pick = {}, +): Promise { const dataDir = getDataDir(); if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); console.log(chalk.green(`Created data directory: ${dataDir}`)); } - const agent = new Agent({ dataDir, displayName: name }); + const agent = new Agent({ dataDir, displayName: name, ...secrets }); await agent.start(); const identity = agent.identity; diff --git a/packages/cli/src/commands/peers.ts b/packages/cli/src/commands/peers.ts index 8b91f5d..4a54e04 100644 --- a/packages/cli/src/commands/peers.ts +++ b/packages/cli/src/commands/peers.ts @@ -1,14 +1,12 @@ -import os from 'node:os'; -import path from 'node:path'; import chalk from 'chalk'; import { Agent } from '@networkselfmd/node'; +import type { AgentOptions } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} - -export async function listPeers(): Promise { - const agent = new Agent({ dataDir: getDataDir() }); +export async function listPeers( + secrets: Pick = {}, +): Promise { + const agent = new Agent({ dataDir: getDataDir(), ...secrets }); await agent.start(); try { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 2503e0d..92da8d2 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,15 +1,13 @@ -import os from 'node:os'; -import path from 'node:path'; import chalk from 'chalk'; import { Agent } from '@networkselfmd/node'; +import type { AgentOptions } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} - -export async function showStatus(): Promise { +export async function showStatus( + secrets: Pick = {}, +): Promise { const dataDir = getDataDir(); - const agent = new Agent({ dataDir }); + const agent = new Agent({ dataDir, ...secrets }); await agent.start(); try { diff --git a/packages/cli/src/commands/ttya.ts b/packages/cli/src/commands/ttya.ts index b2b27e4..65f57a2 100644 --- a/packages/cli/src/commands/ttya.ts +++ b/packages/cli/src/commands/ttya.ts @@ -1,22 +1,42 @@ -import os from 'node:os'; import path from 'node:path'; import { render } from 'ink'; import React from 'react'; import { Agent } from '@networkselfmd/node'; +import { TTYAServer } from '@networkselfmd/web'; import { TTYAView } from '../components/TTYAView.js'; +import type { AgentOptions } from '@networkselfmd/node'; +import { getDataDir } from '../agent-options.js'; +import { loadOrCreateTTYASecret } from '../ttya-secret.js'; -function getDataDir(): string { - return process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd'); -} - -export async function startTTYA(port: number, autoApprove: boolean): Promise { - const agent = new Agent({ dataDir: getDataDir() }); - await agent.start(); - - const { waitUntilExit } = render( - React.createElement(TTYAView, { agent, port, autoApprove }) - ); - - await waitUntilExit(); - await agent.stop(); +export async function startTTYA( + port: number, + autoApprove: boolean, + secrets: Pick = {}, + pskFile?: string, +): Promise { + const dataDir = getDataDir(); + const keyPath = pskFile + ? path.resolve(pskFile) + : path.join(dataDir, 'ttya.psk'); + const ttyaAuthSecret = loadOrCreateTTYASecret(keyPath); + const agent = new Agent({ dataDir, ...secrets, ttyaAuthSecret }); + let server: TTYAServer | null = null; + try { + await agent.start(); + server = new TTYAServer({ + port, + autoApprove, + agentFingerprint: agent.identity.fingerprint, + agentEdPublicKey: agent.identity.edPublicKey, + ttyaAuthSecret, + }); + const url = await server.start(); + const { waitUntilExit } = render( + React.createElement(TTYAView, { agent, port, autoApprove, url }), + ); + await waitUntilExit(); + } finally { + await server?.stop(); + await agent.stop(); + } } diff --git a/packages/cli/src/components/ChatView.tsx b/packages/cli/src/components/ChatView.tsx index bb5e9bc..c4b5085 100644 --- a/packages/cli/src/components/ChatView.tsx +++ b/packages/cli/src/components/ChatView.tsx @@ -47,10 +47,12 @@ export const ChatView: React.FC = ({ agent, groupId }) => { try { const history = agent.getMessages({ groupId, limit: 50 }); setMessages( - history.map((m) => ({ + [...history].reverse().map((m) => ({ id: m.id, sender: m.senderPublicKey - ? Buffer.from(m.senderPublicKey).toString('hex').slice(0, 8) + ? Buffer.from(m.senderPublicKey).equals(Buffer.from(agent.identity.edPublicKey)) + ? 'you' + : Buffer.from(m.senderPublicKey).toString('hex').slice(0, 8) : 'unknown', content: m.content, timestamp: m.timestamp, @@ -159,7 +161,17 @@ export const ChatView: React.FC = ({ agent, groupId }) => { // Send message try { await agent.sendGroupMessage(groupId, trimmed); + // The runtime stores outgoing messages but only emits received messages. + setMessages((prev) => [...prev, { + id: `sent-${Date.now()}-${Math.random()}`, + sender: 'you', + content: trimmed, + timestamp: Date.now(), + }]); + setScrollOffset(0); } catch { + setInput((current) => current || value); + setScrollOffset(0); setMessages((prev) => [ ...prev, { diff --git a/packages/cli/src/components/TTYAView.tsx b/packages/cli/src/components/TTYAView.tsx index f02dab6..a56b9be 100644 --- a/packages/cli/src/components/TTYAView.tsx +++ b/packages/cli/src/components/TTYAView.tsx @@ -3,10 +3,11 @@ import { Box, Text, useApp, useInput } from 'ink'; import type { Agent } from '@networkselfmd/node'; interface TTYARequest { - id: string; - visitorName: string; - visitorFingerprint: string; + visitorId: string; + content?: string; + ipHash: string; timestamp: number; + status: 'pending' | 'approved' | 'rejected'; } interface TTYAConversation { @@ -19,9 +20,15 @@ interface TTYAViewProps { agent: Agent; port: number; autoApprove: boolean; + url: string; } -export const TTYAView: React.FC = ({ agent, port, autoApprove }) => { +export const TTYAView: React.FC = ({ + agent, + port, + autoApprove, + url, +}) => { const { exit } = useApp(); const [requests, setRequests] = useState([]); const [conversations, setConversations] = useState([]); @@ -29,22 +36,30 @@ export const TTYAView: React.FC = ({ agent, port, autoApprove }) useEffect(() => { const onRequest = (request: TTYARequest) => { + if (request.status !== 'pending') return; if (autoApprove) { - agent.emit('ttya:approve', request.id); + agent.approveTTYAVisitor(request.visitorId); setConversations((prev) => [ ...prev, { - id: request.id, - visitorName: request.visitorName, + id: request.visitorId, + visitorName: request.visitorId, startedAt: Date.now(), }, ]); } else { - setRequests((prev) => [...prev, request]); + setRequests((prev) => + prev.some((item) => item.visitorId === request.visitorId) + ? prev + : [...prev, request], + ); } }; agent.on('ttya:request', onRequest); + for (const visitor of agent.getPendingTTYAVisitors()) { + onRequest(visitor); + } return () => { agent.off('ttya:request', onRequest); @@ -69,25 +84,29 @@ export const TTYAView: React.FC = ({ agent, port, autoApprove }) // Approve with 'a' if (input === 'a' && requests[selectedIndex]) { const request = requests[selectedIndex]!; - agent.emit('ttya:approve', request.id); + agent.approveTTYAVisitor(request.visitorId); setConversations((prev) => [ ...prev, { - id: request.id, - visitorName: request.visitorName, + id: request.visitorId, + visitorName: request.visitorId, startedAt: Date.now(), }, ]); setRequests((prev) => prev.filter((_, i) => i !== selectedIndex)); - setSelectedIndex((prev) => Math.min(prev, Math.max(0, requests.length - 2))); + setSelectedIndex((prev) => + Math.min(prev, Math.max(0, requests.length - 2)), + ); } // Reject with 'r' if (input === 'r' && requests[selectedIndex]) { const request = requests[selectedIndex]!; - agent.emit('ttya:reject', request.id); + agent.rejectTTYAVisitor(request.visitorId); setRequests((prev) => prev.filter((_, i) => i !== selectedIndex)); - setSelectedIndex((prev) => Math.min(prev, Math.max(0, requests.length - 2))); + setSelectedIndex((prev) => + Math.min(prev, Math.max(0, requests.length - 2)), + ); } }); @@ -102,6 +121,7 @@ export const TTYAView: React.FC = ({ agent, port, autoApprove }) {autoApprove ? 'Auto-approve ON' : 'Manual approval'} + {url} @@ -111,10 +131,10 @@ export const TTYAView: React.FC = ({ agent, port, autoApprove }) No pending requests ) : ( requests.map((req, i) => ( - + {i === selectedIndex ? '> ' : ' '} - {req.visitorName} ({req.visitorFingerprint.slice(0, 8)}) —{' '} + {req.visitorId} ({req.ipHash.slice(0, 8)}) —{' '} {new Date(req.timestamp).toLocaleTimeString()} @@ -142,9 +162,7 @@ export const TTYAView: React.FC = ({ agent, port, autoApprove }) - - [a] approve [r] reject [↑/↓] navigate [q] quit - + [a] approve [r] reject [↑/↓] navigate [q] quit ); diff --git a/packages/cli/src/ttya-secret.ts b/packages/cli/src/ttya-secret.ts new file mode 100644 index 0000000..6367128 --- /dev/null +++ b/packages/cli/src/ttya-secret.ts @@ -0,0 +1,63 @@ +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { copyAndValidateTTYAAuthSecret } from '@networkselfmd/core'; + +export const TTYA_PSK_ENV = 'NETWORKSELFMD_TTYA_PSK'; + +function decodeEnvironmentSecret(value: string): Uint8Array { + const trimmed = value.trim(); + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, 'hex'); + } + const decoded = Buffer.from(trimmed, 'base64'); + if ( + decoded.toString('base64').replace(/=+$/, '') !== trimmed.replace(/=+$/, '') + ) { + throw new Error(`${TTYA_PSK_ENV} must be canonical hex or base64`); + } + return decoded; +} + +/** + * Load the single PSK used by Agent, TTYAManager, TTYABridge, and TTYAServer. + * An environment value takes precedence; otherwise an owner-only raw key file + * is loaded or created atomically. + */ +export function loadOrCreateTTYASecret( + keyPath: string, + environmentValue = process.env[TTYA_PSK_ENV], +): Uint8Array { + if (environmentValue) { + return copyAndValidateTTYAAuthSecret( + decodeEnvironmentSecret(environmentValue), + ); + } + + mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 }); + try { + const fd = openSync(keyPath, 'wx', 0o600); + try { + writeFileSync(fd, randomBytes(32)); + } finally { + closeSync(fd); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EEXIST') throw error; + } + const permissions = statSync(keyPath).mode & 0o777; + if (process.platform !== 'win32' && (permissions & 0o077) !== 0) { + throw new Error( + `TTYA PSK file must not be accessible by group or others: ${keyPath}`, + ); + } + return copyAndValidateTTYAAuthSecret(readFileSync(keyPath)); +} diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..9feacf1 --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 network.self.md contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md index 985e269..69288ef 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,15 +6,16 @@ Pure cryptographic primitives and protocol definitions for agent-to-agent encryp ## What's Inside -| Module | Purpose | -|--------|---------| -| **Identity** | Ed25519 keypairs, X25519 key derivation, fingerprinting | -| **AEAD** | XChaCha20-Poly1305 authenticated encryption | -| **KDF** | HKDF-SHA256 key derivation and chain advancement | -| **Signatures** | Ed25519 signing and verification | -| **Sender Keys** | Signal Protocol–style symmetric ratchet for group messages | -| **Double Ratchet** | Asynchronous DH ratchet + symmetric chains for 1-to-1 messages | -| **Messages** | CBOR encoding/framing, type definitions | +| Module | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | +| **Identity** | Ed25519 keypairs, X25519 key derivation, fingerprinting | +| **AEAD** | XChaCha20-Poly1305 authenticated encryption | +| **KDF** | HKDF-SHA256 key derivation and chain advancement | +| **Signatures** | Ed25519 signing and verification | +| **Sender Keys** | Signal Protocol–style symmetric ratchet for group messages | +| **Double Ratchet** | Asynchronous DH ratchet + symmetric chains for 1-to-1 messages | +| **Signed Group Epochs** | GroupEpoch hash chain with CBOR serialization, SHA-256 hashing, and Ed25519-signed state transitions | +| **Messages** | CBOR encoding/framing, type definitions | All crypto uses **audited libraries** ([Noble curves/hashes/ciphers](https://github.com/paulmillr/noble-crypto)). Zero custom cryptography. @@ -37,7 +38,10 @@ pnpm add @networkselfmd/core Generate an agent identity (Ed25519 keypair + X25519 derive): ```typescript -import { generateIdentity, fingerprintFromPublicKey } from '@networkselfmd/core'; +import { + generateIdentity, + fingerprintFromPublicKey, +} from '@networkselfmd/core'; const identity = generateIdentity('Alice'); console.log(identity.fingerprint); // "z-base-32 encoded, human readable" @@ -74,7 +78,7 @@ const derivedKey = deriveKey( inputKey, 'optional-salt', 'info-string', - 32 // length in bytes + 32, // length in bytes ); // Advance a chain (for ratcheting) @@ -106,16 +110,17 @@ import { SenderKeys } from '@networkselfmd/core/protocol'; const senderState = SenderKeys.generate(); const { ciphertext, nonce, chainIndex, nextState } = SenderKeys.encrypt( senderState, - plaintext + plaintext, ); // Update sender state after each encryption let state = nextState; -// Receiver: create a record from sender's distribution message +// Receiver: create a record only after decrypting and authenticating the +// recipient-specific SenderKeyDistribution envelope const record: SenderKeyRecord = { - chainKey: distributionMessage.chainKey, - chainIndex: distributionMessage.chainIndex, + chainKey: decryptedDistribution.chainKey, + chainIndex: decryptedDistribution.chainIndex, skippedKeys: new Map(), }; @@ -124,11 +129,12 @@ const { plaintext: decrypted, nextRecord } = SenderKeys.decrypt( record, chainIndex, nonce, - ciphertext + ciphertext, ); ``` **Features:** + - One symmetric encryption per message (efficient for groups) - Out-of-order delivery support via skipped key cache - Per-sender ratchet chains @@ -172,6 +178,7 @@ const { plaintext: decrypted, nextState: newReceiverState } = ``` **Features:** + - X25519 key ratchet on every message (optional sender-side, mandatory receiver-side) - Symmetric HKDF chains for message derivation - Forward secrecy: compromising current keys doesn't expose past messages @@ -183,16 +190,24 @@ const { plaintext: decrypted, nextState: newReceiverState } = CBOR-encode and frame messages for network transmission: ```typescript -import { encodeMessage, frameMessage, parseFrame } from '@networkselfmd/core/protocol'; +import { + encodeMessage, + frameMessage, + parseFrame, +} from '@networkselfmd/core/protocol'; const message: GroupEncryptedMessage = { type: MessageType.GroupMessage, groupId: new Uint8Array(32), - senderFingerprint: 'abc123...', + senderFingerprint: 'y'.repeat(32), chainIndex: 5, + generationId: new Uint8Array(16), + epochVersion: 2, + epochHash: new Uint8Array(32), ciphertext: new Uint8Array(100), nonce: new Uint8Array(24), timestamp: Date.now(), + signature: new Uint8Array(64), }; // Encode to CBOR bytes @@ -211,6 +226,7 @@ if (result) { ``` **Framing:** + - 4-byte big-endian uint32 length prefix - CBOR-encoded payload - Max frame size: 1 MiB @@ -227,32 +243,41 @@ if (result) { ### Crypto Module **AEAD:** + - `encrypt(key, plaintext): { ciphertext, nonce }` — XChaCha20-Poly1305 - `decrypt(key, nonce, ciphertext): Uint8Array` — Decrypt **KDF:** + - `deriveKey(ikm, salt, info, length): Uint8Array` — HKDF-SHA256 - `advanceChain(chainKey): { messageKey, nextChainKey }` — Ratchet chain **Signatures:** + - `sign(message, privateKey): Uint8Array` — Ed25519 sign - `verify(signature, message, publicKey): boolean` — Ed25519 verify ### Protocol Module **Sender Keys:** + - `SenderKeys.generate(): SenderKeyState` - `SenderKeys.encrypt(state, plaintext): { ciphertext, nonce, chainIndex, nextState }` - `SenderKeys.decrypt(record, chainIndex, nonce, ciphertext): { plaintext, nextRecord }` -- `SenderKeys.createDistribution(groupId, state, signingPublicKey): SenderKeyDistributionMessage` +- `SenderKeys.createDistribution(groupId, state, signingPublicKey, epochVersion, epochHash): SenderKeyDistributionPayload` +- `SenderKeys.encryptDistribution(payload, senderXPrivateKey, senderPublicKey, recipientXPublicKey, recipientPublicKey): SenderKeyDistributionMessage` +- `SenderKeys.decryptDistribution(message, recipientXPrivateKey, recipientPublicKey, authenticatedSenderXPublicKey, authenticatedSenderPublicKey): SenderKeyDistributionPayload` +- `senderKeyEnvelopeId(message): Uint8Array` — canonical replay identity for an opaque encrypted distribution envelope **Double Ratchet:** + - `DoubleRatchet.initSender(sharedSecret, recipientRatchetPublic): DoubleRatchetState` - `DoubleRatchet.initReceiver(sharedSecret, ownRatchetKeyPair): DoubleRatchetState` - `DoubleRatchet.encrypt(state, plaintext): { ciphertext, nonce, ratchetPublicKey, previousChainLength, messageNumber, nextState }` - `DoubleRatchet.decrypt(state, ratchetPublicKey, previousChainLength, messageNumber, nonce, ciphertext): { plaintext, nextState }` **Messages:** + - `encodeMessage(message: ProtocolMessage): Uint8Array` — CBOR encode - `decodeMessage(bytes: Uint8Array): ProtocolMessage` — CBOR decode - `frameMessage(message: ProtocolMessage): Uint8Array` — Add length prefix @@ -264,11 +289,11 @@ if (result) { ```typescript interface AgentIdentity { - edPrivateKey: Uint8Array; // Ed25519 private key - edPublicKey: Uint8Array; // Ed25519 public key - xPrivateKey: Uint8Array; // X25519 private key (DH) - xPublicKey: Uint8Array; // X25519 public key (DH) - fingerprint: string; // Human-readable identifier + edPrivateKey: Uint8Array; // Ed25519 private key + edPublicKey: Uint8Array; // Ed25519 public key + xPrivateKey: Uint8Array; // X25519 private key (DH) + xPublicKey: Uint8Array; // X25519 public key (DH) + fingerprint: string; // Human-readable identifier displayName?: string; } ``` @@ -277,8 +302,8 @@ interface AgentIdentity { ```typescript interface SenderKeyState { - chainKey: Uint8Array; // Current chain key - chainIndex: number; // Message counter + chainKey: Uint8Array; // Current chain key + chainIndex: number; // Message counter } interface SenderKeyRecord { @@ -292,7 +317,7 @@ interface SenderKeyRecord { ```typescript interface DoubleRatchetState { - rootKey: Uint8Array; // Root secret + rootKey: Uint8Array; // Root secret sendChainKey: Uint8Array | null; receiveChainKey: Uint8Array | null; sendRatchetPrivate: Uint8Array; @@ -309,12 +334,13 @@ interface DoubleRatchetState { - `IdentityHandshake` — Peer authentication (Ed25519 signature + display name) - `GroupSync` — Group membership and epoch -- `SenderKeyDistribution` — Share sender's chain for group membership +- `SenderKeyDistribution` — Recipient-specific opaque encrypted sender-key envelope - `GroupMessage` — Encrypted message to group (Sender Keys) - `DirectMessage` — Encrypted 1-to-1 message (Double Ratchet) - `GroupManagement` — Invite, join, leave, kick, promote -- `TTYARequest` — Request from visitor to agent owner -- `TTYAResponse` — Agent's reply to visitor +- `GroupEpoch` — Signed group state transitions (epoch chain with Ed25519 admin signatures) +- `TTYARequest` — Deferred implementation: request from visitor to agent owner +- `TTYAResponse` — Deferred implementation: agent's reply to visitor - `Ack` — Acknowledgment ## Design Principles @@ -349,5 +375,5 @@ MIT - **Main Package:** [@networkselfmd/node](../node) — P2P runtime with Hyperswarm - **CLI:** [@networkselfmd/cli](../cli) — Terminal interface -- **Web:** [@networkselfmd/web](../web) — TTYA server and visitor chat +- **Web:** [@networkselfmd/web](../web) — Deferred browser bridge implementation reference - **MCP:** [@networkselfmd/mcp](../mcp) — Claude Code integration diff --git a/packages/core/package.json b/packages/core/package.json index 9dc21f2..f3bce11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,9 +1,25 @@ { "name": "@networkselfmd/core", "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/shmlkv/network.self.md.git", + "directory": "packages/core" + }, + "engines": { + "node": ">=20.0.0" + }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts", + "!dist/**/__tests__/**", + "README.md", + "LICENSE" + ], "exports": { ".": "./dist/index.js", "./crypto": "./dist/crypto/index.js", @@ -12,7 +28,8 @@ "scripts": { "build": "tsc", "test": "vitest run", - "dev": "tsc --watch" + "dev": "tsc --watch", + "prepack": "pnpm run build" }, "dependencies": { "@noble/curves": "^1.8.0", diff --git a/packages/core/src/__tests__/crypto.test.ts b/packages/core/src/__tests__/crypto.test.ts index b6c2a37..462908b 100644 --- a/packages/core/src/__tests__/crypto.test.ts +++ b/packages/core/src/__tests__/crypto.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { encrypt, decrypt } from '../crypto/aead.js'; -import { deriveKey, advanceChain } from '../crypto/kdf.js'; +import { deriveKey, advanceChain, computeSharedSecret } from '../crypto/kdf.js'; import { sign, verify } from '../crypto/signatures.js'; import { randomBytes } from '@noble/hashes/utils'; import { ed25519 } from '@noble/curves/ed25519'; +import { generateIdentity } from '../identity.js'; describe('AEAD (XChaCha20-Poly1305)', () => { it('encrypts and decrypts roundtrip', () => { @@ -72,6 +73,33 @@ describe('KDF', () => { }); }); +describe('computeSharedSecret', () => { + it('is symmetric: both peers derive the same shared secret', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const secretAB = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + const secretBA = computeSharedSecret(bob.xPrivateKey, alice.xPublicKey); + expect(secretAB).toEqual(secretBA); + }); + + it('produces a 32-byte key', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const secret = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + expect(secret.length).toBe(32); + expect(secret).toBeInstanceOf(Uint8Array); + }); + + it('produces different secrets for different peer pairs', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const carol = generateIdentity('carol'); + const secretAB = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + const secretAC = computeSharedSecret(alice.xPrivateKey, carol.xPublicKey); + expect(secretAB).not.toEqual(secretAC); + }); +}); + describe('Signatures', () => { it('sign and verify roundtrip', () => { const privateKey = randomBytes(32); diff --git a/packages/core/src/__tests__/double-ratchet.test.ts b/packages/core/src/__tests__/double-ratchet.test.ts index e474051..abe97c9 100644 --- a/packages/core/src/__tests__/double-ratchet.test.ts +++ b/packages/core/src/__tests__/double-ratchet.test.ts @@ -199,4 +199,34 @@ describe('DoubleRatchet', () => { ); expect(new TextDecoder().decode(p1)).toBe('msg-1'); }); + + it('bounds the total skipped-key cache across successive gaps without mutating rejected state', () => { + let { aliceState, bobState } = setupSession(); + const messages: Array> = []; + for (let i = 0; i <= 511; i++) { + const encrypted = DoubleRatchet.encrypt(aliceState, new TextEncoder().encode(`msg-${i}`)); + aliceState = encrypted.nextState; + messages.push(encrypted); + } + const receive = (index: number) => { + const message = messages[index]; + return DoubleRatchet.decrypt(bobState, message.ratchetPublicKey, + message.previousChainLength, message.messageNumber, message.nonce, message.ciphertext); + }; + bobState = receive(255).nextState; + expect(bobState.skippedKeys.size).toBe(255); + const before = structuredClone(bobState); + expect(() => receive(511)).toThrow('Too many skipped message keys'); + expect(bobState).toEqual(before); + // Consuming an older message frees space without discarding any keys. + expect(new TextDecoder().decode(receive(0).plaintext)).toBe('msg-0'); + bobState = receive(0).nextState; + bobState = receive(257).nextState; + expect(bobState.skippedKeys.size).toBe(255); + bobState = receive(259).nextState; + expect(bobState.skippedKeys.size).toBe(256); + bobState = receive(260).nextState; + expect(bobState.skippedKeys.size).toBe(256); + expect(() => receive(262)).toThrow('Too many skipped message keys'); + }); }); diff --git a/packages/core/src/__tests__/epoch-envelope.test.ts b/packages/core/src/__tests__/epoch-envelope.test.ts new file mode 100644 index 0000000..d9c8789 --- /dev/null +++ b/packages/core/src/__tests__/epoch-envelope.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + canonicalGroupEpochEnvelope, + createGenesisEpoch, + createSignedEpoch, + generateIdentity, + MessageType, + serializeEpoch, + signGroupEpochEnvelope, + verifyGroupEpochEnvelope, +} from '../index.js'; + +describe('GroupEpoch delivery envelope', () => { + it('uses a fresh signed envelope timestamp without changing the epoch', () => { + const sender = generateIdentity(); + const recipient = generateIdentity(); + const epoch = createSignedEpoch( + createGenesisEpoch('01'.repeat(32), sender.edPublicKey, 100), + sender.edPrivateKey, + ); + const base = { + type: MessageType.GroupEpoch, + protocolVersion: 2, + groupId: new Uint8Array(32).fill(1), + epochData: serializeEpoch(epoch.epoch), + signature: epoch.signature, + hash: epoch.hash, + senderFingerprint: sender.fingerprint, + recipientFingerprint: recipient.fingerprint, + } as const; + const first = signGroupEpochEnvelope( + { ...base, timestamp: 1_000 }, + sender.edPrivateKey, + ); + const catchup = signGroupEpochEnvelope( + { ...base, timestamp: 2_000 }, + sender.edPrivateKey, + ); + expect(first.epochData).toEqual(catchup.epochData); + expect(first.hash).toEqual(catchup.hash); + expect(first.signature).toEqual(catchup.signature); + expect(first.envelopeSignature).not.toEqual(catchup.envelopeSignature); + expect(verifyGroupEpochEnvelope(catchup, sender.edPublicKey)).toBe(true); + expect( + verifyGroupEpochEnvelope( + { ...catchup, recipientFingerprint: sender.fingerprint }, + sender.edPublicKey, + ), + ).toBe(false); + }); + + it('matches the canonical envelope golden vector', () => { + const payload = canonicalGroupEpochEnvelope({ + type: MessageType.GroupEpoch, + protocolVersion: 2, + groupId: new Uint8Array(32).fill(1), + epochData: new Uint8Array([2, 3]), + signature: new Uint8Array(64).fill(4), + hash: new Uint8Array(32).fill(5), + senderFingerprint: 'y'.repeat(32), + recipientFingerprint: 'b'.repeat(32), + timestamp: 6, + }); + expect(Buffer.from(payload).toString('hex')).toBe( + '6e6574776f726b2e73656c662e6d642f47726f757045706f6368456e76656c6f70652f7632000a000201010101010101010101010101010101010101010101010101010101010101010000000202030404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040405050505050505050505050505050505050505050505050505050505050505050000002079797979797979797979797979797979797979797979797979797979797979790000002062626262626262626262626262626262626262626262626262626262626262620000000000000006', + ); + }); +}); diff --git a/packages/core/src/__tests__/group-state.test.ts b/packages/core/src/__tests__/group-state.test.ts new file mode 100644 index 0000000..dca3851 --- /dev/null +++ b/packages/core/src/__tests__/group-state.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest'; +import { ed25519 } from '@noble/curves/ed25519'; +import { randomBytes } from '@noble/hashes/utils'; +import { + serializeEpoch, + deserializeEpoch, + hashEpoch, + createSignedEpoch, + verifyEpoch, + verifyGenesisEpoch, + createGenesisEpoch, + type GroupEpoch, + type SignedGroupEpoch, +} from '../protocol/group-state.js'; + +function generateKeypair() { + const privateKey = randomBytes(32); + const publicKey = ed25519.getPublicKey(privateKey); + return { privateKey, publicKey }; +} + +describe('GroupEpoch serialization', () => { + it('roundtrips through serialize/deserialize', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('test-group', admin.publicKey); + + const serialized = serializeEpoch(epoch); + const deserialized = deserializeEpoch(serialized); + + expect(deserialized.version).toBe(0); + expect(deserialized.groupId).toBe('test-group'); + expect(deserialized.members.length).toBe(1); + expect(deserialized.members[0].role).toBe('admin'); + expect(new Uint8Array(deserialized.createdBy)).toEqual(admin.publicKey); + expect(new Uint8Array(deserialized.prevHash)).toEqual(new Uint8Array(32)); + }); + + it('produces deterministic serialization', () => { + const admin = generateKeypair(); + const epoch: GroupEpoch = { + version: 1, + prevHash: new Uint8Array(32), + groupId: 'g1', + members: [{ publicKey: admin.publicKey, role: 'admin' }], + createdAt: 1000, + createdBy: admin.publicKey, + }; + + const a = serializeEpoch(epoch); + const b = serializeEpoch(epoch); + expect(a).toEqual(b); + }); + + it('matches the canonical domain/version golden vector', () => { + const epoch: GroupEpoch = { + version: 0, + prevHash: new Uint8Array(32), + groupId: '01', + members: [{ publicKey: new Uint8Array(32).fill(2), role: 'admin' }], + createdAt: 3, + createdBy: new Uint8Array(32).fill(2), + }; + expect(Buffer.from(serializeEpoch(epoch)).toString('hex')).toBe( + '88781a6e6574776f726b2e73656c662e6d642f47726f757045706f63680100d840582000000000000000000000000000000000000000000000000000000000000000006230318182d840582002020202020202020202020202020202020202020202020202020202020202026561646d696e03d84058200202020202020202020202020202020202020202020202020202020202020202', + ); + }); +}); + +describe('GroupEpoch hashing', () => { + it('produces 32-byte SHA-256 hash', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + const hash = hashEpoch(serializeEpoch(epoch)); + expect(hash.length).toBe(32); + }); + + it('different epochs produce different hashes', () => { + const admin = generateKeypair(); + const e1 = createGenesisEpoch('g1', admin.publicKey); + const e2 = createGenesisEpoch('g2', admin.publicKey); + const h1 = hashEpoch(serializeEpoch(e1)); + const h2 = hashEpoch(serializeEpoch(e2)); + expect(h1).not.toEqual(h2); + }); +}); + +describe('createSignedEpoch', () => { + it('creates a valid signed epoch', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + const signed = createSignedEpoch(epoch, admin.privateKey); + + expect(signed.epoch).toBe(epoch); + expect(signed.signature.length).toBe(64); + expect(signed.hash.length).toBe(32); + }); +}); + +describe('verifyEpoch', () => { + it('verifies a valid genesis epoch', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + const signed = createSignedEpoch(epoch, admin.privateKey); + + expect(verifyEpoch(signed, new Uint8Array(32))).toBe(true); + }); + + it('verifies a valid chained epoch', () => { + const admin = generateKeypair(); + const member = generateKeypair(); + + const genesis = createGenesisEpoch('g1', admin.publicKey); + const signedGenesis = createSignedEpoch(genesis, admin.privateKey); + + const epoch1: GroupEpoch = { + version: 1, + prevHash: signedGenesis.hash, + groupId: 'g1', + members: [ + { publicKey: admin.publicKey, role: 'admin' }, + { publicKey: member.publicKey, role: 'member' }, + ], + createdAt: Date.now(), + createdBy: admin.publicKey, + }; + const signed1 = createSignedEpoch(epoch1, admin.privateKey); + + expect(verifyEpoch(signed1, signedGenesis.hash)).toBe(true); + }); + + it('rejects epoch with wrong signature', () => { + const admin = generateKeypair(); + const attacker = generateKeypair(); + + const epoch = createGenesisEpoch('g1', admin.publicKey); + // Sign with attacker's key but createdBy is admin + const signed = createSignedEpoch(epoch, attacker.privateKey); + + expect(verifyEpoch(signed, new Uint8Array(32))).toBe(false); + }); + + it('rejects epoch with wrong prevHash', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + const signed = createSignedEpoch(epoch, admin.privateKey); + + const wrongPrevHash = new Uint8Array(32).fill(0xff); + expect(verifyEpoch(signed, wrongPrevHash)).toBe(false); + }); + + it('rejects epoch where createdBy is not admin', () => { + const admin = generateKeypair(); + const member = generateKeypair(); + + const epoch: GroupEpoch = { + version: 0, + prevHash: new Uint8Array(32), + groupId: 'g1', + members: [ + { publicKey: admin.publicKey, role: 'admin' }, + { publicKey: member.publicKey, role: 'member' }, + ], + createdAt: Date.now(), + createdBy: member.publicKey, // member, not admin + }; + const signed = createSignedEpoch(epoch, member.privateKey); + + expect(verifyEpoch(signed, new Uint8Array(32))).toBe(false); + }); + + it('rejects tampered epoch data', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + const signed = createSignedEpoch(epoch, admin.privateKey); + + // Tamper with the epoch + signed.epoch.groupId = 'g2-hijacked'; + + expect(verifyEpoch(signed, new Uint8Array(32))).toBe(false); + }); +}); + +describe('createGenesisEpoch', () => { + it('creates version 0 with zero prevHash', () => { + const admin = generateKeypair(); + const epoch = createGenesisEpoch('g1', admin.publicKey); + + expect(epoch.version).toBe(0); + expect(epoch.prevHash).toEqual(new Uint8Array(32)); + expect(epoch.groupId).toBe('g1'); + expect(epoch.members.length).toBe(1); + expect(epoch.members[0].role).toBe('admin'); + expect(new Uint8Array(epoch.members[0].publicKey)).toEqual(admin.publicKey); + expect(new Uint8Array(epoch.createdBy)).toEqual(admin.publicKey); + expect(epoch.createdAt).toBeGreaterThan(0); + }); +}); + +describe('verifyGenesisEpoch', () => { + it('accepts only the exact pinned v0 shape', () => { + const admin = generateKeypair(); + const member = generateKeypair(); + const valid = createSignedEpoch( + createGenesisEpoch('g1', admin.publicKey, 1), + admin.privateKey, + ); + expect(verifyGenesisEpoch(valid, 'g1', admin.publicKey)).toBe(true); + expect(verifyGenesisEpoch(valid, 'g2', admin.publicKey)).toBe(false); + + const notExact = createSignedEpoch( + { + ...valid.epoch, + members: [ + ...valid.epoch.members, + { publicKey: member.publicKey, role: 'member' }, + ], + }, + admin.privateKey, + ); + expect(verifyGenesisEpoch(notExact, 'g1', admin.publicKey)).toBe(false); + }); + + it('rejects a signed v0 that is not the exact one-admin trust anchor', () => { + const admin = generateKeypair(); + const member = generateKeypair(); + const forgedV0: GroupEpoch = { + ...createGenesisEpoch('g1', admin.publicKey), + members: [ + { publicKey: admin.publicKey, role: 'admin' }, + { publicKey: member.publicKey, role: 'member' }, + ], + }; + expect(verifyGenesisEpoch(createSignedEpoch(forgedV0, admin.privateKey), 'g1', admin.publicKey)).toBe(false); + }); +}); + +describe('epoch chain integrity', () => { + it('validates a 3-epoch chain', () => { + const admin = generateKeypair(); + const m1 = generateKeypair(); + const m2 = generateKeypair(); + + // Genesis + const e0 = createGenesisEpoch('g1', admin.publicKey); + const s0 = createSignedEpoch(e0, admin.privateKey); + expect(verifyEpoch(s0, new Uint8Array(32))).toBe(true); + + // Add member 1 + const e1: GroupEpoch = { + version: 1, + prevHash: s0.hash, + groupId: 'g1', + members: [ + { publicKey: admin.publicKey, role: 'admin' }, + { publicKey: m1.publicKey, role: 'member' }, + ], + createdAt: Date.now(), + createdBy: admin.publicKey, + }; + const s1 = createSignedEpoch(e1, admin.privateKey); + expect(verifyEpoch(s1, s0.hash)).toBe(true); + + // Add member 2 + const e2: GroupEpoch = { + version: 2, + prevHash: s1.hash, + groupId: 'g1', + members: [ + { publicKey: admin.publicKey, role: 'admin' }, + { publicKey: m1.publicKey, role: 'member' }, + { publicKey: m2.publicKey, role: 'member' }, + ], + createdAt: Date.now(), + createdBy: admin.publicKey, + }; + const s2 = createSignedEpoch(e2, admin.privateKey); + expect(verifyEpoch(s2, s1.hash)).toBe(true); + + // Verify chain breaks if we skip an epoch + expect(verifyEpoch(s2, s0.hash)).toBe(false); + }); + + it('rejects fork attempt by non-admin', () => { + const admin = generateKeypair(); + const attacker = generateKeypair(); + + const e0 = createGenesisEpoch('g1', admin.publicKey); + const s0 = createSignedEpoch(e0, admin.privateKey); + + // Attacker tries to create a fork adding themselves as admin + const forked: GroupEpoch = { + version: 1, + prevHash: s0.hash, + groupId: 'g1', + members: [{ publicKey: attacker.publicKey, role: 'admin' }], + createdAt: Date.now(), + createdBy: attacker.publicKey, + }; + const signedFork = createSignedEpoch(forked, attacker.privateKey); + + // Signature is valid for attacker's key, but attacker wasn't admin in epoch 0 + // verifyEpoch checks that createdBy is admin in the epoch's own member list, + // but the caller (group-manager) must also verify against the previous epoch + // Here verifyEpoch passes because attacker IS listed as admin in their own epoch + // The real security check is in group-manager which compares against previous epoch + expect(signedFork.signature.length).toBe(64); + }); +}); diff --git a/packages/core/src/__tests__/identity.test.ts b/packages/core/src/__tests__/identity.test.ts index 390d272..6328558 100644 --- a/packages/core/src/__tests__/identity.test.ts +++ b/packages/core/src/__tests__/identity.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { generateIdentity, fingerprintFromPublicKey, zBase32Encode } from '../identity.js'; +import { x25519 } from '@noble/curves/ed25519'; describe('identity', () => { it('generates a valid identity with all key fields', () => { @@ -43,4 +44,10 @@ describe('identity', () => { const identity = generateIdentity(); expect(identity.displayName).toBeUndefined(); }); + + it('xPublicKey is correctly derived from xPrivateKey', () => { + const identity = generateIdentity('key-check'); + const expectedXPublicKey = x25519.getPublicKey(identity.xPrivateKey); + expect(identity.xPublicKey).toEqual(expectedXPublicKey); + }); }); diff --git a/packages/core/src/__tests__/messages.test.ts b/packages/core/src/__tests__/messages.test.ts index 2a61897..e2cc917 100644 --- a/packages/core/src/__tests__/messages.test.ts +++ b/packages/core/src/__tests__/messages.test.ts @@ -18,10 +18,11 @@ describe('messages (CBOR encoding)', () => { const sampleHandshake: ProtocolMessage = { type: MessageType.IdentityHandshake, edPublicKey: new Uint8Array(32).fill(1), + xPublicKey: new Uint8Array(32).fill(4), noisePublicKey: new Uint8Array(32).fill(2), signature: new Uint8Array(64).fill(3), displayName: 'agent-1', - protocolVersion: 1, + protocolVersion: 3, timestamp: 1700000000000, }; @@ -40,11 +41,15 @@ describe('messages (CBOR encoding)', () => { const msg = decoded as typeof sampleHandshake; expect(msg.edPublicKey).toEqual(new Uint8Array(32).fill(1)); expect(msg.displayName).toBe('agent-1'); - expect(msg.protocolVersion).toBe(1); + expect(msg.protocolVersion).toBe(3); }); it('decodeMessage throws on invalid type', () => { - const bad = encodeMessage({ ...sampleAck, type: 0xfe as any }); + const { Encoder } = require('cbor-x'); + const bad = new Encoder({ useRecords: false }).encode({ + ...sampleAck, + type: 0xfe, + }); expect(() => decodeMessage(bad)).toThrow(/invalid message type/i); }); @@ -53,7 +58,7 @@ describe('messages (CBOR encoding)', () => { const { Encoder } = require('cbor-x'); const enc = new Encoder({ useRecords: false }); const bytes = enc.encode({ foo: 'bar' }); - expect(() => decodeMessage(bytes)).toThrow(/missing type/i); + expect(() => decodeMessage(bytes)).toThrow(/type is required/i); }); }); @@ -88,6 +93,26 @@ describe('framing', () => { expect(result).toBeNull(); }); + it('rejects an empty frame payload', () => { + expect(() => parseFrame(new Uint8Array(4))).toThrow(/empty payload/i); + }); + + it('rejects malformed CBOR in a complete frame', () => { + expect(() => parseFrame(new Uint8Array([0, 0, 0, 1, 0x1a]))).toThrow( + /malformed CBOR/i, + ); + }); + + it('consumes exactly one frame and leaves trailing bytes to the caller', () => { + const first = frameMessage(sampleMsg); + const second = frameMessage({ ...sampleMsg, messageId: 'second' }); + const combined = new Uint8Array(first.length + second.length); + combined.set(first); + combined.set(second, first.length); + const parsed = parseFrame(combined); + expect(parsed?.bytesConsumed).toBe(first.length); + }); + it('rejects frames exceeding MAX_FRAME_SIZE', () => { // Create a buffer with length prefix > MAX_FRAME_SIZE const buf = new Uint8Array(8); @@ -103,6 +128,6 @@ describe('framing', () => { messageId: 'x'.repeat(MAX_FRAME_SIZE), timestamp: 0, }; - expect(() => frameMessage(bigMsg)).toThrow(/max_frame_size/i); + expect(() => frameMessage(bigMsg)).toThrow(/messageId/i); }); }); diff --git a/packages/core/src/__tests__/network-announce.test.ts b/packages/core/src/__tests__/network-announce.test.ts index 4916e4d..4dddb9f 100644 --- a/packages/core/src/__tests__/network-announce.test.ts +++ b/packages/core/src/__tests__/network-announce.test.ts @@ -1,35 +1,163 @@ -import { describe, it, expect } from 'vitest'; -import { encodeMessage, decodeMessage, MessageType } from '../protocol/index.js'; -import type { NetworkAnnounceMessage } from '../protocol/index.js'; - -describe('NetworkAnnounce message', () => { - it('encodes and decodes round-trip', () => { - const msg: NetworkAnnounceMessage = { - type: MessageType.NetworkAnnounce, - groups: [{ - groupId: new Uint8Array([1, 2, 3]), - name: 'builders', - selfMd: 'We build network.self.md. Ship > discuss.', - memberCount: 3, - }], - timestamp: Date.now(), - }; - const encoded = encodeMessage(msg); - const decoded = decodeMessage(encoded) as NetworkAnnounceMessage; - expect(decoded.type).toBe(MessageType.NetworkAnnounce); - expect(decoded.groups).toHaveLength(1); - expect(decoded.groups[0].name).toBe('builders'); - expect(decoded.groups[0].selfMd).toBe('We build network.self.md. Ship > discuss.'); +import { describe, expect, it } from 'vitest'; +import { + assertAnnounceShape, + canonicalAnnouncePayload, + createGenesisEpoch, + createSignedEpoch, + decodeMessage, + encodeMessage, + generateIdentity, + MessageType, + NETWORK_ANNOUNCE_VERSION, + serializeEpoch, + signAnnounce, + verifyAnnounce, + verifyAnnouncedGroupAuthority, +} from '../index.js'; +import type { AgentIdentity, NetworkAnnounceMessage } from '../index.js'; + +function announcedGroup(identity = generateIdentity(), fill = 1) { + const groupId = new Uint8Array(32).fill(fill); + const genesis = createSignedEpoch( + createGenesisEpoch(toHex(groupId), identity.edPublicKey, 1_700_000_000_000), + identity.edPrivateKey, + ); + return { + identity, + group: { + groupId, + name: 'builders', + selfMd: 'We build things.', + memberCount: 1, + genesisEpochData: serializeEpoch(genesis.epoch), + genesisSignature: genesis.signature, + genesisHash: genesis.hash, + }, + }; +} + +function announce(identity: AgentIdentity): NetworkAnnounceMessage { + const { group } = announcedGroup(identity); + const timestamp = Date.now(); + return { + type: MessageType.NetworkAnnounce, + protocolVersion: NETWORK_ANNOUNCE_VERSION, + groups: [group], + signature: signAnnounce([group], timestamp, identity.edPrivateKey), + timestamp, + }; +} + +describe('NetworkAnnounce canonical authentication', () => { + it('round-trips and verifies the exhaustive versioned schema', () => { + const identity = generateIdentity(); + const message = announce(identity); + const decoded = decodeMessage( + encodeMessage(message), + ) as NetworkAnnounceMessage; + + expect(decoded).toEqual(message); + expect(decoded.protocolVersion).toBe(2); + expect( + verifyAnnounce( + decoded.groups, + decoded.timestamp, + decoded.signature, + identity.edPublicKey, + decoded.protocolVersion, + ), + ).toBe(true); + }); + + it('binds version, ordered groups, provenance, contents, and timestamp', () => { + const { identity, group } = announcedGroup(); + const timestamp = 1_700_000_000_001; + const signature = signAnnounce([group], timestamp, identity.edPrivateKey); + + expect( + verifyAnnounce([group], timestamp, signature, identity.edPublicKey), + ).toBe(true); + expect( + verifyAnnounce( + [{ ...group, selfMd: 'tampered' }], + timestamp, + signature, + identity.edPublicKey, + ), + ).toBe(false); + expect( + verifyAnnounce([group], timestamp + 1, signature, identity.edPublicKey), + ).toBe(false); + expect( + verifyAnnounce([group], timestamp, signature, identity.edPublicKey, 1), + ).toBe(false); + expect( + verifyAnnounce( + [group], + timestamp, + signature, + generateIdentity().edPublicKey, + ), + ).toBe(false); + }); + + it('enforces freshness, canonical order, duplicates, and field limits', () => { + const message = announce(generateIdentity()); + expect(() => + assertAnnounceShape(message, message.timestamp + 300_001), + ).toThrow(/stale/i); + expect(() => + assertAnnounceShape({ + ...message, + groups: [message.groups[0], message.groups[0]], + }), + ).toThrow(/canonical/i); + expect(() => + assertAnnounceShape({ + ...message, + groups: [{ ...message.groups[0], name: 'x'.repeat(129) }], + }), + ).toThrow(/name/i); + + const first = announcedGroup(undefined, 1).group; + const second = announcedGroup(undefined, 2).group; + expect(() => canonicalAnnouncePayload(2, [second, first], 1)).toThrow( + /canonical/i, + ); }); - it('handles empty groups list', () => { - const msg: NetworkAnnounceMessage = { - type: MessageType.NetworkAnnounce, - groups: [], - timestamp: Date.now(), + it('requires the exact signed genesis anchored to the announcer', () => { + const { identity, group } = announcedGroup(); + expect(verifyAnnouncedGroupAuthority(group, identity.edPublicKey)).toBe( + true, + ); + expect( + verifyAnnouncedGroupAuthority(group, generateIdentity().edPublicKey), + ).toBe(false); + expect( + verifyAnnouncedGroupAuthority( + { ...group, genesisHash: new Uint8Array(32) }, + identity.edPublicKey, + ), + ).toBe(false); + }); + + it('matches the canonical payload golden vector', () => { + const group = { + groupId: new Uint8Array(32).fill(1), + name: 'g', + selfMd: '', + memberCount: 1, + genesisEpochData: new Uint8Array([0xaa]), + genesisSignature: new Uint8Array(64).fill(2), + genesisHash: new Uint8Array(32).fill(3), }; - const encoded = encodeMessage(msg); - const decoded = decodeMessage(encoded) as NetworkAnnounceMessage; - expect(decoded.groups).toHaveLength(0); + expect(toHex(canonicalAnnouncePayload(2, [group], 5))).toBe( + '6e6574776f726b2e73656c662e6d642f4e6574776f726b416e6e6f756e63652f76320000020000000000000005000101010101010101010101010101010101010101010101010101010101010101010000000167000000000000000100000001aa020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020303030303030303030303030303030303030303030303030303030303030303', + ); }); }); + +function toHex(value: Uint8Array): string { + return Buffer.from(value).toString('hex'); +} diff --git a/packages/core/src/__tests__/protocol-validation.test.ts b/packages/core/src/__tests__/protocol-validation.test.ts new file mode 100644 index 0000000..d6e5264 --- /dev/null +++ b/packages/core/src/__tests__/protocol-validation.test.ts @@ -0,0 +1,381 @@ +import { Encoder } from 'cbor-x'; +import { describe, expect, it } from 'vitest'; +import { + decodeMessage, + encodeMessage, + createGenesisEpoch, + createSignedEpoch, + generateIdentity, + MessageType, + signAuthenticatedMessage, + serializeEpoch, + verifyAuthenticatedMessage, +} from '../index.js'; +import type { + DirectEncryptedMessage, + GroupEncryptedMessage, + GroupManagementMessage, + ProtocolMessage, +} from '../index.js'; + +const rawEncoder = new Encoder({ useRecords: false }); +const fpA = 'y'.repeat(32); +const fpB = 'b'.repeat(32); +const bytes = (length: number, fill: number) => + new Uint8Array(length).fill(fill); +const anchorIdentity = generateIdentity(); +const anchorGroupId = bytes(32, 1); +const anchorGenesis = createSignedEpoch( + createGenesisEpoch( + Buffer.from(anchorGroupId).toString('hex'), + anchorIdentity.edPublicKey, + 1, + ), + anchorIdentity.edPrivateKey, +); +const anchorEpochData = serializeEpoch(anchorGenesis.epoch); + +const goldenMessages: ProtocolMessage[] = [ + { + type: MessageType.IdentityHandshake, + edPublicKey: bytes(32, 1), + xPublicKey: bytes(32, 2), + noisePublicKey: bytes(32, 3), + signature: bytes(64, 4), + displayName: 'agent', + protocolVersion: 3, + capabilities: ['sender-key-v2', 'group-epoch-v1'], + timestamp: 1, + }, + { + type: MessageType.GroupSync, + groupId: bytes(32, 1), + members: [bytes(32, 2)], + epoch: 0, + timestamp: 2, + }, + { + type: MessageType.SenderKeyDistribution, + protocolVersion: 2, + recipientPublicKey: bytes(32, 1), + ciphertext: bytes(16, 2), + nonce: bytes(24, 3), + timestamp: 4, + }, + { + type: MessageType.GroupMessage, + groupId: bytes(32, 1), + senderFingerprint: fpA, + chainIndex: 2, + generationId: bytes(16, 9), + epochVersion: 3, + epochHash: bytes(32, 2), + ciphertext: bytes(16, 3), + nonce: bytes(24, 4), + timestamp: 5, + signature: bytes(64, 6), + }, + { + type: MessageType.DirectMessage, + recipientFingerprint: fpB, + senderFingerprint: fpA, + ratchetPublicKey: bytes(32, 1), + previousChainLength: 2, + messageNumber: 3, + ciphertext: bytes(16, 4), + nonce: bytes(24, 5), + timestamp: 6, + signature: bytes(64, 7), + }, + { + type: MessageType.GroupManagement, + groupId: bytes(32, 1), + action: 'kick', + targetFingerprint: fpB, + senderFingerprint: fpA, + recipientFingerprint: fpB, + timestamp: 7, + signature: bytes(64, 8), + }, + { + type: MessageType.TTYARequest, + visitorId: 'visitor', + message: 'hello', + ipHash: 'hash', + timestamp: 8, + }, + { + type: MessageType.TTYAResponse, + visitorId: 'visitor', + message: 'reply', + timestamp: 9, + }, + { + type: MessageType.NetworkAnnounce, + protocolVersion: 2, + groups: [ + { + groupId: bytes(32, 1), + name: 'group', + selfMd: '', + memberCount: 1, + genesisEpochData: anchorEpochData, + genesisSignature: anchorGenesis.signature, + genesisHash: anchorGenesis.hash, + }, + ], + signature: bytes(64, 2), + timestamp: 10, + }, + { + type: MessageType.GroupEpoch, + protocolVersion: 2, + groupId: bytes(32, 1), + epochData: anchorEpochData, + signature: anchorGenesis.signature, + hash: anchorGenesis.hash, + senderFingerprint: fpA, + recipientFingerprint: fpB, + envelopeSignature: bytes(64, 5), + timestamp: 11, + }, + { type: MessageType.Ack, messageId: 'id', timestamp: 12 }, +]; + +describe('protocol runtime validation', () => { + it.each(goldenMessages)( + 'golden round-trips message type $type', + (message) => { + expect(decodeMessage(encodeMessage(message))).toEqual(message); + }, + ); + + it.each([ + ['wrong key size', { ...goldenMessages[0], edPublicKey: bytes(31, 1) }], + ['bad envelope version', { ...goldenMessages[2], protocolVersion: 1 }], + ['wrong nonce size', { ...goldenMessages[3], nonce: bytes(12, 1) }], + ['non-integer counter', { ...goldenMessages[4], messageNumber: 1.5 }], + ['missing action field', { ...goldenMessages[5], action: undefined }], + ['extra field', { ...goldenMessages[10], unexpected: true }], + [ + 'duplicate group members', + { + ...goldenMessages[1], + members: [bytes(32, 2), bytes(32, 2)], + }, + ], + [ + 'oversized ciphertext', + { ...goldenMessages[3], ciphertext: bytes(65_553, 1) }, + ], + [ + 'oversized UTF-8 display name', + { ...goldenMessages[0], displayName: 'é'.repeat(65) }, + ], + ['invalid nested epoch', { ...goldenMessages[9], epochData: bytes(1, 2) }], + [ + 'invalid announced genesis', + { + ...goldenMessages[8], + groups: [ + { + ...( + goldenMessages[8] as Extract< + ProtocolMessage, + { type: typeof MessageType.NetworkAnnounce } + > + ).groups[0], + genesisHash: bytes(32, 0), + }, + ], + }, + ], + ])('rejects malformed payload: %s', (_label, malformed) => { + expect(() => decodeMessage(rawEncoder.encode(malformed))).toThrow( + /invalid message/i, + ); + }); + + it.each( + goldenMessages.map((message) => [ + message.type, + { ...message, unexpected: true }, + ]), + )('rejects unknown fields for message type %s', (_type, malformed) => { + expect(() => decodeMessage(rawEncoder.encode(malformed))).toThrow( + /is not allowed/i, + ); + }); + + it('rejects malformed CBOR without exposing decoder errors', () => { + expect(() => decodeMessage(new Uint8Array([0xff]))).toThrow( + /invalid message/i, + ); + }); +}); + +describe('authenticated protocol payloads', () => { + const sender = generateIdentity(); + const recipient = generateIdentity(); + + function signedMessages() { + const timestamp = 1_700_000_000_000; + return [ + signAuthenticatedMessage( + { + type: MessageType.GroupMessage, + groupId: bytes(32, 1), + senderFingerprint: sender.fingerprint, + chainIndex: 3, + generationId: bytes(16, 9), + epochVersion: 1, + epochHash: bytes(32, 8), + ciphertext: bytes(16, 2), + nonce: bytes(24, 3), + timestamp, + }, + sender.edPrivateKey, + ), + signAuthenticatedMessage( + { + type: MessageType.DirectMessage, + senderFingerprint: sender.fingerprint, + recipientFingerprint: recipient.fingerprint, + ratchetPublicKey: bytes(32, 1), + previousChainLength: 2, + messageNumber: 3, + ciphertext: bytes(16, 4), + nonce: bytes(24, 5), + timestamp, + }, + sender.edPrivateKey, + ), + signAuthenticatedMessage( + { + type: MessageType.GroupManagement, + groupId: bytes(32, 1), + action: 'kick', + targetFingerprint: recipient.fingerprint, + senderFingerprint: sender.fingerprint, + recipientFingerprint: recipient.fingerprint, + timestamp, + }, + sender.edPrivateKey, + ), + ]; + } + + it.each(signedMessages())( + 'verifies an intact signed message type $type', + (message) => { + expect(verifyAuthenticatedMessage(message, sender.edPublicKey)).toBe( + true, + ); + }, + ); + + it('keeps signatures valid across Buffer/Uint8Array wire normalization', () => { + const timestamp = 1_700_000_000_000; + const message = signAuthenticatedMessage( + { + type: MessageType.GroupManagement, + groupId: Buffer.alloc(32, 1), + action: 'kick', + targetFingerprint: recipient.fingerprint, + senderFingerprint: sender.fingerprint, + recipientFingerprint: recipient.fingerprint, + timestamp, + }, + sender.edPrivateKey, + ); + const decoded = decodeMessage( + encodeMessage(message), + ) as GroupManagementMessage; + expect(verifyAuthenticatedMessage(decoded, sender.edPublicKey)).toBe(true); + }); + + it.each(signedMessages())( + 'rejects context/ciphertext swaps for type $type', + (message) => { + const swapped = + message.type === MessageType.DirectMessage || + message.type === MessageType.GroupManagement + ? { ...message, recipientFingerprint: fpB } + : { ...message, groupId: bytes(32, 9) }; + expect(verifyAuthenticatedMessage(swapped, sender.edPublicKey)).toBe( + false, + ); + }, + ); + + it('binds the sender-key generation to the group-message signature', () => { + const message = signedMessages()[0] as GroupEncryptedMessage; + expect( + verifyAuthenticatedMessage( + { ...message, generationId: bytes(16, 7) }, + sender.edPublicKey, + ), + ).toBe(false); + }); + + it('binds invite and epoch context to the management signature', () => { + const message = signAuthenticatedMessage( + { + type: MessageType.GroupManagement, + groupId: bytes(32, 1), + action: 'accept', + targetFingerprint: sender.fingerprint, + inviteId: 'invite-1', + epochVersion: 3, + epochHash: bytes(32, 8), + senderFingerprint: sender.fingerprint, + recipientFingerprint: recipient.fingerprint, + timestamp: 1_700_000_000_000, + }, + sender.edPrivateKey, + ); + + for (const swapped of [ + { ...message, inviteId: 'invite-2' }, + { ...message, epochVersion: 4 }, + { ...message, epochHash: bytes(32, 9) }, + ]) { + expect(verifyAuthenticatedMessage(swapped, sender.edPublicKey)).toBe(false); + } + }); + + it.each(signedMessages())( + 'rejects forged signatures for type $type', + (message) => { + expect( + verifyAuthenticatedMessage( + { ...message, signature: bytes(64, 0) }, + sender.edPublicKey, + ), + ).toBe(false); + }, + ); + + it.each(signedMessages())( + 'binds every encrypted/key/action field for type $type', + (message) => { + let tampered; + switch (message.type) { + case MessageType.GroupMessage: + case MessageType.DirectMessage: + tampered = { + ...message, + ciphertext: bytes(16, 9), + nonce: bytes(24, 9), + }; + break; + case MessageType.GroupManagement: + tampered = { ...message, action: 'promote' as const }; + break; + } + expect(verifyAuthenticatedMessage(tampered, sender.edPublicKey)).toBe( + false, + ); + }, + ); +}); diff --git a/packages/core/src/__tests__/reliable-delivery.test.ts b/packages/core/src/__tests__/reliable-delivery.test.ts new file mode 100644 index 0000000..0120483 --- /dev/null +++ b/packages/core/src/__tests__/reliable-delivery.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { generateIdentity } from '../identity.js'; +import { encodeMessage, decodeMessage, validateProtocolMessage } from '../protocol/messages.js'; +import { MessageType, type DirectEncryptedMessage } from '../protocol/types.js'; +import { signAuthenticatedMessage } from '../protocol/message-auth.js'; +import { + DELIVERY_TTL_MS, deliveryContentHash, signDelivery, verifyDelivery, + type ReliableDeliveryMessage, type DeliveryReceiptMessage, +} from '../protocol/reliable-delivery.js'; + +function fixture() { + const sender = generateIdentity(); + const recipient = generateIdentity(); + const timestamp = Date.now(); + const message = signAuthenticatedMessage({ + type: MessageType.DirectMessage, + senderFingerprint: sender.fingerprint, recipientFingerprint: recipient.fingerprint, + ratchetPublicKey: new Uint8Array(32).fill(1), previousChainLength: 0, messageNumber: 0, + nonce: new Uint8Array(24), ciphertext: new Uint8Array(16), timestamp, + }, sender.edPrivateKey); + const packet = signDelivery({ + type: MessageType.ReliableDelivery, id: 'stable-delivery-id', + senderFingerprint: sender.fingerprint, recipientFingerprint: recipient.fingerprint, + contentHash: deliveryContentHash('hello'), createdAt: timestamp, + expiresAt: timestamp + DELIVERY_TTL_MS, timestamp, message, + }, sender.edPrivateKey); + return { sender, recipient, packet }; +} + +describe('reliable delivery wire authentication', () => { + it('preserves signed delivery and receipt through CBOR encoding', () => { + const { sender, recipient, packet } = fixture(); + const decoded = decodeMessage(encodeMessage(packet)) as ReliableDeliveryMessage; + expect(verifyDelivery(decoded, sender.edPublicKey)).toBe(true); + const receipt = signDelivery({ + type: MessageType.DeliveryReceipt, id: packet.id, + senderFingerprint: recipient.fingerprint, recipientFingerprint: sender.fingerprint, + timestamp: Date.now(), + }, recipient.edPrivateKey); + expect(verifyDelivery(decodeMessage(encodeMessage(receipt)) as DeliveryReceiptMessage, recipient.edPublicKey)).toBe(true); + expect(verifyDelivery(receipt, sender.edPublicKey)).toBe(false); + expect(verifyDelivery({ ...receipt, id: 'another-id' }, recipient.edPublicKey)).toBe(false); + expect(verifyDelivery({ ...receipt, recipientFingerprint: recipient.fingerprint }, recipient.edPublicKey)).toBe(false); + }); + + it('binds every routing, lifetime, content and nested ciphertext field', () => { + const { sender, recipient, packet } = fixture(); + const tampered: ReliableDeliveryMessage[] = [ + { ...packet, id: 'other' }, + { ...packet, senderFingerprint: recipient.fingerprint }, + { ...packet, recipientFingerprint: sender.fingerprint }, + { ...packet, contentHash: deliveryContentHash('different') }, + { ...packet, createdAt: packet.createdAt - 1 }, + { ...packet, expiresAt: packet.expiresAt + 1 }, + { ...packet, timestamp: packet.timestamp + 1 }, + { ...packet, message: { ...packet.message, ciphertext: new Uint8Array(16).fill(1) } }, + { ...packet, message: { ...packet.message, signature: new Uint8Array(64) } }, + ]; + for (const changed of tampered) expect(verifyDelivery(changed, sender.edPublicKey)).toBe(false); + expect(deliveryContentHash('hello', new Uint8Array(32))).not.toBe(packet.contentHash); + }); + + it('rejects malformed IDs, coerced fingerprints, unknown fields and recursive envelopes', () => { + const { packet } = fixture(); + for (const malformed of [ + { ...packet, id: '' }, { ...packet, id: 'x'.repeat(129) }, + { ...packet, senderFingerprint: [packet.senderFingerprint] }, + { ...packet, recipientFingerprint: [packet.recipientFingerprint] }, + { ...packet, createdAt: -1 }, { ...packet, contentHash: 'invalid' }, + { ...packet, signature: new Uint8Array(63) }, + { ...packet, unexpected: true }, { ...packet, message: packet }, + ]) expect(() => validateProtocolMessage(malformed)).toThrow(); + }); +}); diff --git a/packages/core/src/__tests__/sender-key-envelope.test.ts b/packages/core/src/__tests__/sender-key-envelope.test.ts new file mode 100644 index 0000000..4890ee9 --- /dev/null +++ b/packages/core/src/__tests__/sender-key-envelope.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + MessageType, + senderKeyEnvelopeId, + SENDER_KEY_ENVELOPE_VERSION, +} from '../index.js'; + +describe('sender-key encrypted envelope replay identity', () => { + const envelope = { + type: MessageType.SenderKeyDistribution, + protocolVersion: SENDER_KEY_ENVELOPE_VERSION, + recipientPublicKey: new Uint8Array(32).fill(1), + ciphertext: new Uint8Array(16).fill(2), + nonce: new Uint8Array(24).fill(3), + timestamp: 4, + } as const; + + it('binds recipient, nonce, ciphertext, timestamp, domain, and version', () => { + const id = senderKeyEnvelopeId(envelope); + expect(id).toHaveLength(32); + expect(senderKeyEnvelopeId({ ...envelope })).toEqual(id); + expect( + senderKeyEnvelopeId({ + ...envelope, + recipientPublicKey: new Uint8Array(32).fill(4), + }), + ).not.toEqual(id); + expect(senderKeyEnvelopeId({ ...envelope, timestamp: 5 })).not.toEqual(id); + }); + + it('matches the replay-id golden vector', () => { + expect(Buffer.from(senderKeyEnvelopeId(envelope)).toString('hex')).toBe( + '628c1198459e45ee88f57a5cc5d31b058dada6ebe9613a5523256fce0d25fb47', + ); + }); +}); diff --git a/packages/core/src/__tests__/sender-keys.test.ts b/packages/core/src/__tests__/sender-keys.test.ts index a69cf3e..6411085 100644 --- a/packages/core/src/__tests__/sender-keys.test.ts +++ b/packages/core/src/__tests__/sender-keys.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { SenderKeys, type SenderKeyRecord } from '../protocol/sender-keys.js'; +import { generateIdentity } from '../identity.js'; describe('SenderKeys', () => { it('generates initial state with random chain key and index 0', () => { @@ -12,7 +13,10 @@ describe('SenderKeys', () => { it('encrypt/decrypt roundtrip', () => { const state = SenderKeys.generate(); const plaintext = new TextEncoder().encode('group message'); - const { ciphertext, nonce, chainIndex, nextState } = SenderKeys.encrypt(state, plaintext); + const { ciphertext, nonce, chainIndex, nextState } = SenderKeys.encrypt( + state, + plaintext, + ); expect(chainIndex).toBe(0); expect(nextState.chainIndex).toBe(1); @@ -23,7 +27,12 @@ describe('SenderKeys', () => { skippedKeys: new Map(), }; - const { plaintext: decrypted } = SenderKeys.decrypt(record, chainIndex, nonce, ciphertext); + const { plaintext: decrypted } = SenderKeys.decrypt( + record, + chainIndex, + nonce, + ciphertext, + ); expect(decrypted).toEqual(plaintext); }); @@ -35,7 +44,11 @@ describe('SenderKeys', () => { skippedKeys: new Map(), }; - const messages: Array<{ ciphertext: Uint8Array; nonce: Uint8Array; chainIndex: number }> = []; + const messages: Array<{ + ciphertext: Uint8Array; + nonce: Uint8Array; + chainIndex: number; + }> = []; for (let i = 0; i < 5; i++) { const plaintext = new TextEncoder().encode(`message ${i}`); @@ -52,7 +65,7 @@ describe('SenderKeys', () => { currentRecord, messages[i].chainIndex, messages[i].nonce, - messages[i].ciphertext + messages[i].ciphertext, ); expect(new TextDecoder().decode(plaintext)).toBe(`message ${i}`); currentRecord = nextRecord; @@ -67,7 +80,11 @@ describe('SenderKeys', () => { skippedKeys: new Map(), }; - const messages: Array<{ ciphertext: Uint8Array; nonce: Uint8Array; chainIndex: number }> = []; + const messages: Array<{ + ciphertext: Uint8Array; + nonce: Uint8Array; + chainIndex: number; + }> = []; for (let i = 0; i < 3; i++) { const plaintext = new TextEncoder().encode(`msg-${i}`); @@ -81,7 +98,7 @@ describe('SenderKeys', () => { record, messages[2].chainIndex, messages[2].nonce, - messages[2].ciphertext + messages[2].ciphertext, ); expect(new TextDecoder().decode(p2)).toBe('msg-2'); expect(r1.skippedKeys.size).toBe(2); // keys 0 and 1 cached @@ -91,7 +108,7 @@ describe('SenderKeys', () => { r1, messages[0].chainIndex, messages[0].nonce, - messages[0].ciphertext + messages[0].ciphertext, ); expect(new TextDecoder().decode(p0)).toBe('msg-0'); expect(r2.skippedKeys.size).toBe(1); // key 1 still cached @@ -101,7 +118,7 @@ describe('SenderKeys', () => { r2, messages[1].chainIndex, messages[1].nonce, - messages[1].ciphertext + messages[1].ciphertext, ); expect(new TextDecoder().decode(p1)).toBe('msg-1'); expect(r3.skippedKeys.size).toBe(0); @@ -124,25 +141,117 @@ describe('SenderKeys', () => { const { ciphertext, nonce, chainIndex } = SenderKeys.encrypt( advancedState, - new TextEncoder().encode('too far') + new TextEncoder().encode('too far'), ); expect(() => - SenderKeys.decrypt(record, chainIndex, nonce, ciphertext) + SenderKeys.decrypt(record, chainIndex, nonce, ciphertext), ).toThrow(/too many skipped/i); }); - it('createDistribution produces valid message', () => { const state = SenderKeys.generate(); - const groupId = new Uint8Array(16); + const groupId = new Uint8Array(32); const signingKey = new Uint8Array(32); - const dist = SenderKeys.createDistribution(groupId, state, signingKey); + const epochHash = new Uint8Array(32).fill(7); + const dist = SenderKeys.createDistribution( + groupId, + state, + signingKey, + 3, + epochHash, + ); - expect(dist.type).toBe(0x03); expect(dist.groupId).toBe(groupId); expect(dist.chainKey).toBe(state.chainKey); expect(dist.chainIndex).toBe(state.chainIndex); expect(dist.signingPublicKey).toBe(signingKey); + expect(dist.epochVersion).toBe(3); + expect(dist.epochHash).toBe(epochHash); + expect(dist.protocolVersion).toBe(2); + expect(dist.generationId).toHaveLength(16); + expect(dist.sequence).toBe(0); expect(typeof dist.timestamp).toBe('number'); }); + + it('encrypts a distribution for one authenticated recipient', () => { + const alice = generateIdentity('Alice'); + const bob = generateIdentity('Bob'); + const groupId = new Uint8Array(32).fill(11); + const epochHash = new Uint8Array(32).fill(12); + const state = SenderKeys.generate(); + const payload = SenderKeys.createDistribution( + groupId, + state, + alice.edPublicKey, + 4, + epochHash, + ); + + const envelope = SenderKeys.encryptDistribution( + payload, + alice.xPrivateKey, + alice.edPublicKey, + bob.xPublicKey, + bob.edPublicKey, + ); + + expect(envelope.type).toBe(0x03); + expect(envelope.protocolVersion).toBe(2); + expect(envelope.recipientPublicKey).toEqual(bob.edPublicKey); + expect(envelope).not.toHaveProperty('groupId'); + expect(envelope).not.toHaveProperty('chainKey'); + expect(envelope.ciphertext).not.toEqual(state.chainKey); + + const decrypted = SenderKeys.decryptDistribution( + envelope, + bob.xPrivateKey, + bob.edPublicKey, + alice.xPublicKey, + alice.edPublicKey, + ); + expect(decrypted.groupId).toEqual(groupId); + expect(decrypted.chainKey).toEqual(state.chainKey); + expect(decrypted.epochVersion).toBe(4); + expect(decrypted.epochHash).toEqual(epochHash); + expect(decrypted.generationId).toEqual(payload.generationId); + expect(decrypted.sequence).toBe(payload.sequence); + + expect(() => SenderKeys.decryptDistribution( + { ...envelope, protocolVersion: 1 }, + bob.xPrivateKey, + bob.edPublicKey, + alice.xPublicKey, + alice.edPublicKey, + )).toThrow(/unsupported/i); + }); + + it('rejects a distribution relayed over another authenticated session', () => { + const alice = generateIdentity('Alice'); + const bob = generateIdentity('Bob'); + const mallory = generateIdentity('Mallory'); + const payload = SenderKeys.createDistribution( + new Uint8Array(32).fill(21), + SenderKeys.generate(), + alice.edPublicKey, + 1, + new Uint8Array(32).fill(22), + ); + const envelope = SenderKeys.encryptDistribution( + payload, + alice.xPrivateKey, + alice.edPublicKey, + bob.xPublicKey, + bob.edPublicKey, + ); + + expect(() => + SenderKeys.decryptDistribution( + envelope, + bob.xPrivateKey, + bob.edPublicKey, + mallory.xPublicKey, + mallory.edPublicKey, + ), + ).toThrow(); + }); }); diff --git a/packages/core/src/__tests__/ttya-auth.test.ts b/packages/core/src/__tests__/ttya-auth.test.ts new file mode 100644 index 0000000..a866e9a --- /dev/null +++ b/packages/core/src/__tests__/ttya-auth.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + TTYA_AUTH_VERSION, + TTYAFrameDecoder, + buildTTYAAuthProofPayload, + copyAndValidateTTYAAuthSecret, + isTTYAAuthChallengeFrame, + isTTYAAuthConfirmationFrame, + isTTYAAuthResponseFrame, +} from '../protocol/ttya-auth.js'; + +const agentNonce = '11'.repeat(32); +const bridgeNonce = '22'.repeat(32); +const proof = '33'.repeat(32); +const channelBinding = new Uint8Array(64).fill(0x44); + +describe('TTYA mutual-auth wire contract', () => { + it('accepts only fixed-size lowercase hexadecimal fields', () => { + expect( + isTTYAAuthChallengeFrame({ + type: 'ttya-auth-challenge', + version: TTYA_AUTH_VERSION, + agentNonce, + }), + ).toBe(true); + expect( + isTTYAAuthResponseFrame({ + type: 'ttya-auth-response', + version: TTYA_AUTH_VERSION, + agentNonce, + bridgeNonce, + proof, + }), + ).toBe(true); + expect( + isTTYAAuthConfirmationFrame({ + type: 'ttya-auth-confirmation', + version: TTYA_AUTH_VERSION, + agentNonce, + bridgeNonce, + proof, + }), + ).toBe(true); + + expect( + isTTYAAuthChallengeFrame({ + type: 'ttya-auth-challenge', + version: TTYA_AUTH_VERSION, + agentNonce: 'AA'.repeat(32), + }), + ).toBe(false); + expect( + isTTYAAuthResponseFrame({ + type: 'ttya-auth-response', + version: TTYA_AUTH_VERSION, + agentNonce, + bridgeNonce, + proof: '33'.repeat(31), + }), + ).toBe(false); + }); + + it('domain-separates bridge and agent proofs over the full transcript', () => { + const bridgePayload = buildTTYAAuthProofPayload( + 'bridge', + agentNonce, + bridgeNonce, + channelBinding, + ); + const agentPayload = buildTTYAAuthProofPayload( + 'agent', + agentNonce, + bridgeNonce, + channelBinding, + ); + const changedTranscript = buildTTYAAuthProofPayload( + 'bridge', + '44'.repeat(32), + bridgeNonce, + channelBinding, + ); + const changedConnection = buildTTYAAuthProofPayload( + 'bridge', + agentNonce, + bridgeNonce, + new Uint8Array(64).fill(0x55), + ); + + expect(bridgePayload).not.toEqual(agentPayload); + expect(bridgePayload).not.toEqual(changedTranscript); + expect(bridgePayload).not.toEqual(changedConnection); + }); + + it('requires and defensively copies a 32-byte-or-longer PSK', () => { + expect(() => copyAndValidateTTYAAuthSecret(new Uint8Array(31))).toThrow( + /at least 32 random bytes/i, + ); + const input = new Uint8Array(32).fill(7); + const copy = copyAndValidateTTYAAuthSecret(input); + input.fill(9); + expect(copy).toEqual(new Uint8Array(32).fill(7)); + }); + + it('parses fragmented and coalesced frames without buffering an oversize body', () => { + const encode = (value: string) => { + const payload = new TextEncoder().encode(value); + const frame = new Uint8Array(4 + payload.length); + new DataView(frame.buffer).setUint32(0, payload.length, false); + frame.set(payload, 4); + return frame; + }; + const first = encode('one'); + const second = encode('two'); + const decoder = new TTYAFrameDecoder(); + + expect(decoder.push(first.subarray(0, 2))).toEqual([]); + expect( + decoder.push(new Uint8Array([...first.subarray(2), ...second])), + ).toEqual([ + new TextEncoder().encode('one'), + new TextEncoder().encode('two'), + ]); + + const oversized = new Uint8Array(4); + new DataView(oversized.buffer).setUint32(0, 65_537, false); + expect(() => decoder.push(oversized)).toThrow(/invalid ttya frame length/i); + }); +}); diff --git a/packages/core/src/crypto/index.ts b/packages/core/src/crypto/index.ts index 9462ee6..c15c645 100644 --- a/packages/core/src/crypto/index.ts +++ b/packages/core/src/crypto/index.ts @@ -1,3 +1,3 @@ export { encrypt, decrypt } from './aead.js'; -export { deriveKey, advanceChain } from './kdf.js'; +export { deriveKey, advanceChain, computeSharedSecret } from './kdf.js'; export { sign, verify } from './signatures.js'; diff --git a/packages/core/src/crypto/kdf.ts b/packages/core/src/crypto/kdf.ts index 2dfdf2a..8098b9a 100644 --- a/packages/core/src/crypto/kdf.ts +++ b/packages/core/src/crypto/kdf.ts @@ -1,15 +1,28 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha256'; +import { x25519 } from '@noble/curves/ed25519'; export function deriveKey( ikm: Uint8Array, salt: string | Uint8Array, - info: string, + info: string | Uint8Array, length: number ): Uint8Array { return hkdf(sha256, ikm, salt, info, length); } +/** + * Compute X25519 ECDH shared secret between two peers and derive + * a root key suitable for Double Ratchet initialization. + */ +export function computeSharedSecret( + myXPrivateKey: Uint8Array, + peerXPublicKey: Uint8Array +): Uint8Array { + const rawSharedSecret = x25519.getSharedSecret(myXPrivateKey, peerXPublicKey); + return deriveKey(rawSharedSecret, 'networkselfmd-dm-v1', '', 32); +} + export function advanceChain(chainKey: Uint8Array): { messageKey: Uint8Array; nextChainKey: Uint8Array; diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index ab8622c..cd1b7be 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -1,5 +1,5 @@ -import { ed25519 } from '@noble/curves/ed25519'; -import { edwardsToMontgomery, edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; +import { ed25519, x25519 } from '@noble/curves/ed25519'; +import { edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { randomBytes } from '@noble/hashes/utils'; import type { AgentIdentity } from './protocol/types.js'; @@ -33,11 +33,23 @@ export function fingerprintFromPublicKey(edPublicKey: Uint8Array): string { return zBase32Encode(truncated); } +export function deriveEd25519PublicKey(edPrivateKey: Uint8Array): Uint8Array { + return ed25519.getPublicKey(edPrivateKey); +} + +export function deriveX25519KeyPair(edPrivateKey: Uint8Array): { + xPrivateKey: Uint8Array; + xPublicKey: Uint8Array; +} { + const xPrivateKey = edwardsToMontgomeryPriv(edPrivateKey); + const xPublicKey = x25519.getPublicKey(xPrivateKey); + return { xPrivateKey, xPublicKey }; +} + export function generateIdentity(displayName?: string): AgentIdentity { const edPrivateKey = randomBytes(32); const edPublicKey = ed25519.getPublicKey(edPrivateKey); - const xPublicKey = edwardsToMontgomery(edPublicKey); - const xPrivateKey = edwardsToMontgomeryPriv(edPrivateKey); + const { xPrivateKey, xPublicKey } = deriveX25519KeyPair(edPrivateKey); const fingerprint = fingerprintFromPublicKey(edPublicKey); return { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36fe334..ce5854b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,9 @@ -export { generateIdentity, fingerprintFromPublicKey, zBase32Encode } from './identity.js'; +export { + generateIdentity, + deriveEd25519PublicKey, + deriveX25519KeyPair, + fingerprintFromPublicKey, + zBase32Encode, +} from './identity.js'; export * from './crypto/index.js'; export * from './protocol/index.js'; diff --git a/packages/core/src/protocol/announce-signature.ts b/packages/core/src/protocol/announce-signature.ts new file mode 100644 index 0000000..b8a106a --- /dev/null +++ b/packages/core/src/protocol/announce-signature.ts @@ -0,0 +1,258 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { sign, verify } from '../crypto/signatures.js'; +import { deserializeEpoch, verifyGenesisEpoch } from './group-state.js'; +import type { SignedGroupEpoch } from './group-state.js'; +import type { NetworkAnnounceMessage } from './types.js'; + +export const NETWORK_ANNOUNCE_VERSION = 2; +export const MAX_ANNOUNCE_GROUPS = 64; +export const MAX_ANNOUNCE_AGE_MS = 5 * 60 * 1000; +const DOMAIN = new TextEncoder().encode('network.self.md/NetworkAnnounce/v2\0'); +const textEncoder = new TextEncoder(); +type AnnounceGroup = NetworkAnnounceMessage['groups'][number]; + +export function canonicalAnnouncePayload( + protocolVersion: number, + groups: AnnounceGroup[], + timestamp: number, +): Uint8Array { + assertAnnouncePayload(protocolVersion, groups, timestamp); + const parts: Uint8Array[] = [ + DOMAIN, + u16(protocolVersion), + u64(timestamp), + u16(groups.length), + ]; + for (const group of groups) { + parts.push( + group.groupId, + lp(textEncoder.encode(group.name)), + lp(textEncoder.encode(group.selfMd)), + u32(group.memberCount), + lp(group.genesisEpochData), + group.genesisSignature, + group.genesisHash, + ); + } + return concatBytes(...parts); +} + +export function signAnnounce( + groups: AnnounceGroup[], + timestamp: number, + privateKey: Uint8Array, + protocolVersion: number = NETWORK_ANNOUNCE_VERSION, +): Uint8Array { + return sign( + canonicalAnnouncePayload(protocolVersion, groups, timestamp), + privateKey, + ); +} + +export function verifyAnnounce( + groups: AnnounceGroup[], + timestamp: number, + signature: Uint8Array, + publicKey: Uint8Array, + protocolVersion: number = NETWORK_ANNOUNCE_VERSION, +): boolean { + try { + return ( + signature instanceof Uint8Array && + signature.length === 64 && + verify( + signature, + canonicalAnnouncePayload(protocolVersion, groups, timestamp), + publicKey, + ) + ); + } catch { + return false; + } +} + +export function networkAnnounceId(message: NetworkAnnounceMessage): Uint8Array { + const payload = canonicalAnnouncePayload( + message.protocolVersion, + message.groups, + message.timestamp, + ); + return sha256(concatBytes(payload, message.signature)); +} + +/** Exhaustive announce schema validation, optionally including freshness. */ +export function assertAnnounceShape( + message: Pick< + NetworkAnnounceMessage, + 'protocolVersion' | 'groups' | 'timestamp' | 'signature' + >, + now?: number, +): void { + if ('type' in message) { + assertExactKeys( + message as unknown as Record, + ['type', 'protocolVersion', 'groups', 'signature', 'timestamp'], + 'NetworkAnnounce', + ); + } + assertAnnouncePayload( + message.protocolVersion, + message.groups, + message.timestamp, + ); + assertBytes(message.signature, 64, 'NetworkAnnounce signature'); + if ( + now !== undefined && + Math.abs(now - message.timestamp) > MAX_ANNOUNCE_AGE_MS + ) { + throw new Error('Stale NetworkAnnounce'); + } + for (const group of message.groups) { + assertExactKeys( + group as unknown as Record, + [ + 'groupId', + 'name', + 'selfMd', + 'memberCount', + 'genesisEpochData', + 'genesisSignature', + 'genesisHash', + ], + 'announced group', + ); + } +} + +export function verifyAnnouncedGroupAuthority( + group: AnnounceGroup, + announcerPublicKey: Uint8Array, +): boolean { + try { + const signed: SignedGroupEpoch = { + epoch: deserializeEpoch(group.genesisEpochData), + signature: group.genesisSignature, + hash: group.genesisHash, + }; + return verifyGenesisEpoch(signed, toHex(group.groupId), announcerPublicKey); + } catch { + return false; + } +} + +function assertAnnouncePayload( + protocolVersion: number, + groups: AnnounceGroup[], + timestamp: number, +): void { + if (protocolVersion !== NETWORK_ANNOUNCE_VERSION) { + throw new Error('Unsupported NetworkAnnounce version'); + } + if (!Number.isSafeInteger(timestamp) || timestamp < 0) { + throw new Error('Invalid NetworkAnnounce timestamp'); + } + if (!Array.isArray(groups) || groups.length > MAX_ANNOUNCE_GROUPS) { + throw new Error('Too many announced groups'); + } + let previous = ''; + for (const group of groups) { + if (!group || typeof group !== 'object') { + throw new Error('Invalid announced group'); + } + assertBytes(group.groupId, 32, 'group id'); + const id = toHex(group.groupId); + if (previous && id <= previous) { + throw new Error('NetworkAnnounce groups are not canonical'); + } + previous = id; + assertString(group.name, 1, 128, 'group name'); + assertString(group.selfMd, 0, 16 * 1024, 'self.md'); + if ( + !Number.isSafeInteger(group.memberCount) || + group.memberCount < 1 || + group.memberCount > 100_000 + ) { + throw new Error('Invalid member count'); + } + if ( + !(group.genesisEpochData instanceof Uint8Array) || + group.genesisEpochData.length === 0 || + group.genesisEpochData.length > 4096 + ) { + throw new Error('Invalid genesis epoch data'); + } + assertBytes(group.genesisSignature, 64, 'genesis signature'); + assertBytes(group.genesisHash, 32, 'genesis hash'); + } +} + +function assertBytes(value: unknown, length: number, label: string): void { + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new Error(`Invalid ${label}`); + } +} + +function assertString( + value: unknown, + min: number, + maxBytes: number, + label: string, +): void { + if (typeof value !== 'string') throw new Error(`Invalid ${label}`); + const length = textEncoder.encode(value).length; + if (length < min || length > maxBytes) throw new Error(`Invalid ${label}`); +} + +function u16(value: number): Uint8Array { + const out = new Uint8Array(2); + new DataView(out.buffer).setUint16(0, value, false); + return out; +} + +function u32(value: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value, false); + return out; +} + +function u64(value: number): Uint8Array { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigUint64(0, BigInt(value), false); + return out; +} + +function lp(value: Uint8Array): Uint8Array { + return concatBytes(u32(value.length), value); +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array( + parts.reduce((length, part) => length + part.length, 0), + ); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +function assertExactKeys( + value: Record, + allowed: string[], + label: string, +): void { + const keys = Object.keys(value); + if ( + keys.length !== allowed.length || + keys.some((key) => !allowed.includes(key)) + ) { + throw new Error(`Invalid ${label} schema`); + } +} diff --git a/packages/core/src/protocol/double-ratchet.ts b/packages/core/src/protocol/double-ratchet.ts index 5b76d54..af5f14f 100644 --- a/packages/core/src/protocol/double-ratchet.ts +++ b/packages/core/src/protocol/double-ratchet.ts @@ -230,6 +230,10 @@ function skipMessages( if (skip > MAX_SKIP) { throw new Error(`Too many skipped messages: ${skip} > ${MAX_SKIP}`); } + const retainedKeys = state.skippedKeys.size + Math.max(0, skip); + if (retainedKeys > MAX_SKIP) { + throw new Error(`Too many skipped message keys: ${retainedKeys} > ${MAX_SKIP}`); + } let chainKey = state.receiveChainKey; const newSkipped = new Map(state.skippedKeys); diff --git a/packages/core/src/protocol/epoch-envelope.ts b/packages/core/src/protocol/epoch-envelope.ts new file mode 100644 index 0000000..fe0d371 --- /dev/null +++ b/packages/core/src/protocol/epoch-envelope.ts @@ -0,0 +1,127 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { sign, verify } from '../crypto/signatures.js'; +import { MessageType } from './types.js'; +import type { GroupEpochMessage } from './types.js'; + +export const GROUP_EPOCH_ENVELOPE_VERSION = 2; +const DOMAIN = new TextEncoder().encode( + 'network.self.md/GroupEpochEnvelope/v2\0', +); + +type UnsignedGroupEpochEnvelope = Omit; + +export function canonicalGroupEpochEnvelope( + message: UnsignedGroupEpochEnvelope, +): Uint8Array { + if ( + message.type !== MessageType.GroupEpoch || + message.protocolVersion !== GROUP_EPOCH_ENVELOPE_VERSION + ) { + throw new Error('Unsupported GroupEpoch envelope version'); + } + assertBytes(message.groupId, 32, 'groupId'); + if ( + !(message.epochData instanceof Uint8Array) || + message.epochData.length === 0 || + message.epochData.length > 256 * 1024 + ) { + throw new Error('Invalid GroupEpoch epochData'); + } + assertBytes(message.signature, 64, 'signature'); + assertBytes(message.hash, 32, 'hash'); + assertFingerprint(message.senderFingerprint, 'senderFingerprint'); + assertFingerprint(message.recipientFingerprint, 'recipientFingerprint'); + if (!Number.isSafeInteger(message.timestamp) || message.timestamp < 0) { + throw new Error('Invalid GroupEpoch timestamp'); + } + return concatBytes( + DOMAIN, + new Uint8Array([message.type]), + u16(message.protocolVersion), + message.groupId, + lp(message.epochData), + message.signature, + message.hash, + lp(new TextEncoder().encode(message.senderFingerprint)), + lp(new TextEncoder().encode(message.recipientFingerprint)), + u64(message.timestamp), + ); +} + +function assertBytes(value: unknown, length: number, label: string): void { + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new Error(`Invalid GroupEpoch ${label}`); + } +} + +function assertFingerprint(value: unknown, label: string): void { + if ( + typeof value !== 'string' || + !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{32}$/.test(value) + ) { + throw new Error(`Invalid GroupEpoch ${label}`); + } +} + +export function signGroupEpochEnvelope( + message: UnsignedGroupEpochEnvelope, + privateKey: Uint8Array, +): GroupEpochMessage { + return { + ...message, + envelopeSignature: sign(canonicalGroupEpochEnvelope(message), privateKey), + }; +} + +export function verifyGroupEpochEnvelope( + message: GroupEpochMessage, + publicKey: Uint8Array, +): boolean { + try { + const { envelopeSignature, ...unsigned } = message; + return verify( + envelopeSignature, + canonicalGroupEpochEnvelope(unsigned), + publicKey, + ); + } catch { + return false; + } +} + +export function groupEpochEnvelopeId(message: GroupEpochMessage): Uint8Array { + const { envelopeSignature, ...unsigned } = message; + return sha256( + concatBytes(canonicalGroupEpochEnvelope(unsigned), envelopeSignature), + ); +} + +function u16(value: number): Uint8Array { + const out = new Uint8Array(2); + new DataView(out.buffer).setUint16(0, value, false); + return out; +} + +function u64(value: number): Uint8Array { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigUint64(0, BigInt(value), false); + return out; +} + +function lp(value: Uint8Array): Uint8Array { + const length = new Uint8Array(4); + new DataView(length.buffer).setUint32(0, value.length, false); + return concatBytes(length, value); +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array( + parts.reduce((length, part) => length + part.length, 0), + ); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} diff --git a/packages/core/src/protocol/group-state.ts b/packages/core/src/protocol/group-state.ts new file mode 100644 index 0000000..a7d69f6 --- /dev/null +++ b/packages/core/src/protocol/group-state.ts @@ -0,0 +1,269 @@ +import { Decoder, Encoder } from 'cbor-x'; +import { sha256 } from '@noble/hashes/sha256'; +import { sign, verify } from '../crypto/signatures.js'; + +const encoder = new Encoder({ useRecords: false }); +const decoder = new Decoder({ mapsAsObjects: true, useRecords: false }); +const textEncoder = new TextEncoder(); + +export interface GroupMemberEntry { + publicKey: Uint8Array; + role: 'admin' | 'member'; +} + +export interface GroupEpoch { + version: number; + prevHash: Uint8Array; + groupId: string; + members: GroupMemberEntry[]; + /** Immutable creation time covered by the epoch signature and hash. */ + createdAt: number; + createdBy: Uint8Array; +} + +export interface SignedGroupEpoch { + epoch: GroupEpoch; + signature: Uint8Array; + hash: Uint8Array; +} + +export const GROUP_EPOCH_FORMAT_VERSION = 1; +export const MAX_EPOCH_BYTES = 256 * 1024; +export const MAX_GROUP_MEMBERS = 1024; +const EPOCH_DOMAIN = 'network.self.md/GroupEpoch'; +const ZERO_HASH = new Uint8Array(32); + +/** Canonical, domain-separated representation used for signing and hashing. */ +export function serializeEpoch(epoch: GroupEpoch): Uint8Array { + assertEpoch(epoch); + return encoder.encode([ + EPOCH_DOMAIN, + GROUP_EPOCH_FORMAT_VERSION, + epoch.version, + canonicalBytes(epoch.prevHash), + epoch.groupId, + epoch.members.map((member) => [ + canonicalBytes(member.publicKey), + member.role, + ]), + epoch.createdAt, + canonicalBytes(epoch.createdBy), + ]); +} + +export function deserializeEpoch(data: Uint8Array): GroupEpoch { + if ( + !(data instanceof Uint8Array) || + data.length === 0 || + data.length > MAX_EPOCH_BYTES + ) { + throw new Error('Invalid group epoch: encoded size is out of range'); + } + + let decoded: unknown; + try { + decoded = decoder.decode(data); + } catch (error) { + throw new Error('Invalid group epoch: malformed CBOR', { cause: error }); + } + if (!Array.isArray(decoded) || decoded.length !== 8) { + throw new Error('Invalid group epoch: fields do not match schema'); + } + + const [ + domain, + formatVersion, + version, + prevHash, + groupId, + rawMembers, + createdAt, + createdBy, + ] = decoded; + if (domain !== EPOCH_DOMAIN || formatVersion !== GROUP_EPOCH_FORMAT_VERSION) { + throw new Error( + 'Invalid group epoch: unsupported domain or format version', + ); + } + + const epoch = { + version, + prevHash, + groupId, + members: decodeMembers(rawMembers), + createdAt, + createdBy, + } as GroupEpoch; + assertEpoch(epoch); + + return { + ...epoch, + prevHash: new Uint8Array(epoch.prevHash), + createdBy: new Uint8Array(epoch.createdBy), + members: epoch.members.map((member) => ({ + publicKey: new Uint8Array(member.publicKey), + role: member.role, + })), + }; +} + +export function hashEpoch(serialized: Uint8Array): Uint8Array { + return sha256(serialized); +} + +export function createSignedEpoch( + epoch: GroupEpoch, + privateKey: Uint8Array, +): SignedGroupEpoch { + const serialized = serializeEpoch(epoch); + return { + epoch, + signature: sign(serialized, privateKey), + hash: hashEpoch(serialized), + }; +} + +export function verifyEpoch( + signed: SignedGroupEpoch, + expectedPrevHash: Uint8Array, +): boolean { + try { + const serialized = serializeEpoch(signed.epoch); + return ( + signed.signature instanceof Uint8Array && + signed.signature.length === 64 && + signed.hash instanceof Uint8Array && + signed.hash.length === 32 && + expectedPrevHash instanceof Uint8Array && + expectedPrevHash.length === 32 && + bytesEqual(hashEpoch(serialized), signed.hash) && + verify(signed.signature, serialized, signed.epoch.createdBy) && + bytesEqual(signed.epoch.prevHash, expectedPrevHash) && + signed.epoch.members.some( + (member) => + member.role === 'admin' && + bytesEqual(member.publicKey, signed.epoch.createdBy), + ) + ); + } catch { + return false; + } +} + +/** Verify the exact v0 trust anchor, not merely a zero-prevHash epoch. */ +export function verifyGenesisEpoch( + signed: SignedGroupEpoch, + expectedGroupId: string, + expectedCreator?: Uint8Array, +): boolean { + try { + const { epoch } = signed; + return ( + epoch.version === 0 && + epoch.groupId === expectedGroupId && + bytesEqual(epoch.prevHash, ZERO_HASH) && + epoch.members.length === 1 && + epoch.members[0].role === 'admin' && + bytesEqual(epoch.members[0].publicKey, epoch.createdBy) && + (!expectedCreator || bytesEqual(epoch.createdBy, expectedCreator)) && + verifyEpoch(signed, ZERO_HASH) + ); + } catch { + return false; + } +} + +export function createGenesisEpoch( + groupId: string, + adminPublicKey: Uint8Array, + createdAt: number = Date.now(), +): GroupEpoch { + return { + version: 0, + prevHash: new Uint8Array(ZERO_HASH), + groupId, + members: [{ publicKey: adminPublicKey, role: 'admin' }], + createdAt, + createdBy: adminPublicKey, + }; +} + +function decodeMembers(value: unknown): GroupMemberEntry[] { + if (!Array.isArray(value)) { + throw new Error('Invalid group epoch: members must be an array'); + } + return value.map((member) => { + if (!Array.isArray(member) || member.length !== 2) { + throw new Error('Invalid group epoch: member fields do not match schema'); + } + return { publicKey: member[0], role: member[1] } as GroupMemberEntry; + }); +} + +function assertEpoch(epoch: GroupEpoch): void { + if ( + !Number.isSafeInteger(epoch.version) || + epoch.version < 0 || + epoch.version > 0xffff_ffff + ) { + throw new Error('Invalid group epoch: version is out of range'); + } + assertBytes(epoch.prevHash, 32, 'prevHash'); + if ( + typeof epoch.groupId !== 'string' || + byteLength(epoch.groupId) < 1 || + byteLength(epoch.groupId) > 128 + ) { + throw new Error('Invalid group epoch: groupId length is out of range'); + } + if ( + !Array.isArray(epoch.members) || + epoch.members.length === 0 || + epoch.members.length > MAX_GROUP_MEMBERS + ) { + throw new Error('Invalid group epoch: members count is out of range'); + } + const seen = new Set(); + for (const member of epoch.members) { + assertBytes(member.publicKey, 32, 'member publicKey'); + if (member.role !== 'admin' && member.role !== 'member') { + throw new Error('Invalid group epoch: member role is invalid'); + } + const key = toHex(member.publicKey); + if (seen.has(key)) throw new Error('Invalid group epoch: duplicate member'); + seen.add(key); + } + if (!Number.isSafeInteger(epoch.createdAt) || epoch.createdAt < 0) { + throw new Error('Invalid group epoch: createdAt is out of range'); + } + assertBytes(epoch.createdBy, 32, 'createdBy'); +} + +function assertBytes(value: unknown, length: number, label: string): void { + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new Error(`Invalid group epoch: ${label} must be ${length} bytes`); + } +} + +function byteLength(value: string): number { + return textEncoder.encode(value).length; +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) { + difference |= a[index] ^ b[index]; + } + return difference === 0; +} + +function canonicalBytes(value: Uint8Array): Uint8Array { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} diff --git a/packages/core/src/protocol/index.ts b/packages/core/src/protocol/index.ts index 19dc164..165fcec 100644 --- a/packages/core/src/protocol/index.ts +++ b/packages/core/src/protocol/index.ts @@ -7,6 +7,7 @@ export { type GroupEncryptedMessage, type DirectEncryptedMessage, type GroupManagementMessage, + type GroupEpochMessage, type TTYARequestMessage, type TTYAResponseMessage, type NetworkAnnounceMessage, @@ -26,16 +27,98 @@ export { decodeMessage, frameMessage, parseFrame, + validateProtocolMessage, MAX_FRAME_SIZE, } from './messages.js'; +export { + authenticatedMessagePayload, + authenticatedMessageId, + signAuthenticatedMessage, + verifyAuthenticatedMessage, + type AuthenticatedProtocolMessage, +} from './message-auth.js'; + +export { + NETWORK_ANNOUNCE_VERSION, + MAX_ANNOUNCE_GROUPS, + MAX_ANNOUNCE_AGE_MS, + canonicalAnnouncePayload, + assertAnnounceShape, + signAnnounce, + verifyAnnounce, + networkAnnounceId, + verifyAnnouncedGroupAuthority, +} from './announce-signature.js'; + +export { + TTYA_AUTH_PROTOCOL, + TTYA_AUTH_VERSION, + TTYA_AUTH_NONCE_BYTES, + TTYA_AUTH_PROOF_BYTES, + TTYA_AUTH_SECRET_MIN_BYTES, + TTYA_CHANNEL_BINDING_MIN_BYTES, + TTYA_CHANNEL_BINDING_MAX_BYTES, + MAX_TTYA_FRAME_SIZE, + MAX_TTYA_CONTENT_BYTES, + MAX_TTYA_USER_AGENT_BYTES, + isTTYAAuthChallengeFrame, + isTTYAAuthResponseFrame, + isTTYAAuthConfirmationFrame, + isTTYADataFrame, + copyAndValidateTTYAAuthSecret, + copyAndValidateTTYAChannelBinding, + buildTTYAAuthProofPayload, + buildTTYASessionKeyPayload, + buildTTYADataProofPayload, + TTYAFrameDecoder, + type TTYAAuthRole, + type TTYADataDirection, + type TTYAAuthChallengeFrame, + type TTYAAuthResponseFrame, + type TTYAAuthConfirmationFrame, + type TTYADataFrame, + type TTYAAuthFrame, +} from './ttya-auth.js'; + +export { + GROUP_EPOCH_ENVELOPE_VERSION, + canonicalGroupEpochEnvelope, + signGroupEpochEnvelope, + verifyGroupEpochEnvelope, + groupEpochEnvelopeId, +} from './epoch-envelope.js'; + +export { + SENDER_KEY_ENVELOPE_VERSION, + senderKeyEnvelopeId, +} from './sender-key-envelope.js'; + export { SenderKeys, type SenderKeyState, type SenderKeyRecord, + type SenderKeyDistributionPayload, + SENDER_KEY_PROTOCOL_VERSION, + SENDER_KEY_CAPABILITY, + assertSenderKeyDistributionMessage, } from './sender-keys.js'; +export { DoubleRatchet, type DoubleRatchetState } from './double-ratchet.js'; + export { - DoubleRatchet, - type DoubleRatchetState, -} from './double-ratchet.js'; + type GroupMemberEntry, + type GroupEpoch, + type SignedGroupEpoch, + serializeEpoch, + deserializeEpoch, + hashEpoch, + createSignedEpoch, + verifyEpoch, + verifyGenesisEpoch, + createGenesisEpoch, + GROUP_EPOCH_FORMAT_VERSION, + MAX_EPOCH_BYTES, + MAX_GROUP_MEMBERS, +} from './group-state.js'; +export * from './reliable-delivery.js'; diff --git a/packages/core/src/protocol/message-auth.ts b/packages/core/src/protocol/message-auth.ts new file mode 100644 index 0000000..0302965 --- /dev/null +++ b/packages/core/src/protocol/message-auth.ts @@ -0,0 +1,119 @@ +import { Encoder } from 'cbor-x'; +import { sha256 } from '@noble/hashes/sha256'; +import { sign, verify } from '../crypto/signatures.js'; +import { MessageType } from './types.js'; +import type { + DirectEncryptedMessage, + GroupEncryptedMessage, + GroupManagementMessage, +} from './types.js'; + +const encoder = new Encoder({ useRecords: false, mapsAsObjects: true }); +const AUTH_DOMAIN = 'networkselfmd-protocol-auth-v2'; + +export type AuthenticatedProtocolMessage = + | GroupEncryptedMessage + | DirectEncryptedMessage + | GroupManagementMessage; + +type UnsignedAuthenticatedMessage = AuthenticatedProtocolMessage extends infer T + ? T extends AuthenticatedProtocolMessage + ? Omit + : never + : never; + +/** Canonical, domain-separated representation of every security-relevant field. */ +export function authenticatedMessagePayload( + message: UnsignedAuthenticatedMessage, +): Uint8Array { + let fields: unknown[]; + switch (message.type) { + case MessageType.GroupMessage: + fields = [ + canonicalBytes(message.groupId), + message.senderFingerprint, + message.chainIndex, + canonicalBytes(message.generationId), + message.epochVersion, + canonicalBytes(message.epochHash), + canonicalBytes(message.nonce), + canonicalBytes(message.ciphertext), + message.timestamp, + ]; + break; + case MessageType.DirectMessage: + fields = [ + message.senderFingerprint, + message.recipientFingerprint, + canonicalBytes(message.ratchetPublicKey), + message.previousChainLength, + message.messageNumber, + canonicalBytes(message.nonce), + canonicalBytes(message.ciphertext), + message.timestamp, + ]; + break; + case MessageType.GroupManagement: + fields = [ + canonicalBytes(message.groupId), + message.action, + message.targetFingerprint ?? null, + message.groupName ?? null, + message.inviteId ?? null, + message.epochVersion ?? null, + optionalBytes(message.epochHash), + optionalBytes(message.genesisEpochData), + optionalBytes(message.genesisSignature), + optionalBytes(message.genesisHash), + message.senderFingerprint, + message.recipientFingerprint, + message.timestamp, + ]; + if (message.action === 'metadata') { + fields.push(message.selfMd, message.isPublic, message.metadataVersion); + } + break; + default: + throw new Error('Message type is not authenticated by protocol v2'); + } + return encoder.encode([AUTH_DOMAIN, message.type, ...fields]); +} + +function canonicalBytes(value: Uint8Array): Uint8Array { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +function optionalBytes(value: Uint8Array | undefined): Uint8Array | null { + return value === undefined ? null : canonicalBytes(value); +} + +export function signAuthenticatedMessage< + T extends AuthenticatedProtocolMessage, +>( + message: T extends AuthenticatedProtocolMessage + ? Omit + : never, + privateKey: Uint8Array, +): T { + const signature = sign(authenticatedMessagePayload(message), privateKey); + return { ...message, signature } as unknown as T; +} + +export function verifyAuthenticatedMessage( + message: AuthenticatedProtocolMessage, + publicKey: Uint8Array, +): boolean { + const { signature, ...unsigned } = message; + return verify(signature, authenticatedMessagePayload(unsigned), publicKey); +} + +/** Stable identifier used by durable replay ledgers. */ +export function authenticatedMessageId( + message: AuthenticatedProtocolMessage, +): Uint8Array { + const payload = authenticatedMessagePayload(message); + const combined = new Uint8Array(payload.length + message.signature.length); + combined.set(payload); + combined.set(message.signature, payload.length); + return sha256(combined); +} diff --git a/packages/core/src/protocol/messages.ts b/packages/core/src/protocol/messages.ts index 6a1801a..b933f50 100644 --- a/packages/core/src/protocol/messages.ts +++ b/packages/core/src/protocol/messages.ts @@ -1,64 +1,612 @@ import { Encoder, Decoder } from 'cbor-x'; -import type { ProtocolMessage, MessageTypeValue } from './types.js'; +import type { ProtocolMessage } from './types.js'; import { MessageType } from './types.js'; +import { + deserializeEpoch, + hashEpoch, + verifyGenesisEpoch, +} from './group-state.js'; +import { SENDER_KEY_PROTOCOL_VERSION } from './sender-keys.js'; const encoder = new Encoder({ useRecords: false }); const decoder = new Decoder({ mapsAsObjects: true, useRecords: false }); -export const MAX_FRAME_SIZE = 1_048_576; // 1 MiB +export const MAX_FRAME_SIZE = 1_048_576; +const MAX_TEXT_SIZE = 65_536; +const MAX_CIPHERTEXT_SIZE = MAX_TEXT_SIZE + 16; +const MAX_EPOCH_SIZE = 256 * 1024; +const MAX_GENESIS_SIZE = 4096; +const FINGERPRINT = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{32}$/; +const textEncoder = new TextEncoder(); -const validMessageTypes = new Set(Object.values(MessageType)); +function fail(field: string, reason: string): never { + throw new Error(`Invalid message: ${field} ${reason}`); +} + +function object(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + fail('payload', 'must be a map'); + return value as Record; +} + +function keys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) + fail(key, 'is required'); + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(key, 'is not allowed'); + } +} + +function bytes( + value: unknown, + field: string, + length?: number, + min = 0, +): asserts value is Uint8Array { + if (!(value instanceof Uint8Array)) fail(field, 'must be bytes'); + if (length !== undefined && value.length !== length) + fail(field, `must be ${length} bytes`); + if (value.length < min) fail(field, `must be at least ${min} bytes`); +} + +function integer( + value: unknown, + field: string, + max = 0xffff_ffff, +): asserts value is number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) > max + ) { + fail(field, `must be an integer from 0 to ${max}`); + } +} + +function timestamp(value: unknown): asserts value is number { + integer(value, 'timestamp', Number.MAX_SAFE_INTEGER); +} + +function string( + value: unknown, + field: string, + min: number, + max: number, +): asserts value is string { + const length = + typeof value === 'string' ? textEncoder.encode(value).length : -1; + if (typeof value !== 'string' || length < min || length > max) { + fail(field, `must be a string of ${min}-${max} UTF-8 bytes`); + } +} + +function optionalString( + value: unknown, + field: string, + min: number, + max: number, +): void { + if (value !== undefined) string(value, field, min, max); +} + +function fingerprint(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || !FINGERPRINT.test(value)) + fail(field, 'must be a valid fingerprint'); +} + +function validateHandshake(m: Record): void { + keys( + m, + [ + 'type', + 'edPublicKey', + 'xPublicKey', + 'noisePublicKey', + 'signature', + 'protocolVersion', + 'timestamp', + ], + ['displayName', 'capabilities'], + ); + bytes(m.edPublicKey, 'edPublicKey', 32); + bytes(m.xPublicKey, 'xPublicKey', 32); + bytes(m.noisePublicKey, 'noisePublicKey', 32); + bytes(m.signature, 'signature', 64); + if (m.protocolVersion !== 3) fail('protocolVersion', 'must be 3'); + timestamp(m.timestamp); + optionalString(m.displayName, 'displayName', 1, 128); + if (m.capabilities !== undefined) { + if (!Array.isArray(m.capabilities) || m.capabilities.length > 32) + fail('capabilities', 'must be an array with at most 32 entries'); + const seen = new Set(); + for (const capability of m.capabilities) { + string(capability, 'capabilities[]', 1, 128); + if (seen.has(capability)) fail('capabilities', 'must not contain duplicates'); + seen.add(capability); + } + } +} + +function validateGroupSync(m: Record): void { + keys(m, ['type', 'groupId', 'members', 'epoch', 'timestamp']); + bytes(m.groupId, 'groupId', 32); + if (!Array.isArray(m.members) || m.members.length > 1024) + fail('members', 'must be an array with at most 1024 entries'); + const seen = new Set(); + for (const member of m.members) { + bytes(member, 'members[]', 32); + const key = toHex(member); + if (seen.has(key)) fail('members', 'must not contain duplicates'); + seen.add(key); + } + integer(m.epoch, 'epoch'); + timestamp(m.timestamp); +} + +function validateSenderKeyDistribution(m: Record): void { + keys(m, [ + 'type', + 'protocolVersion', + 'recipientPublicKey', + 'ciphertext', + 'nonce', + 'timestamp', + ]); + if (m.protocolVersion !== SENDER_KEY_PROTOCOL_VERSION) + fail('protocolVersion', `must be ${SENDER_KEY_PROTOCOL_VERSION}`); + bytes(m.recipientPublicKey, 'recipientPublicKey', 32); + bytes(m.ciphertext, 'ciphertext', undefined, 16); + if ((m.ciphertext as Uint8Array).length > MAX_TEXT_SIZE) + fail('ciphertext', 'is too large'); + bytes(m.nonce, 'nonce', 24); + timestamp(m.timestamp); +} + +function validateGroupMessage(m: Record): void { + keys(m, [ + 'type', + 'groupId', + 'senderFingerprint', + 'chainIndex', + 'generationId', + 'epochVersion', + 'epochHash', + 'ciphertext', + 'nonce', + 'timestamp', + 'signature', + ]); + bytes(m.groupId, 'groupId', 32); + fingerprint(m.senderFingerprint, 'senderFingerprint'); + integer(m.chainIndex, 'chainIndex'); + bytes(m.generationId, 'generationId', 16); + integer(m.epochVersion, 'epochVersion'); + bytes(m.epochHash, 'epochHash', 32); + bytes(m.ciphertext, 'ciphertext', undefined, 16); + if ((m.ciphertext as Uint8Array).length > MAX_CIPHERTEXT_SIZE) + fail('ciphertext', 'is too large'); + bytes(m.nonce, 'nonce', 24); + timestamp(m.timestamp); + bytes(m.signature, 'signature', 64); +} + +function validateDirectMessage(m: Record): void { + keys(m, [ + 'type', + 'recipientFingerprint', + 'senderFingerprint', + 'ratchetPublicKey', + 'previousChainLength', + 'messageNumber', + 'ciphertext', + 'nonce', + 'timestamp', + 'signature', + ]); + fingerprint(m.recipientFingerprint, 'recipientFingerprint'); + fingerprint(m.senderFingerprint, 'senderFingerprint'); + bytes(m.ratchetPublicKey, 'ratchetPublicKey', 32); + integer(m.previousChainLength, 'previousChainLength'); + integer(m.messageNumber, 'messageNumber'); + bytes(m.ciphertext, 'ciphertext', undefined, 16); + if ((m.ciphertext as Uint8Array).length > MAX_CIPHERTEXT_SIZE) + fail('ciphertext', 'is too large'); + bytes(m.nonce, 'nonce', 24); + timestamp(m.timestamp); + bytes(m.signature, 'signature', 64); +} + +function validateGroupManagement(m: Record): void { + keys( + m, + [ + 'type', + 'groupId', + 'action', + 'senderFingerprint', + 'recipientFingerprint', + 'timestamp', + 'signature', + ], + [ + 'targetFingerprint', + 'groupName', + 'selfMd', + 'isPublic', + 'metadataVersion', + 'inviteId', + 'epochVersion', + 'epochHash', + 'genesisEpochData', + 'genesisSignature', + 'genesisHash', + ], + ); + bytes(m.groupId, 'groupId', 32); + if ( + ![ + 'create', + 'invite', + 'accept', + 'sync-request', + 'join', + 'leave', + 'kick', + 'promote', + 'metadata', + ].includes(m.action as string) + ) + fail('action', 'is invalid'); + fingerprint(m.senderFingerprint, 'senderFingerprint'); + fingerprint(m.recipientFingerprint, 'recipientFingerprint'); + timestamp(m.timestamp); + bytes(m.signature, 'signature', 64); + if (m.targetFingerprint !== undefined) + fingerprint(m.targetFingerprint, 'targetFingerprint'); + optionalString(m.groupName, 'groupName', 1, 128); + if (m.action === 'metadata') { + if (typeof m.selfMd !== 'string') fail('selfMd', 'is required for metadata'); + optionalString(m.selfMd, 'selfMd', 0, 16 * 1024); + if (typeof m.isPublic !== 'boolean') fail('isPublic', 'must be boolean'); + integer(m.metadataVersion, 'metadataVersion'); + integer(m.epochVersion, 'epochVersion'); + bytes(m.epochHash, 'epochHash', 32); + } else if (m.selfMd !== undefined || m.isPublic !== undefined || m.metadataVersion !== undefined) { + fail('metadata', 'fields require metadata action'); + } + optionalString(m.inviteId, 'inviteId', 1, 128); + if (m.epochVersion !== undefined) integer(m.epochVersion, 'epochVersion'); + if (m.epochHash !== undefined) bytes(m.epochHash, 'epochHash', 32); + if (m.genesisEpochData !== undefined) { + bytes(m.genesisEpochData, 'genesisEpochData', undefined, 1); + if ((m.genesisEpochData as Uint8Array).length > MAX_GENESIS_SIZE) + fail('genesisEpochData', 'is too large'); + } + if (m.genesisSignature !== undefined) + bytes(m.genesisSignature, 'genesisSignature', 64); + if (m.genesisHash !== undefined) bytes(m.genesisHash, 'genesisHash', 32); + if ( + (m.action === 'invite' || m.action === 'kick' || m.action === 'promote') && + m.targetFingerprint === undefined + ) { + fail('targetFingerprint', `is required for ${String(m.action)}`); + } + if (m.action === 'invite') { + if (m.groupName === undefined) fail('groupName', 'is required for invite'); + if (m.inviteId === undefined) fail('inviteId', 'is required for invite'); + if (m.epochVersion === undefined) + fail('epochVersion', 'is required for invite'); + if (m.epochHash === undefined) fail('epochHash', 'is required for invite'); + if (m.genesisEpochData === undefined) + fail('genesisEpochData', 'is required for invite'); + if (m.genesisSignature === undefined) + fail('genesisSignature', 'is required for invite'); + if (m.genesisHash === undefined) + fail('genesisHash', 'is required for invite'); + validateGenesisAnchor( + m.groupId as Uint8Array, + m.genesisEpochData as Uint8Array, + m.genesisSignature as Uint8Array, + m.genesisHash as Uint8Array, + 'invite', + ); + } else if (m.action === 'accept') { + if (m.targetFingerprint === undefined) + fail('targetFingerprint', 'is required for accept'); + if (m.inviteId === undefined) fail('inviteId', 'is required for accept'); + if (m.epochVersion === undefined) + fail('epochVersion', 'is required for accept'); + if (m.epochHash === undefined) fail('epochHash', 'is required for accept'); + } else if (m.action === 'sync-request') { + if (m.epochVersion === undefined) + fail('epochVersion', 'is required for sync-request'); + if (m.epochHash === undefined) + fail('epochHash', 'is required for sync-request'); + } else if ( + m.genesisEpochData !== undefined || + m.genesisSignature !== undefined || + m.genesisHash !== undefined + ) { + fail('genesisEpochData', 'is only allowed for invite'); + } +} + +function validateTTYARequest(m: Record): void { + keys(m, ['type', 'visitorId', 'message', 'ipHash', 'timestamp']); + string(m.visitorId, 'visitorId', 1, 128); + string(m.message, 'message', 1, MAX_TEXT_SIZE); + string(m.ipHash, 'ipHash', 1, 128); + timestamp(m.timestamp); +} + +function validateTTYAResponse(m: Record): void { + keys(m, ['type', 'visitorId', 'message', 'timestamp']); + string(m.visitorId, 'visitorId', 1, 128); + string(m.message, 'message', 0, MAX_TEXT_SIZE); + timestamp(m.timestamp); +} + +function validateNetworkAnnounce(m: Record): void { + keys(m, ['type', 'protocolVersion', 'groups', 'signature', 'timestamp']); + if (m.protocolVersion !== 2) fail('protocolVersion', 'must be 2'); + if (!Array.isArray(m.groups) || m.groups.length > 64) + fail('groups', 'must be an array with at most 64 entries'); + let previous = ''; + for (const item of m.groups) { + const group = object(item); + keys(group, [ + 'groupId', + 'name', + 'selfMd', + 'memberCount', + 'genesisEpochData', + 'genesisSignature', + 'genesisHash', + ]); + bytes(group.groupId, 'groups[].groupId', 32); + const id = Array.from(group.groupId as Uint8Array, (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + if (previous && id <= previous) + fail('groups', 'must contain unique group IDs in ascending order'); + previous = id; + string(group.name, 'groups[].name', 1, 128); + string(group.selfMd, 'groups[].selfMd', 0, 16 * 1024); + integer(group.memberCount, 'groups[].memberCount', 100_000); + if ((group.memberCount as number) < 1) + fail('groups[].memberCount', 'must be at least 1'); + bytes(group.genesisEpochData, 'groups[].genesisEpochData', undefined, 1); + if ((group.genesisEpochData as Uint8Array).length > MAX_GENESIS_SIZE) + fail('groups[].genesisEpochData', 'is too large'); + bytes(group.genesisSignature, 'groups[].genesisSignature', 64); + bytes(group.genesisHash, 'groups[].genesisHash', 32); + validateGenesisAnchor( + group.groupId as Uint8Array, + group.genesisEpochData as Uint8Array, + group.genesisSignature as Uint8Array, + group.genesisHash as Uint8Array, + 'groups[]', + ); + } + bytes(m.signature, 'signature', 64); + timestamp(m.timestamp); +} + +function validateGroupEpoch(m: Record): void { + keys(m, [ + 'type', + 'protocolVersion', + 'groupId', + 'epochData', + 'signature', + 'hash', + 'senderFingerprint', + 'recipientFingerprint', + 'envelopeSignature', + 'timestamp', + ]); + if (m.protocolVersion !== 2) fail('protocolVersion', 'must be 2'); + bytes(m.groupId, 'groupId', 32); + bytes(m.epochData, 'epochData', undefined, 1); + if ((m.epochData as Uint8Array).length > MAX_EPOCH_SIZE) + fail('epochData', 'is too large'); + bytes(m.signature, 'signature', 64); + bytes(m.hash, 'hash', 32); + fingerprint(m.senderFingerprint, 'senderFingerprint'); + fingerprint(m.recipientFingerprint, 'recipientFingerprint'); + bytes(m.envelopeSignature, 'envelopeSignature', 64); + timestamp(m.timestamp); + let epoch; + try { + epoch = deserializeEpoch(m.epochData as Uint8Array); + } catch { + fail('epochData', 'contains an invalid group epoch'); + } + if (epoch.groupId !== toHex(m.groupId as Uint8Array)) + fail('epochData', 'groupId does not match envelope'); + if (!bytesEqual(hashEpoch(m.epochData as Uint8Array), m.hash as Uint8Array)) + fail('hash', 'does not match epochData'); +} + +function validateGenesisAnchor( + groupId: Uint8Array, + epochData: Uint8Array, + signature: Uint8Array, + hash: Uint8Array, + field: string, +): void { + try { + const epoch = deserializeEpoch(epochData); + if ( + !verifyGenesisEpoch( + { epoch, signature, hash }, + toHex(groupId), + epoch.createdBy, + ) + ) { + fail(field, 'contains an invalid genesis trust anchor'); + } + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith('Invalid message:') + ) { + throw error; + } + fail(field, 'contains an invalid genesis trust anchor'); + } +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) { + difference |= a[index] ^ b[index]; + } + return difference === 0; +} + +function validateAck(m: Record): void { + keys(m, ['type', 'messageId', 'timestamp']); + string(m.messageId, 'messageId', 1, 128); + timestamp(m.timestamp); +} + +function validateDelivery(m: Record): void { + keys(m, ['type', 'id', 'senderFingerprint', 'recipientFingerprint', 'timestamp', 'signature', + ...(m.type === MessageType.ReliableDelivery ? ['contentHash', 'createdAt', 'expiresAt', 'message'] : [])]); + string(m.id, 'id', 1, 128); + fingerprint(m.senderFingerprint, 'senderFingerprint'); + fingerprint(m.recipientFingerprint, 'recipientFingerprint'); + timestamp(m.timestamp); + bytes(m.signature, 'signature', 64); + if (m.type === MessageType.ReliableDelivery) { + timestamp(m.createdAt); timestamp(m.expiresAt); + if (typeof m.contentHash !== 'string' || !/^[0-9a-f]{64}$/.test(m.contentHash)) fail('contentHash', 'invalid'); + const inner = object(m.message); + if (inner.type === MessageType.GroupMessage) validateGroupMessage(inner); + else if (inner.type === MessageType.DirectMessage) validateDirectMessage(inner); + else fail('message', 'must be group or direct'); + } +} + +export function validateProtocolMessage( + value: unknown, +): asserts value is ProtocolMessage { + const m = object(value); + if (!Object.prototype.hasOwnProperty.call(m, 'type')) + fail('type', 'is required'); + if (!Number.isInteger(m.type)) fail('type', 'must be an integer'); + switch (m.type) { + case MessageType.ReliableDelivery: + case MessageType.DeliveryReceipt: + validateDelivery(m); + break; + case MessageType.IdentityHandshake: + validateHandshake(m); + break; + case MessageType.GroupSync: + validateGroupSync(m); + break; + case MessageType.SenderKeyDistribution: + validateSenderKeyDistribution(m); + break; + case MessageType.GroupMessage: + validateGroupMessage(m); + break; + case MessageType.DirectMessage: + validateDirectMessage(m); + break; + case MessageType.GroupManagement: + validateGroupManagement(m); + break; + case MessageType.TTYARequest: + validateTTYARequest(m); + break; + case MessageType.TTYAResponse: + validateTTYAResponse(m); + break; + case MessageType.NetworkAnnounce: + validateNetworkAnnounce(m); + break; + case MessageType.GroupEpoch: + validateGroupEpoch(m); + break; + case MessageType.Ack: + validateAck(m); + break; + default: + throw new Error(`Invalid message type: ${String(m.type)}`); + } +} export function encodeMessage(message: ProtocolMessage): Uint8Array { + validateProtocolMessage(message); return encoder.encode(message); } -export function decodeMessage(bytes: Uint8Array): ProtocolMessage { - const decoded = decoder.decode(bytes); +export function decodeMessage(encoded: Uint8Array): ProtocolMessage { if ( - typeof decoded !== 'object' || - decoded === null || - !('type' in decoded) + !(encoded instanceof Uint8Array) || + encoded.length === 0 || + encoded.length > MAX_FRAME_SIZE ) { - throw new Error('Invalid message: missing type field'); + throw new Error('Invalid message: encoded payload size is out of range'); } - if (!validMessageTypes.has(decoded.type as number)) { - throw new Error(`Invalid message type: ${decoded.type}`); + let decoded: unknown; + try { + decoded = decoder.decode(encoded); + } catch (error) { + throw new Error('Invalid message: malformed CBOR payload', { + cause: error, + }); } - return decoded as ProtocolMessage; + validateProtocolMessage(decoded); + return decoded; } export function frameMessage(message: ProtocolMessage): Uint8Array { const payload = encodeMessage(message); - if (payload.length > MAX_FRAME_SIZE) { + if (payload.length > MAX_FRAME_SIZE) throw new Error( - `Message exceeds MAX_FRAME_SIZE: ${payload.length} > ${MAX_FRAME_SIZE}` + `Message exceeds MAX_FRAME_SIZE: ${payload.length} > ${MAX_FRAME_SIZE}`, ); - } const frame = new Uint8Array(4 + payload.length); - const view = new DataView(frame.buffer); - view.setUint32(0, payload.length, false); // big-endian + new DataView(frame.buffer).setUint32(0, payload.length, false); frame.set(payload, 4); return frame; } export function parseFrame( - buffer: Uint8Array + buffer: Uint8Array, ): { message: ProtocolMessage; bytesConsumed: number } | null { - if (buffer.length < 4) { - return null; - } - const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + if (!(buffer instanceof Uint8Array)) throw new Error('Invalid frame buffer'); + if (buffer.length < 4) return null; + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); const payloadLength = view.getUint32(0, false); - if (payloadLength > MAX_FRAME_SIZE) { + if (payloadLength === 0) throw new Error('Invalid frame: empty payload'); + if (payloadLength > MAX_FRAME_SIZE) throw new Error( - `Frame exceeds MAX_FRAME_SIZE: ${payloadLength} > ${MAX_FRAME_SIZE}` + `Frame exceeds MAX_FRAME_SIZE: ${payloadLength} > ${MAX_FRAME_SIZE}`, ); - } - if (buffer.length < 4 + payloadLength) { - return null; // incomplete frame - } - const payload = buffer.slice(4, 4 + payloadLength); - const message = decodeMessage(payload); + if (buffer.length < 4 + payloadLength) return null; + const message = decodeMessage(buffer.slice(4, 4 + payloadLength)); return { message, bytesConsumed: 4 + payloadLength }; } diff --git a/packages/core/src/protocol/reliable-delivery.ts b/packages/core/src/protocol/reliable-delivery.ts new file mode 100644 index 0000000..2f0db36 --- /dev/null +++ b/packages/core/src/protocol/reliable-delivery.ts @@ -0,0 +1,49 @@ +import { Encoder } from 'cbor-x'; +import { sha256 } from '@noble/hashes/sha256'; +import { sign, verify } from '../crypto/signatures.js'; +import { authenticatedMessagePayload } from './message-auth.js'; +import type { DirectEncryptedMessage, GroupEncryptedMessage } from './types.js'; + +export const RELIABLE_DELIVERY_CAPABILITY = 'reliable-delivery-v1'; +export const DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export interface ReliableDeliveryMessage { + type: 0x0c; + id: string; + senderFingerprint: string; + recipientFingerprint: string; + contentHash: string; + createdAt: number; + expiresAt: number; + timestamp: number; + message: DirectEncryptedMessage | GroupEncryptedMessage; + signature: Uint8Array; +} +export interface DeliveryReceiptMessage { + type: 0x0d; + id: string; + senderFingerprint: string; + recipientFingerprint: string; + timestamp: number; + signature: Uint8Array; +} +const encoder = new Encoder({ useRecords: false, mapsAsObjects: true }); +type Packet = ReliableDeliveryMessage | DeliveryReceiptMessage; +function payload(message: Omit | Omit): Uint8Array { + const fields: unknown[] = ['networkselfmd-reliable-delivery-v1', message.type, + message.id, message.senderFingerprint, message.recipientFingerprint, message.timestamp]; + if (message.type === 0x0c) { + fields.push(message.contentHash, message.createdAt, message.expiresAt, + authenticatedMessagePayload(message.message), new Uint8Array(message.message.signature)); + } + return encoder.encode(fields); +} +export function signDelivery(message: Omit, privateKey: Uint8Array): T { + return { ...message, signature: sign(payload(message as Parameters[0]), privateKey) } as T; +} +export function verifyDelivery(message: Packet, publicKey: Uint8Array): boolean { + return verify(message.signature, payload(message), publicKey); +} +export function deliveryContentHash(content: string, groupId?: Uint8Array): string { + return Array.from(sha256(encoder.encode(['networkselfmd-delivery-content-v1', groupId ? new Uint8Array(groupId) : null, content])), + byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/core/src/protocol/sender-key-envelope.ts b/packages/core/src/protocol/sender-key-envelope.ts new file mode 100644 index 0000000..8ff63d1 --- /dev/null +++ b/packages/core/src/protocol/sender-key-envelope.ts @@ -0,0 +1,66 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { MessageType } from './types.js'; +import type { SenderKeyDistributionMessage } from './types.js'; + +export const SENDER_KEY_ENVELOPE_VERSION = 2; +const DOMAIN = new TextEncoder().encode( + 'network.self.md/SenderKeyEnvelope/v2\0', +); + +/** Stable replay identity for the opaque recipient-specific encrypted envelope. */ +export function senderKeyEnvelopeId( + message: SenderKeyDistributionMessage, +): Uint8Array { + if ( + message.type !== MessageType.SenderKeyDistribution || + message.protocolVersion !== SENDER_KEY_ENVELOPE_VERSION + ) { + throw new Error('Unsupported sender-key envelope version'); + } + assertBytes(message.recipientPublicKey, 32, 'recipient public key'); + assertBytes(message.nonce, 24, 'nonce'); + if ( + !(message.ciphertext instanceof Uint8Array) || + message.ciphertext.length < 16 || + message.ciphertext.length > 65_536 + ) { + throw new Error('Invalid sender-key envelope ciphertext'); + } + if (!Number.isSafeInteger(message.timestamp) || message.timestamp < 0) { + throw new Error('Invalid sender-key envelope timestamp'); + } + const timestamp = new Uint8Array(8); + new DataView(timestamp.buffer).setBigUint64( + 0, + BigInt(message.timestamp), + false, + ); + return sha256( + concatBytes( + DOMAIN, + new Uint8Array([message.type, message.protocolVersion]), + message.recipientPublicKey, + message.nonce, + timestamp, + message.ciphertext, + ), + ); +} + +function assertBytes(value: unknown, length: number, label: string): void { + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new Error(`Invalid sender-key envelope ${label}`); + } +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array( + parts.reduce((length, part) => length + part.length, 0), + ); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} diff --git a/packages/core/src/protocol/sender-keys.ts b/packages/core/src/protocol/sender-keys.ts index ce7885a..4d1247d 100644 --- a/packages/core/src/protocol/sender-keys.ts +++ b/packages/core/src/protocol/sender-keys.ts @@ -1,6 +1,8 @@ import { randomBytes } from '@noble/hashes/utils'; -import { encrypt, decrypt } from '../crypto/aead.js'; -import { advanceChain } from '../crypto/kdf.js'; +import { x25519 } from '@noble/curves/ed25519'; +import { xchacha20poly1305 } from '@noble/ciphers/chacha'; +import { Decoder, Encoder } from 'cbor-x'; +import { advanceChain, deriveKey } from '../crypto/kdf.js'; import type { SenderKeyDistributionMessage } from './types.js'; import { MessageType } from './types.js'; @@ -15,7 +17,31 @@ export interface SenderKeyRecord { skippedKeys: Map; } +/** Plaintext carried inside a recipient-specific sender-key envelope. */ +export interface SenderKeyDistributionPayload { + protocolVersion: number; + groupId: Uint8Array; + generationId: Uint8Array; + sequence: number; + chainKey: Uint8Array; + chainIndex: number; + signingPublicKey: Uint8Array; + epochVersion: number; + epochHash: Uint8Array; + timestamp: number; +} + const MAX_SKIP = 256; +export const SENDER_KEY_PROTOCOL_VERSION = 2; +export const SENDER_KEY_CAPABILITY = 'sender-key-v2'; +const DISTRIBUTION_KEY_DOMAIN = new TextEncoder().encode( + 'networkselfmd-sender-key-distribution-key-v1', +); +const DISTRIBUTION_AAD_DOMAIN = new TextEncoder().encode( + 'networkselfmd-sender-key-distribution-aad-v1', +); +const encoder = new Encoder({ useRecords: false }); +const decoder = new Decoder({ mapsAsObjects: true, useRecords: false }); export const SenderKeys = { generate(): SenderKeyState { @@ -27,7 +53,8 @@ export const SenderKeys = { encrypt( state: SenderKeyState, - plaintext: Uint8Array + plaintext: Uint8Array, + aad?: Uint8Array, ): { ciphertext: Uint8Array; nonce: Uint8Array; @@ -35,7 +62,8 @@ export const SenderKeys = { nextState: SenderKeyState; } { const { messageKey, nextChainKey } = advanceChain(state.chainKey); - const { ciphertext, nonce } = encrypt(messageKey, plaintext); + const nonce = randomBytes(24); + const ciphertext = xchacha20poly1305(messageKey, nonce, aad).encrypt(plaintext); return { ciphertext, nonce, @@ -51,7 +79,8 @@ export const SenderKeys = { record: SenderKeyRecord, chainIndex: number, nonce: Uint8Array, - ciphertext: Uint8Array + ciphertext: Uint8Array, + aad?: Uint8Array, ): { plaintext: Uint8Array; nextRecord: SenderKeyRecord; @@ -59,7 +88,7 @@ export const SenderKeys = { // Check skipped keys first if (record.skippedKeys.has(chainIndex)) { const messageKey = record.skippedKeys.get(chainIndex)!; - const plaintext = decrypt(messageKey, nonce, ciphertext); + const plaintext = xchacha20poly1305(messageKey, nonce, aad).decrypt(ciphertext); const newSkipped = new Map(record.skippedKeys); newSkipped.delete(chainIndex); return { @@ -74,15 +103,13 @@ export const SenderKeys = { if (chainIndex < record.chainIndex) { throw new Error( - `Cannot decrypt: chain index ${chainIndex} already consumed and not in skipped keys` + `Cannot decrypt: chain index ${chainIndex} already consumed and not in skipped keys`, ); } const skip = chainIndex - record.chainIndex; if (skip > MAX_SKIP) { - throw new Error( - `Too many skipped messages: ${skip} > ${MAX_SKIP}` - ); + throw new Error(`Too many skipped messages: ${skip} > ${MAX_SKIP}`); } // Advance chain, caching skipped keys @@ -97,7 +124,7 @@ export const SenderKeys = { // Derive message key for this index const { messageKey, nextChainKey } = advanceChain(currentChainKey); - const plaintext = decrypt(messageKey, nonce, ciphertext); + const plaintext = xchacha20poly1305(messageKey, nonce, aad).decrypt(ciphertext); return { plaintext, @@ -108,19 +135,226 @@ export const SenderKeys = { }, }; }, - createDistribution( groupId: Uint8Array, state: SenderKeyState, - signingPublicKey: Uint8Array - ): SenderKeyDistributionMessage { + signingPublicKey: Uint8Array, + epochVersion = 0, + epochHash: Uint8Array = new Uint8Array(32), + generationId: Uint8Array = randomBytes(16), + sequence = 0, + ): SenderKeyDistributionPayload { return { - type: MessageType.SenderKeyDistribution, + protocolVersion: SENDER_KEY_PROTOCOL_VERSION, groupId, + generationId, + sequence, chainKey: state.chainKey, chainIndex: state.chainIndex, signingPublicKey, + epochVersion, + epochHash, timestamp: Date.now(), }; }, + + encryptDistribution( + payload: SenderKeyDistributionPayload, + senderXPrivateKey: Uint8Array, + senderPublicKey: Uint8Array, + recipientXPublicKey: Uint8Array, + recipientPublicKey: Uint8Array, + ): SenderKeyDistributionMessage { + assertDistributionPayload(payload); + assertLength(senderXPrivateKey, 32, 'sender X25519 private key'); + assertLength(senderPublicKey, 32, 'sender public key'); + assertLength(recipientXPublicKey, 32, 'recipient X25519 public key'); + assertLength(recipientPublicKey, 32, 'recipient public key'); + + if (!bytesEqual(payload.signingPublicKey, senderPublicKey)) { + throw new Error('Sender-key signing public key does not match sender identity'); + } + + const context = distributionContext( + senderPublicKey, + recipientPublicKey, + payload.timestamp, + ); + const sharedSecret = x25519.getSharedSecret( + senderXPrivateKey, + recipientXPublicKey, + ); + const key = deriveKey( + sharedSecret, + DISTRIBUTION_KEY_DOMAIN, + context, + 32, + ); + const nonce = randomBytes(24); + const aad = concatBytes(DISTRIBUTION_AAD_DOMAIN, context); + const cipher = xchacha20poly1305(key, nonce, aad); + + return { + type: MessageType.SenderKeyDistribution, + protocolVersion: SENDER_KEY_PROTOCOL_VERSION, + recipientPublicKey, + ciphertext: cipher.encrypt(encoder.encode(payload)), + nonce, + timestamp: payload.timestamp, + }; + }, + + decryptDistribution( + message: SenderKeyDistributionMessage, + recipientXPrivateKey: Uint8Array, + recipientPublicKey: Uint8Array, + authenticatedSenderXPublicKey: Uint8Array, + authenticatedSenderPublicKey: Uint8Array, + ): SenderKeyDistributionPayload { + assertSenderKeyDistributionMessage(message); + if (message.protocolVersion !== SENDER_KEY_PROTOCOL_VERSION) { + throw new Error('Unsupported sender-key distribution version'); + } + assertLength(message.recipientPublicKey, 32, 'recipient public key'); + assertLength(message.nonce, 24, 'sender-key distribution nonce'); + assertLength(recipientXPrivateKey, 32, 'recipient X25519 private key'); + assertLength(recipientPublicKey, 32, 'recipient public key'); + assertLength(authenticatedSenderXPublicKey, 32, 'sender X25519 public key'); + assertLength(authenticatedSenderPublicKey, 32, 'sender public key'); + if (!Number.isSafeInteger(message.timestamp) || message.timestamp < 0) { + throw new Error('Invalid sender-key distribution timestamp'); + } + if (!bytesEqual(message.recipientPublicKey, recipientPublicKey)) { + throw new Error('Sender-key distribution is intended for another recipient'); + } + + const context = distributionContext( + authenticatedSenderPublicKey, + recipientPublicKey, + message.timestamp, + message.protocolVersion, + ); + const sharedSecret = x25519.getSharedSecret( + recipientXPrivateKey, + authenticatedSenderXPublicKey, + ); + const key = deriveKey( + sharedSecret, + DISTRIBUTION_KEY_DOMAIN, + context, + 32, + ); + const aad = concatBytes(DISTRIBUTION_AAD_DOMAIN, context); + const cipher = xchacha20poly1305(key, message.nonce, aad); + const decoded = decoder.decode(cipher.decrypt(message.ciphertext)); + assertDistributionPayload(decoded); + + if (decoded.timestamp !== message.timestamp) { + throw new Error('Sender-key distribution timestamp mismatch'); + } + if (decoded.protocolVersion !== message.protocolVersion) { + throw new Error('Sender-key distribution protocol mismatch'); + } + if (!bytesEqual(decoded.signingPublicKey, authenticatedSenderPublicKey)) { + throw new Error( + 'Sender-key signing public key does not match authenticated session', + ); + } + + return decoded; + }, }; + +export function assertSenderKeyDistributionMessage(value: unknown): asserts value is SenderKeyDistributionMessage { + if (!value || typeof value !== 'object') throw new Error('Invalid sender-key envelope'); + const message = value as Partial; + assertExactKeys(message as Record, ['type', 'protocolVersion', 'recipientPublicKey', 'ciphertext', 'nonce', 'timestamp'], 'sender-key envelope'); + if (message.type !== MessageType.SenderKeyDistribution || message.protocolVersion !== SENDER_KEY_PROTOCOL_VERSION) throw new Error('Unsupported sender-key distribution version'); + assertLength(message.recipientPublicKey, 32, 'recipient public key'); + assertLength(message.nonce, 24, 'sender-key distribution nonce'); + if (!(message.ciphertext instanceof Uint8Array) || message.ciphertext.length === 0 || message.ciphertext.length > 64 * 1024) throw new Error('Invalid sender-key ciphertext'); + if (!Number.isSafeInteger(message.timestamp) || message.timestamp! < 0) throw new Error('Invalid sender-key distribution timestamp'); +} + +function distributionContext( + senderPublicKey: Uint8Array, + recipientPublicKey: Uint8Array, + timestamp: number, + protocolVersion = SENDER_KEY_PROTOCOL_VERSION, +): Uint8Array { + const timestampBytes = new Uint8Array(8); + new DataView(timestampBytes.buffer).setBigUint64(0, BigInt(timestamp), false); + return concatBytes( + new Uint8Array([MessageType.SenderKeyDistribution]), + new Uint8Array([protocolVersion]), + senderPublicKey, + recipientPublicKey, + timestampBytes, + ); +} + +function assertDistributionPayload( + value: unknown, +): asserts value is SenderKeyDistributionPayload { + if (!value || typeof value !== 'object') { + throw new Error('Invalid sender-key distribution payload'); + } + const payload = value as Partial; + assertExactKeys(payload as Record, ['protocolVersion', 'groupId', 'generationId', 'sequence', 'chainKey', 'chainIndex', 'signingPublicKey', 'epochVersion', 'epochHash', 'timestamp'], 'sender-key payload'); + if (payload.protocolVersion !== SENDER_KEY_PROTOCOL_VERSION) { + throw new Error('Unsupported sender-key payload version'); + } + assertLength(payload.groupId, 32, 'group id'); + assertLength(payload.generationId, 16, 'sender-key generation id'); + assertLength(payload.chainKey, 32, 'sender chain key'); + assertLength(payload.signingPublicKey, 32, 'signing public key'); + assertLength(payload.epochHash, 32, 'group epoch hash'); + if (!Number.isSafeInteger(payload.chainIndex) || payload.chainIndex! < 0) { + throw new Error('Invalid sender chain index'); + } + if (!Number.isSafeInteger(payload.sequence) || payload.sequence! < 0) { + throw new Error('Invalid sender-key distribution sequence'); + } + if (!Number.isSafeInteger(payload.epochVersion) || payload.epochVersion! < 0) { + throw new Error('Invalid group epoch version'); + } + if (!Number.isSafeInteger(payload.timestamp) || payload.timestamp! < 0) { + throw new Error('Invalid sender-key distribution timestamp'); + } +} + +function assertLength( + value: unknown, + length: number, + label: string, +): asserts value is Uint8Array { + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new Error(`Invalid ${label}`); + } +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array( + parts.reduce((length, part) => length + part.length, 0), + ); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) { + difference |= a[index] ^ b[index]; + } + return difference === 0; +} + +function assertExactKeys(value: Record, allowed: string[], label: string): void { + const keys = Object.keys(value); + if (keys.length !== allowed.length || keys.some((key) => !allowed.includes(key))) throw new Error(`Invalid ${label} schema`); +} diff --git a/packages/core/src/protocol/ttya-auth.ts b/packages/core/src/protocol/ttya-auth.ts new file mode 100644 index 0000000..b39dba8 --- /dev/null +++ b/packages/core/src/protocol/ttya-auth.ts @@ -0,0 +1,329 @@ +export const TTYA_AUTH_PROTOCOL = 'networkselfmd-ttya-auth-v3' as const; +export const TTYA_AUTH_VERSION = 3 as const; +export const TTYA_AUTH_NONCE_BYTES = 32; +export const TTYA_AUTH_PROOF_BYTES = 32; +export const TTYA_AUTH_SECRET_MIN_BYTES = 32; +export const TTYA_CHANNEL_BINDING_MIN_BYTES = 32; +export const TTYA_CHANNEL_BINDING_MAX_BYTES = 128; +export const MAX_TTYA_FRAME_SIZE = 65_536; +/** Application limits enforced by both ends of the authenticated relay. */ +export const MAX_TTYA_CONTENT_BYTES = 4_096; +export const MAX_TTYA_USER_AGENT_BYTES = 1_024; + +export type TTYAAuthRole = 'bridge' | 'agent'; +export type TTYADataDirection = 'bridge-to-agent' | 'agent-to-bridge'; + +export interface TTYAAuthChallengeFrame { + type: 'ttya-auth-challenge'; + version: typeof TTYA_AUTH_VERSION; + agentNonce: string; +} + +export interface TTYAAuthResponseFrame { + type: 'ttya-auth-response'; + version: typeof TTYA_AUTH_VERSION; + agentNonce: string; + bridgeNonce: string; + proof: string; +} + +export interface TTYAAuthConfirmationFrame { + type: 'ttya-auth-confirmation'; + version: typeof TTYA_AUTH_VERSION; + agentNonce: string; + bridgeNonce: string; + proof: string; +} + +/** Every application message is carried in an authenticated, ordered envelope. */ +export interface TTYADataFrame { + type: 'ttya-data'; + version: typeof TTYA_AUTH_VERSION; + sequence: number; + /** Canonical base64 of the JSON application message. */ + payload: string; + proof: string; +} + +export type TTYAAuthFrame = + | TTYAAuthChallengeFrame + | TTYAAuthResponseFrame + | TTYAAuthConfirmationFrame; + +const HEX_PATTERN = /^[0-9a-f]+$/; +const BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const encoder = new TextEncoder(); + +function isHexBytes(value: unknown, byteLength: number): value is string { + return ( + typeof value === 'string' && + value.length === byteLength * 2 && + HEX_PATTERN.test(value) + ); +} + +function isSafeSequence(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +export function isTTYAAuthChallengeFrame( + value: unknown, +): value is TTYAAuthChallengeFrame { + if (value === null || typeof value !== 'object') return false; + const frame = value as Record; + return ( + frame.type === 'ttya-auth-challenge' && + frame.version === TTYA_AUTH_VERSION && + isHexBytes(frame.agentNonce, TTYA_AUTH_NONCE_BYTES) + ); +} + +export function isTTYAAuthResponseFrame( + value: unknown, +): value is TTYAAuthResponseFrame { + if (value === null || typeof value !== 'object') return false; + const frame = value as Record; + return ( + frame.type === 'ttya-auth-response' && + frame.version === TTYA_AUTH_VERSION && + isHexBytes(frame.agentNonce, TTYA_AUTH_NONCE_BYTES) && + isHexBytes(frame.bridgeNonce, TTYA_AUTH_NONCE_BYTES) && + isHexBytes(frame.proof, TTYA_AUTH_PROOF_BYTES) + ); +} + +export function isTTYAAuthConfirmationFrame( + value: unknown, +): value is TTYAAuthConfirmationFrame { + if (value === null || typeof value !== 'object') return false; + const frame = value as Record; + return ( + frame.type === 'ttya-auth-confirmation' && + frame.version === TTYA_AUTH_VERSION && + isHexBytes(frame.agentNonce, TTYA_AUTH_NONCE_BYTES) && + isHexBytes(frame.bridgeNonce, TTYA_AUTH_NONCE_BYTES) && + isHexBytes(frame.proof, TTYA_AUTH_PROOF_BYTES) + ); +} + +export function isTTYADataFrame(value: unknown): value is TTYADataFrame { + if (value === null || typeof value !== 'object') return false; + const frame = value as Record; + return ( + frame.type === 'ttya-data' && + frame.version === TTYA_AUTH_VERSION && + isSafeSequence(frame.sequence) && + typeof frame.payload === 'string' && + frame.payload.length > 0 && + BASE64_PATTERN.test(frame.payload) && + isHexBytes(frame.proof, TTYA_AUTH_PROOF_BYTES) + ); +} + +function hexToBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function appendLengthPrefixed( + target: Uint8Array, + offset: number, + value: Uint8Array, +): void { + const view = new DataView( + target.buffer, + target.byteOffset, + target.byteLength, + ); + view.setUint16(offset, value.length, false); + target.set(value, offset + 2); +} + +/** Return an owned PSK copy after enforcing the production entropy floor. */ +export function copyAndValidateTTYAAuthSecret(secret: Uint8Array): Uint8Array { + if ( + !(secret instanceof Uint8Array) || + secret.length < TTYA_AUTH_SECRET_MIN_BYTES + ) { + throw new Error( + `TTYA authentication secret must contain at least ${TTYA_AUTH_SECRET_MIN_BYTES} random bytes`, + ); + } + return new Uint8Array(secret); +} + +/** Return an owned copy of the Noise handshake hash used as channel binding. */ +export function copyAndValidateTTYAChannelBinding( + binding: Uint8Array | null | undefined, +): Uint8Array { + if ( + !(binding instanceof Uint8Array) || + binding.length < TTYA_CHANNEL_BINDING_MIN_BYTES || + binding.length > TTYA_CHANNEL_BINDING_MAX_BYTES + ) { + throw new Error('Missing or invalid Noise channel binding'); + } + return new Uint8Array(binding); +} + +/** + * Canonical proof input for the mutual TTYA handshake. Both fresh nonces and + * the local Noise transcript hash are covered, preventing a proof from being + * relayed between two independent encrypted connections. + */ +export function buildTTYAAuthProofPayload( + role: TTYAAuthRole, + agentNonce: string, + bridgeNonce: string, + channelBinding: Uint8Array, +): Uint8Array { + if ( + !isHexBytes(agentNonce, TTYA_AUTH_NONCE_BYTES) || + !isHexBytes(bridgeNonce, TTYA_AUTH_NONCE_BYTES) + ) { + throw new Error('Invalid TTYA authentication nonce'); + } + const binding = copyAndValidateTTYAChannelBinding(channelBinding); + const prefix = encoder.encode(`${TTYA_AUTH_PROTOCOL}\0proof\0${role}\0`); + const payload = new Uint8Array( + prefix.length + TTYA_AUTH_NONCE_BYTES * 2 + 2 + binding.length, + ); + let offset = 0; + payload.set(prefix, offset); + offset += prefix.length; + payload.set(hexToBytes(agentNonce), offset); + offset += TTYA_AUTH_NONCE_BYTES; + payload.set(hexToBytes(bridgeNonce), offset); + offset += TTYA_AUTH_NONCE_BYTES; + appendLengthPrefixed(payload, offset, binding); + return payload; +} + +/** Canonical input used to derive the post-authentication application key. */ +export function buildTTYASessionKeyPayload( + agentNonce: string, + bridgeNonce: string, + channelBinding: Uint8Array, +): Uint8Array { + if ( + !isHexBytes(agentNonce, TTYA_AUTH_NONCE_BYTES) || + !isHexBytes(bridgeNonce, TTYA_AUTH_NONCE_BYTES) + ) { + throw new Error('Invalid TTYA authentication nonce'); + } + const binding = copyAndValidateTTYAChannelBinding(channelBinding); + const prefix = encoder.encode(`${TTYA_AUTH_PROTOCOL}\0session-key\0`); + const payload = new Uint8Array( + prefix.length + TTYA_AUTH_NONCE_BYTES * 2 + 2 + binding.length, + ); + let offset = 0; + payload.set(prefix, offset); + offset += prefix.length; + payload.set(hexToBytes(agentNonce), offset); + offset += TTYA_AUTH_NONCE_BYTES; + payload.set(hexToBytes(bridgeNonce), offset); + offset += TTYA_AUTH_NONCE_BYTES; + appendLengthPrefixed(payload, offset, binding); + return payload; +} + +/** Canonical input for an ordered application-frame MAC. */ +export function buildTTYADataProofPayload( + direction: TTYADataDirection, + sequence: number, + payload: Uint8Array, +): Uint8Array { + if (!isSafeSequence(sequence)) { + throw new Error('Invalid TTYA data sequence'); + } + if (!(payload instanceof Uint8Array) || payload.length === 0) { + throw new Error('Invalid TTYA data payload'); + } + const prefix = encoder.encode(`${TTYA_AUTH_PROTOCOL}\0data\0${direction}\0`); + const result = new Uint8Array(prefix.length + 8 + payload.length); + result.set(prefix, 0); + new DataView( + result.buffer, + result.byteOffset, + result.byteLength, + ).setBigUint64(prefix.length, BigInt(sequence), false); + result.set(payload, prefix.length + 8); + return result; +} + +/** + * Incremental length-prefixed frame parser. It accepts fragmented headers and + * payloads, drains any number of coalesced frames, and rejects an oversized + * length before allocating the advertised payload. + */ +export class TTYAFrameDecoder { + private readonly header = new Uint8Array(4); + private headerOffset = 0; + private payload: Uint8Array | null = null; + private payloadOffset = 0; + + push(chunk: Uint8Array): Uint8Array[] { + if (!(chunk instanceof Uint8Array)) { + throw new Error('TTYA frame chunk must be bytes'); + } + + const frames: Uint8Array[] = []; + let offset = 0; + while (offset < chunk.length) { + if (this.payload === null) { + const headerBytes = Math.min( + 4 - this.headerOffset, + chunk.length - offset, + ); + this.header.set( + chunk.subarray(offset, offset + headerBytes), + this.headerOffset, + ); + this.headerOffset += headerBytes; + offset += headerBytes; + if (this.headerOffset < 4) continue; + + const length = new DataView( + this.header.buffer, + this.header.byteOffset, + this.header.byteLength, + ).getUint32(0, false); + this.headerOffset = 0; + if (length === 0 || length > MAX_TTYA_FRAME_SIZE) { + this.reset(); + throw new Error(`Invalid TTYA frame length: ${length}`); + } + this.payload = new Uint8Array(length); + this.payloadOffset = 0; + } + + const payloadBytes = Math.min( + this.payload.length - this.payloadOffset, + chunk.length - offset, + ); + this.payload.set( + chunk.subarray(offset, offset + payloadBytes), + this.payloadOffset, + ); + this.payloadOffset += payloadBytes; + offset += payloadBytes; + + if (this.payloadOffset === this.payload.length) { + frames.push(this.payload); + this.payload = null; + this.payloadOffset = 0; + } + } + return frames; + } + + reset(): void { + this.headerOffset = 0; + this.payload = null; + this.payloadOffset = 0; + } +} diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index 376c0c5..dd87f55 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -8,7 +8,10 @@ export const MessageType = { TTYARequest: 0x07, TTYAResponse: 0x08, NetworkAnnounce: 0x09, + GroupEpoch: 0x0a, Ack: 0xff, + ReliableDelivery: 0x0c, + DeliveryReceipt: 0x0d, } as const; export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType]; @@ -16,10 +19,12 @@ export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType]; export interface IdentityHandshakeMessage { type: typeof MessageType.IdentityHandshake; edPublicKey: Uint8Array; + xPublicKey: Uint8Array; noisePublicKey: Uint8Array; signature: Uint8Array; displayName?: string; protocolVersion: number; + capabilities?: string[]; timestamp: number; } @@ -33,10 +38,10 @@ export interface GroupSyncMessage { export interface SenderKeyDistributionMessage { type: typeof MessageType.SenderKeyDistribution; - groupId: Uint8Array; - chainKey: Uint8Array; - chainIndex: number; - signingPublicKey: Uint8Array; + protocolVersion: number; + recipientPublicKey: Uint8Array; + ciphertext: Uint8Array; + nonce: Uint8Array; timestamp: number; } @@ -45,9 +50,13 @@ export interface GroupEncryptedMessage { groupId: Uint8Array; senderFingerprint: string; chainIndex: number; + generationId: Uint8Array; + epochVersion: number; + epochHash: Uint8Array; ciphertext: Uint8Array; nonce: Uint8Array; timestamp: number; + signature: Uint8Array; } export interface DirectEncryptedMessage { @@ -60,15 +69,28 @@ export interface DirectEncryptedMessage { ciphertext: Uint8Array; nonce: Uint8Array; timestamp: number; + signature: Uint8Array; } export interface GroupManagementMessage { type: typeof MessageType.GroupManagement; groupId: Uint8Array; - action: 'create' | 'invite' | 'join' | 'leave' | 'kick' | 'promote'; + action: 'create' | 'invite' | 'accept' | 'sync-request' | 'join' | 'leave' | 'kick' | 'promote' | 'metadata'; + selfMd?: string; + isPublic?: boolean; + metadataVersion?: number; targetFingerprint?: string; groupName?: string; + inviteId?: string; + epochVersion?: number; + epochHash?: Uint8Array; + genesisEpochData?: Uint8Array; + genesisSignature?: Uint8Array; + genesisHash?: Uint8Array; + senderFingerprint: string; + recipientFingerprint: string; timestamp: number; + signature: Uint8Array; } export interface TTYARequestMessage { @@ -88,12 +110,32 @@ export interface TTYAResponseMessage { export interface NetworkAnnounceMessage { type: typeof MessageType.NetworkAnnounce; + protocolVersion: number; groups: Array<{ groupId: Uint8Array; name: string; selfMd: string; memberCount: number; + genesisEpochData: Uint8Array; + genesisSignature: Uint8Array; + genesisHash: Uint8Array; }>; + signature: Uint8Array; + timestamp: number; +} + +export interface GroupEpochMessage { + type: typeof MessageType.GroupEpoch; + protocolVersion: number; + groupId: Uint8Array; + epochData: Uint8Array; + /** Signature of the immutable epoch. */ + signature: Uint8Array; + hash: Uint8Array; + senderFingerprint: string; + recipientFingerprint: string; + /** Signature of this fresh, recipient-bound delivery envelope. */ + envelopeSignature: Uint8Array; timestamp: number; } @@ -113,7 +155,10 @@ export type ProtocolMessage = | TTYARequestMessage | TTYAResponseMessage | NetworkAnnounceMessage - | AckMessage; + | GroupEpochMessage + | AckMessage + | import('./reliable-delivery.js').ReliableDeliveryMessage + | import('./reliable-delivery.js').DeliveryReceiptMessage; // Domain types diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index ed7da23..7c37758 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -3,7 +3,15 @@ - network.self.md — sovereign agent network + + + + Network — your agent. meet theirs. +
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 1a55005..dce2876 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -12,19 +12,25 @@ }, "dependencies": { "@fastify/cors": "^10.0.0", - "@fastify/static": "^8.1.0", + "@fastify/helmet": "^13.0.0", + "@fastify/rate-limit": "^10.2.0", + "@fastify/static": "^10.1.2", "@networkselfmd/node": "workspace:*", - "fastify": "^5.2.0", + "fastify": "^5.12.1", "react": "^18.3.0", - "react-dom": "^18.3.0" + "react-dom": "^18.3.0", + "zustand": "^5.0.15" }, "devDependencies": { "@paper-design/shaders-react": "^0.0.76", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "concurrently": "^9.1.0", + "jsdom": "^29.1.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vite": "^6.3.0", diff --git a/packages/dashboard/public/network-mark.svg b/packages/dashboard/public/network-mark.svg new file mode 100644 index 0000000..598d8c5 --- /dev/null +++ b/packages/dashboard/public/network-mark.svg @@ -0,0 +1 @@ + diff --git a/packages/dashboard/publication.example.json b/packages/dashboard/publication.example.json new file mode 100644 index 0000000..d0c68e7 --- /dev/null +++ b/packages/dashboard/publication.example.json @@ -0,0 +1,6 @@ +{ + "observerLabel": "this node", + "stateIds": [], + "stateContextIds": [], + "peers": {} +} diff --git a/packages/dashboard/src/client/App.tsx b/packages/dashboard/src/client/App.tsx index 748626e..2858a94 100644 --- a/packages/dashboard/src/client/App.tsx +++ b/packages/dashboard/src/client/App.tsx @@ -1,57 +1,74 @@ -import { useMemo, useState } from 'react'; -import { usePolling } from './hooks/usePolling'; -import { useRoute } from './hooks/useRoute'; -import { StatusBar } from './components/StatusBar'; -import { PeerList } from './components/PeerList'; -import { StateList } from './components/StateList'; -import { StatePage } from './components/StatePage'; -import { ToastProvider, useToast } from './components/Toast'; -import type { ApiStatus, ApiPeer, ApiState, ApiDiscoveredState, ApiJoinResponse } from './types'; +import { PublicApp } from "./public/PublicApp"; +import { useMemo, useState } from "react"; +import { usePolling } from "./hooks/usePolling"; +import { useRoute } from "./hooks/useRoute"; +import { StatusBar } from "./components/StatusBar"; +import { PeerList } from "./components/PeerList"; +import { StateList } from "./components/StateList"; +import { StatePage } from "./components/StatePage"; +import { CopyButton } from "./components/CopyButton"; +import { ToastProvider, useToast } from "./components/Toast"; +import type { + ApiStatus, + ApiPeer, + ApiState, + ApiDiscoveredState, + ApiJoinResponse, +} from "./types"; const SETUP_PROMPT = `git clone https://github.com/selfmd/network.self.md.git cd network.self.md pnpm install && pnpm build -npx networkselfmd init --name my-agent -npx networkselfmd states`; +node packages/cli/dist/bin.js init --name my-agent +node packages/cli/dist/bin.js states`; function CopySetupButton() { - const toast = useToast(); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard.writeText(SETUP_PROMPT).then(() => { - setCopied(true); - toast(SETUP_PROMPT); - setTimeout(() => setCopied(false), 2000); - }); - }; - - return ; + return ( + + ); } function Chrome({ children }: { children: React.ReactNode }) { const route = useRoute(); const nav = [ - ['/', 'overview'], - ['/discover', 'public states'], - ['/wire', 'wire'], - ['/security', 'keys'], - ['/settings', 'settings'], + ["/operator", "overview"], + ["/operator/discover", "public states"], + ["/wire", "wire"], + ["/security", "keys"], + ["/settings", "settings"], ]; return (
- @@ -59,95 +76,144 @@ function Chrome({ children }: { children: React.ReactNode }) { } function routeFor(href: string) { - if (href === '/discover') return 'discover'; - if (href === '/wire') return 'wire'; - if (href === '/security') return 'security'; - if (href === '/settings') return 'settings'; - return 'home'; + if (href === "/operator/discover") return "operator-discover"; + if (href === "/wire") return "wire"; + if (href === "/security") return "security"; + if (href === "/settings") return "settings"; + return "operator"; } function HeroStats({ status }: { status: ApiStatus | null }) { const caps = status?.capabilities; return (
-
agents online{status?.peersOnline ?? '—'}{status?.peersTotal ?? 0} known
-
states{status?.stateCount ?? '—'}joined + discovered
-
sync{status?.syncPct ?? 100}%local node
-
capabilities{caps?.discovery ? 'discovery' : 'local'}{caps?.wireTrace ? 'wire trace live' : 'wire trace gated'}
+
+ agents online + {status?.peersOnline ?? "—"} + {status?.peersTotal ?? 0} known +
+
+ states + {status?.stateCount ?? "—"} + joined + discovered +
+
+ sync + {status?.syncPct == null ? "—" : `${status.syncPct}%`} + {status?.syncPct == null ? "measurement unavailable" : "local node"} +
+
+ capabilities + {caps?.discovery ? "discovery" : "local"} + {caps?.wireTrace ? "wire trace live" : "wire trace gated"} +
); } function HomePage() { - const { data: status, error } = usePolling('/api/status'); - const { data: peers } = usePolling('/api/peers'); - const { data: states } = usePolling('/api/states'); - const { data: discovered } = usePolling('/api/discovery/states'); + const { data: status, error } = usePolling("/api/status"); + const { data: peers } = usePolling("/api/peers"); + const { data: states } = usePolling("/api/states"); + const { data: discovered } = usePolling( + "/api/discovery/states", + ); const topStates = useMemo(() => (states ?? []).slice(0, 5), [states]); return (
-
-
node://local/overviewlive
-
-
-
sovereign agent network
-

context that moves
without a platform.

-

A local control plane for encrypted states, public discovery and agent-to-agent rooms. Your identity stays a keypair. Your context stays yours.

- -
- 01 local-first - 02 end-to-end - 03 protocol-native +
+
+ + + + /mesh/live +
+
+
+
encrypted p2p network state
+

+ agents dial agents. +
+ state moves on the wire. +

+

+ A local dashboard for sovereign shared context: states, public + states, rooms and agents. Protocol, not platform. +

+
-
-
- {error &&
daemon unavailable · {error}
} + {error && ( +
+ daemon unavailable · {error} +
+ )}
-
states discover ↗
+
+ states{" "} + discover ↗ +
-
agents
+
+ agents +
-
public state radar
+
+ public state radar +
{(discovered?.length ?? 0) === 0 ? ( -
No public states discovered yet. Keep the node online; gossip needs peers.
+
+ No public states discovered yet. Keep the node online; gossip + needs peers. +
) : ( )}
-
wire inspector
-
Wire trace is deliberately gated until the backend exposes structured events. No fake packet theatre in prod.
+
+ wire inspector +
+
+ Wire trace is deliberately gated until the backend exposes + structured events. No fake packet theatre in prod. +
@@ -155,21 +221,49 @@ function HomePage() { } function DiscoveryPage() { - const { data: status } = usePolling('/api/status'); - const { data: discovered, error } = usePolling('/api/discovery/states', 7000); + const { data: status } = usePolling("/api/status"); + const { data: discovered, error } = usePolling( + "/api/discovery/states", + 7000, + ); const toast = useToast(); const [joining, setJoining] = useState(null); + const [joinError, setJoinError] = useState<{ + id: string; + message: string; + } | null>(null); async function joinState(id: string) { + if (joining !== null) return; setJoining(id); + setJoinError(null); try { - const res = await fetch(`/api/discovery/states/${encodeURIComponent(id)}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); - const body = (await res.json()) as ApiJoinResponse; - if (!body.ok) throw new Error(body.message); + const res = await fetch( + `/api/discovery/states/${encodeURIComponent(id)}/join`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + const body = (await res.json()) as + ApiJoinResponse | { error?: { message?: string } }; + if (!res.ok || !("ok" in body) || !body.ok) { + const message = + "message" in body + ? body.message + : "error" in body + ? body.error?.message + : undefined; + throw new Error(message ?? `Could not join state (${res.status})`); + } toast(`joined state: ${body.state.name}`); window.location.hash = `/state/${encodeURIComponent(body.state.id)}`; - } catch (err: any) { - toast(`join failed: ${err.message ?? err}`); + } catch (err: unknown) { + setJoinError({ + id, + message: err instanceof Error ? err.message : String(err), + }); } finally { setJoining(null); } @@ -178,58 +272,130 @@ function DiscoveryPage() { return (
-
/discover/public-states
+
+ + + + /discover/public-states +
public state discovery

read the manifesto before you join.

-

Public states are discoverable encrypted contexts with a self.md contract. Join only what you actually want your agents to inhabit.

+

+ Public states are discoverable encrypted contexts with a self.md + contract. Join only what you actually want your agents to inhabit. +

- {error &&
discovery unavailable · {error}
} - {status?.capabilities?.discovery === false &&
this node reports discovery disabled
} + {error && ( +
discovery unavailable · {error}
+ )} + {status?.capabilities?.discovery === false && ( +
this node reports discovery disabled
+ )}
{(discovered ?? []).map((s) => (
-
{s.name}public state
-
{s.selfMd ?? 'No self.md manifesto announced yet.'}
-
{s.memberCount} agents
+
+ {s.name} + public state +
+
+              {s.selfMd ?? "No self.md manifesto announced yet."}
+            
+
+ {s.memberCount} agents + +
+ {joinError?.id === s.id && ( +
+ join failed · {joinError.message} +
+ )}
))} - {discovered && discovered.length === 0 &&
No public states found yet. That is a real network state, not a fake empty-table tragedy.
} + {discovered && discovered.length === 0 && ( +
+ No public states found yet. That is a real network state, not a fake + empty-table tragedy. +
+ )}
); } function WirePage() { - const { data: status } = usePolling('/api/status'); + const { data: status } = usePolling("/api/status"); const enabled = status?.capabilities?.wireTrace; return (
-
/wire/events
+
+ + + + /wire/events +
wire inspector
-

{enabled ? 'wire trace online.' : 'trace unavailable.'}

-

This surface only renders structured wire events. Until the daemon exposes them, the UI refuses to hallucinate traffic.

+

{enabled ? "wire trace online." : "trace unavailable."}

+

+ This surface only renders structured wire events. Until the daemon + exposes them, the UI refuses to hallucinate traffic. +

+
+
+
+ Backend capability wireTrace is{" "} + {enabled ? "enabled" : "disabled"}. +
-
Backend capability wireTrace is {enabled ? 'enabled' : 'disabled'}.
); } function SecurityPage() { - const { data: status } = usePolling('/api/status'); + const { data: status } = usePolling("/api/status"); const caps = status?.capabilities; return (
-
/security/keys
+
+ + + + /security/keys +
keys

identity is a keypair.

-

Key rotation, revoke and export controls stay disabled until the daemon exposes the underlying operations.

+

+ Key rotation, revoke and export controls stay disabled until the + daemon exposes the underlying operations. +

-
key rotation
-
device revoke
-
identity export
+
+
key rotation
+ +
+
+
device revoke
+ +
+
+
identity export
+ +
); @@ -239,10 +405,19 @@ function SettingsPage() { return (
-
/settings/local
+
+ + + + /settings/local +
local node settings

loopback first. no surprise platform.

-

The dashboard is intended for a local daemon bound to 127.0.0.1. Exposing it publicly requires VPN, Access, or a real reverse-proxy auth decision.

+

+ The dashboard is intended for a local daemon bound to 127.0.0.1. + Exposing it publicly requires VPN, Access, or a real reverse-proxy + auth decision. +

); @@ -250,18 +425,28 @@ function SettingsPage() { function Router() { const route = useRoute(); + if ( + route.page === "home" || + route.page === "discover" || + route.page === "selfmd" + ) + return ; return ( - {route.page === 'home' && } - {route.page === 'state' && } - {route.page === 'discover' && } - {route.page === 'wire' && } - {route.page === 'security' && } - {route.page === 'settings' && } + {route.page === "operator" && } + {route.page === "state" && } + {route.page === "operator-discover" && } + {route.page === "wire" && } + {route.page === "security" && } + {route.page === "settings" && } ); } export function App() { - return ; + return ( + + + + ); } diff --git a/packages/dashboard/src/client/__tests__/actions.test.tsx b/packages/dashboard/src/client/__tests__/actions.test.tsx new file mode 100644 index 0000000..8d7a2d1 --- /dev/null +++ b/packages/dashboard/src/client/__tests__/actions.test.tsx @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { App } from '../App'; +import { CopyButton } from '../components/CopyButton'; +import { ToastProvider } from '../components/Toast'; +import { StateList } from '../components/StateList'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.location.hash = ''; +}); + +describe('clipboard actions', () => { + it('offers manual copying when clipboard access is denied', async () => { + vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'copy command' })); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', expect.stringContaining('Clipboard unavailable')); + expect(screen.getByRole('textbox', { name: 'copy command — copy manually' })).toHaveProperty('value', 'networkselfmd states'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('offers manual copying when the browser has no clipboard API', async () => { + vi.stubGlobal('navigator', {}); + render(); + fireEvent.click(screen.getByRole('button')); + expect(await screen.findByRole('textbox')).toHaveProperty('value', 'networkselfmd states'); + }); + + it('recovers from denied access and announces a successful copy', async () => { + const copy = vi.fn().mockRejectedValueOnce(new Error('denied')).mockResolvedValueOnce(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); + render(); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('alert'); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('button', { name: 'copied' }); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('status').textContent).toBe('Copied to clipboard.'); + expect(copy).toHaveBeenLastCalledWith('networkselfmd states'); + }); + + it('ignores clipboard completion after the command changes or unmounts', async () => { + let finish!: () => void; + const copy = vi.fn(() => new Promise((resolve) => { finish = resolve; })); + vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); + const { rerender, unmount } = render(); + fireEvent.click(screen.getByRole('button')); + rerender(); + await act(async () => { finish(); }); + expect(screen.getByRole('button').textContent).toBe('copy'); + expect(screen.queryByRole('status')).toBeNull(); + fireEvent.click(screen.getByRole('button')); + unmount(); + await act(async () => { finish(); }); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('copies discovery instructions for public states and invitation prerequisites for private states', async () => { + const copy = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); + render(); + const buttons = screen.getAllByRole('button', { name: 'copy join instructions' }); + await act(async () => { fireEvent.click(buttons[0]); }); + expect(copy.mock.calls[0][0]).toContain('discover_states'); + expect(copy.mock.calls[0][0]).toContain('join_public_state with {"stateId":"010203"}'); + await act(async () => { fireEvent.click(buttons[1]); }); + expect(copy.mock.calls[1][0]).toContain('Ask an admin to invite your connected agent first.'); + expect(copy.mock.calls[1][0]).toContain('npx --yes @networkselfmd/cli join-state 040506'); + }); +}); + +const discovered = [ + { id: '010203', name: 'builders', selfMd: 'Build together.', memberCount: 3, isPublic: true, lastActivity: 0 }, + { id: '040506', name: 'research', selfMd: 'Research together.', memberCount: 5, isPublic: true, lastActivity: 0 }, +]; + +function mockApi(post: () => Promise) { + const fetchMock = vi.fn(async (url: string, options?: RequestInit) => { + if (options?.method === 'POST') return post(); + return { ok: true, json: async () => { + if (url === '/api/discovery/states') return discovered; + if (url.startsWith('/api/states/')) { + return { ...discovered.find((state) => url.endsWith(state.id)), members: [], messages: [] }; + } + return { capabilities: { discovery: true } }; + } }; + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('public state joining', () => { + it('shows a server rejection beside the state and permits retry', async () => { + window.location.hash = '#/operator/discover'; + const post = vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({ error: { message: 'mutations require a localhost origin' } }) }); + mockApi(post); + render(); + const buttons = await screen.findAllByRole('button', { name: 'join state' }); + fireEvent.click(buttons[0]); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('mutations require a localhost origin'); + expect(alert.closest('article')?.textContent).toContain('builders'); + expect(screen.queryByRole('status')).toBeNull(); + expect(buttons[0]).toHaveProperty('disabled', false); + fireEvent.click(buttons[0]); + await waitFor(() => { expect(post).toHaveBeenCalledTimes(2); }); + }); + + it('prevents overlapping joins and navigates using the returned state ID', async () => { + window.location.hash = '#/operator/discover'; + let resolve!: (value: unknown) => void; + const pending = new Promise((done) => { resolve = done; }); + const post = vi.fn(() => pending); + const fetchMock = mockApi(post); + render(); + const buttons = await screen.findAllByRole('button', { name: 'join state' }); + fireEvent.click(buttons[0]); + expect(buttons[0]).toHaveProperty('disabled', true); + expect(buttons[1]).toHaveProperty('disabled', true); + fireEvent.click(buttons[1]); + expect(post).toHaveBeenCalledTimes(1); + await act(async () => { resolve({ ok: true, json: async () => ({ ok: true, state: discovered[0] }) }); }); + expect(window.location.hash).toBe('#/state/010203'); + await screen.findByRole('heading', { name: 'builders' }); + expect(fetchMock).toHaveBeenCalledWith('/api/discovery/states/010203/join', expect.objectContaining({ method: 'POST' })); + expect(screen.getByRole('status').textContent).toBe('joined state: builders'); + }); +}); + +describe('operator telemetry', () => { + it.each([null, undefined])('does not imply full synchronization when telemetry is %s', async (syncPct) => { + window.location.hash = '#/operator'; + vi.stubGlobal('fetch', vi.fn(async (url: string) => ({ ok: true, json: async () => url === '/api/status' ? { syncPct, capabilities: { discovery: true } } : [] }))); + render(); + await screen.findByText('measurement unavailable'); + expect(screen.queryByText('100%')).toBeNull(); + }); +}); diff --git a/packages/dashboard/src/client/__tests__/usePolling.test.ts b/packages/dashboard/src/client/__tests__/usePolling.test.ts new file mode 100644 index 0000000..a054728 --- /dev/null +++ b/packages/dashboard/src/client/__tests__/usePolling.test.ts @@ -0,0 +1,85 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePolling } from '../hooks/usePolling.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +const response = (value: unknown) => ({ ok: true, json: async () => value }) as Response; + +describe('usePolling request lifecycle', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('does not display the previous state while the next state loads', async () => { + const next = deferred(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(response('state A')).mockReturnValueOnce(next.promise)); + const { result, rerender } = renderHook(({ url }) => usePolling(url), { initialProps: { url: '/a' } }); + await act(async () => {}); + expect(result.current.data).toBe('state A'); + + rerender({ url: '/b' }); + expect(result.current.data).toBeNull(); + await act(async () => { next.resolve(response('state B')); }); + expect(result.current.data).toBe('state B'); + }); + + it('ignores a late response after navigation and aborts the old request', async () => { + const old = deferred(); + const fetchMock = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(response('state B')); + vi.stubGlobal('fetch', fetchMock); + const { result, rerender } = renderHook(({ url }) => usePolling(url), { initialProps: { url: '/a' } }); + rerender({ url: '/b' }); + await act(async () => {}); + await act(async () => { old.resolve(response('state A')); }); + expect(result.current.data).toBe('state B'); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + }); + + it('waits for a slow response before scheduling another request', async () => { + const slow = deferred(); + const fetchMock = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(response('updated')); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => usePolling('/a', 1000)); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(fetchMock).toHaveBeenCalledTimes(1); + await act(async () => { slow.resolve(response('initial')); }); + expect(result.current.data).toBe('initial'); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.current.data).toBe('updated'); + }); + + it('retains the last successful response on transient failure and recovers', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(response('initial')) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce(response('recovered'))); + const { result } = renderHook(() => usePolling('/a', 1000)); + await act(async () => {}); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(result.current).toEqual({ data: 'initial', error: '503' }); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(result.current).toEqual({ data: 'recovered', error: null }); + }); + + it('aborts requests and stops polling when unmounted', async () => { + const pending = deferred(); + const fetchMock = vi.fn().mockReturnValue(pending.promise); + vi.stubGlobal('fetch', fetchMock); + const { unmount } = renderHook(() => usePolling('/a', 1000)); + unmount(); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + await act(async () => { pending.resolve(response('late')); }); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/client/__tests__/useRoute.test.ts b/packages/dashboard/src/client/__tests__/useRoute.test.ts new file mode 100644 index 0000000..6b288ef --- /dev/null +++ b/packages/dashboard/src/client/__tests__/useRoute.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRoute } from '../hooks/useRoute.js'; + +describe('useRoute', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('defaults to home when hash is empty', () => { + window.location.hash = ''; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'home' }); + }); + + it('parses /discover', () => { + window.location.hash = '#/discover'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'discover' }); + }); + + it('parses /wire', () => { + window.location.hash = '#/wire'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'wire' }); + }); + + it('parses /security', () => { + window.location.hash = '#/security'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'security' }); + }); + + it('parses /settings', () => { + window.location.hash = '#/settings'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'settings' }); + }); + + it('parses /state/:id', () => { + window.location.hash = '#/state/abc123'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'abc123' }); + }); + + it('parses /states/:id (plural form)', () => { + window.location.hash = '#/states/def456'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'def456' }); + }); + + it('decodes percent-encoded state id', () => { + window.location.hash = '#/state/hello%20world'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'hello world' }); + }); + + it('falls back to home for unknown routes', () => { + window.location.hash = '#/unknown-page'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'home' }); + }); + + it('falls back to home for malformed percent encoding instead of crashing', () => { + window.location.hash = '#/state/%E0%A4%A'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'home' }); + }); + + it('responds to hashchange events', () => { + window.location.hash = '#/'; + const { result } = renderHook(() => useRoute()); + expect(result.current.page).toBe('home'); + + act(() => { + window.location.hash = '#/discover'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(result.current).toEqual({ page: 'discover' }); + }); +}); diff --git a/packages/dashboard/src/client/assets/fonts/anybody-LICENSE.txt b/packages/dashboard/src/client/assets/fonts/anybody-LICENSE.txt new file mode 100644 index 0000000..0a12f69 --- /dev/null +++ b/packages/dashboard/src/client/assets/fonts/anybody-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Anybody Project Authors (https://github.com/Etcetera-Type-Co/Anybody) Anybody-Italic[wdth,wght].ttf: Copyright 2020 The Anybody Project Authors (https://github.com/Etcetera-Type-Co/Anybody) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/dashboard/src/client/assets/fonts/anybody.woff2 b/packages/dashboard/src/client/assets/fonts/anybody.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4914a006e7e6016693ecd6d5271194e5af7ae660 GIT binary patch literal 24048 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 literal 0 HcmV?d00001 diff --git a/packages/dashboard/src/client/assets/fonts/bricolage-grotesque-LICENSE.txt b/packages/dashboard/src/client/assets/fonts/bricolage-grotesque-LICENSE.txt new file mode 100644 index 0000000..49135ef --- /dev/null +++ b/packages/dashboard/src/client/assets/fonts/bricolage-grotesque-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/dashboard/src/client/assets/fonts/bricolage-grotesque.woff2 b/packages/dashboard/src/client/assets/fonts/bricolage-grotesque.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..82152a40802e0646b7a732744394a02c8c72772f GIT binary patch literal 41344 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∾Na#*TBTU(!jF?9zmR-!C*+RdvGrd0&{`| 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 + const generation = useRef(0); + + useEffect(() => { + setCopied(false); + setError(false); + return () => { + generation.current += 1; + clearTimeout(timer.current); + }; + }, [text]); + + async function copy(event: React.MouseEvent) { + event.stopPropagation(); + const request = ++generation.current; + clearTimeout(timer.current); + setCopied(false); + setError(false); + try { + await navigator.clipboard.writeText(text); + if (request !== generation.current) return; + setCopied(true); + toast('Copied to clipboard.'); + timer.current = setTimeout(() => setCopied(false), 2000); + } catch { + if (request === generation.current) setError(true); + } + } + + return ( + <> + + {error && ( +

+

Clipboard unavailable. Select and copy the text below.

+ - +
+
+ + +
- +
-