diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5f23a1d..92f8b87 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,7 +16,6 @@ body: label: Affected package options: - "@authmesh/cli" - - "@authmesh/agent" - "@authmesh/sdk" - "@authmesh/core" - "@authmesh/keystore" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 72280d0..a85f8cc 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -27,7 +27,6 @@ body: label: Related package options: - "@authmesh/cli" - - "@authmesh/agent" - "@authmesh/sdk" - "@authmesh/core" - "@authmesh/keystore" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 27feb55..41ba421 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,7 @@ jobs: - run: bun run test - run: | - for pkg in packages/core packages/keystore packages/cli packages/sdk packages/relay packages/agent; do + for pkg in packages/core packages/keystore packages/cli packages/sdk packages/relay; do (cd $pkg && npm publish --no-git-checks --access public) || echo "Skipped $pkg (already published or error)" done env: diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 4d1df8d..26af69f 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -178,7 +178,7 @@ jobs: def install bin.install "amesh" - bin.install "amesh-agent" if File.exist?("amesh-agent") + bin.install "amesh" if File.exist?("amesh") bin.install "amesh-se-helper" if File.exist?("amesh-se-helper") end diff --git a/CHANGELOG.md b/CHANGELOG.md index f418774..e443d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Install script** served at `authmesh.dev/install` — one-liner install for headless devices: `curl -fsSL https://authmesh.dev/install | sh` - **arm64 .deb package** — release workflow now produces both `amd64` and `arm64` Debian packages -- **amesh-agent in .deb** — the `.deb` package now includes both `amesh` and `amesh-agent` binaries +- **amesh-agent in .deb** — the `.deb` package now includes both `amesh` and `amesh` binaries - **Blog post** — "Your AI just wrote another .env file" ### Changed @@ -82,11 +82,11 @@ Full external-pen-tester-style security audit. All findings (2 critical, 4 high, ### Added -- **Prebuilt `amesh-agent` binaries** for macOS arm64/x64 and Linux x64/arm64, built by the release pipeline and shipped in the same `amesh-{version}-{platform}-{arch}.tar.gz` tarball as the main `amesh` binary. Fixes the "install fails on every platform" issue — `npm install -g @authmesh/agent` and `brew install ameshdev/tap/amesh` now both produce a working `amesh-agent` without the `bun $(which amesh-agent) ...` wrapper. Raspberry Pi 3 and earlier (armv7, 32-bit Pi OS) remain unsupported because Bun itself does not ship for that architecture. +- **Prebuilt `amesh` binaries** for macOS arm64/x64 and Linux x64/arm64, built by the release pipeline and shipped in the same `amesh-{version}-{platform}-{arch}.tar.gz` tarball as the main `amesh` binary. Fixes the "install fails on every platform" issue — `npm install -g @authmesh/cli` and `brew install ameshdev/tap/amesh` now both produce a working `amesh` without the `bun $(which amesh-agent) ...` wrapper. Raspberry Pi 3 and earlier (armv7, 32-bit Pi OS) remain unsupported because Bun itself does not ship for that architecture. - **`linux-arm64` target** added to the release matrix (cross-compiled from `ubuntu-latest` via Bun's `--target` flag). Unblocks Raspberry Pi 4/5 on 64-bit Pi OS and ARM cloud VMs. -- **npm postinstall binary downloader** (`packages/agent/scripts/postinstall.mjs`) detects host platform and arch, downloads the matching prebuilt binary from the GitHub release, extracts it into `node_modules/@authmesh/agent/bin/amesh-agent`, and chmods it executable. Unsupported architectures (e.g. armv7) get a clear "install Bun and use wrapper" message and a graceful exit-0 so `npm install` never fails. +- **npm postinstall binary downloader** (`packages/agent/scripts/postinstall.mjs`) detects host platform and arch, downloads the matching prebuilt binary from the GitHub release, extracts it into `node_modules/@authmesh/cli/bin/amesh-agent`, and chmods it executable. Unsupported architectures (e.g. armv7) get a clear "install Bun and use wrapper" message and a graceful exit-0 so `npm install` never fails. - **Launcher script** (`packages/agent/scripts/launcher.mjs`) — the new `bin` entry that execs the prebuilt binary when present and falls back to the JS oclif entry otherwise. Same pattern esbuild/swc use. -- **Compiled-binary entry point** for the agent (`packages/agent/src/sea.ts`) — mirrors `packages/cli/src/sea.ts` with explicit nested-command dispatch for `amesh-agent agent start` since the CLI sea.ts only handled top-level commands. +- **Compiled-binary entry point** for the agent (`packages/agent/src/sea.ts`) — mirrors `packages/cli/src/sea.ts` with explicit nested-command dispatch for `amesh agent start` since the CLI sea.ts only handled top-level commands. - **Docs IA restructure (vite.dev / bun.com style)** on the landing page: nested sidebar sections (Introduction / Getting Started / Guides / Reference / Packages), redesigned `/docs` landing with feature-grid layout, cross-section prev/next navigation. - **Five new doc pages**: `/docs/introduction`, `/docs/quickstart`, `/docs/faq`, `/docs/troubleshooting`, `/docs/changelog`. - **`/blog` section** with two seed posts: "Why we built amesh" (essay) and "Introducing amesh 0.3" (release notes). Includes `BlogPosting` JSON-LD schema and proper `article:*` Open Graph meta. @@ -96,23 +96,23 @@ Full external-pen-tester-style security audit. All findings (2 critical, 4 high, ### Changed -- **`@authmesh/agent` oclif config** — added `topicSeparator: " "` so `amesh-agent agent start` (space-separated) resolves correctly under the JS fallback path. Previously only colon syntax (`agent:start`) worked under Node, which meant users hitting the fallback path saw topic help instead of the Bun runtime guard error. -- **`packaging/build-bun.mjs`** refactored to a shared `compile()` helper invoked once for `amesh` and once for `amesh-agent`. Both binaries land in `packaging/dist/` and are packed into the same release tarball. -- **Homebrew formula** (`packaging/homebrew/amesh.rb` + the heredoc in `release-packages.yml`) installs `amesh-agent` alongside `amesh` from a single `brew install ameshdev/tap/amesh`. Added the `on_linux/on_arm` block for the new linux-arm64 target. +- **`@authmesh/cli` oclif config** — added `topicSeparator: " "` so `amesh agent start` (space-separated) resolves correctly under the JS fallback path. Previously only colon syntax (`agent:start`) worked under Node, which meant users hitting the fallback path saw topic help instead of the Bun runtime guard error. +- **`packaging/build-bun.mjs`** refactored to a shared `compile()` helper invoked once for `amesh` and once for `amesh`. Both binaries land in `packaging/dist/` and are packed into the same release tarball. +- **Homebrew formula** (`packaging/homebrew/amesh.rb` + the heredoc in `release-packages.yml`) installs `amesh` alongside `amesh` from a single `brew install ameshdev/tap/amesh`. Added the `on_linux/on_arm` block for the new linux-arm64 target. - **Hero chip** on the landing page: `Production ready` → `Beta`. The 0.3.x line is a pre-1.0 product and "production ready" was overclaiming. - **Comparison table** on the landing page: `Vault` column renamed to `Secrets Manager`. Matches the genericize-company-names policy below. - **Genericize company mentions across prose.** Removed `AWS Secrets Manager`, `HashiCorp Vault`, `Doppler`, `Uber`, `Samsung`, `Toyota`, `Twitch`, `Ethereum`, `Signal`, `Cloudflare` from the landing page, blog, `/docs/introduction`, `/docs/faq`, and `docs/why-amesh.md`. The technical critique of the secrets-manager pattern is preserved; the name-dropping is gone. Product/infrastructure names required for instructions (Cloud Run, Homebrew, npm, Bun, TPM, Secure Enclave, etc.) are kept. - **Flattened navigation** — removed the Use Cases dropdown. Nav is now a flat list: `Docs | Use Cases | Blog | GitHub | Get Started`. - **Hero visual upgrades** — trust strip below hero with `@noble/*` dependency credits, card lift hovers with shadow on all feature/replace/CTA cards, tightened h1 typography. -- **ADR-011** (`docs/architecture-decisions.md`) — marked partially superseded. The "one package, one binary" design was reversed in favor of splitting the agent into `@authmesh/agent`. Runtime-dependency argument (`Bun.spawn({ terminal })` is Bun-only) outweighed the install-confusion concern once prebuilt binaries landed. +- **ADR-011** (`docs/architecture-decisions.md`) — marked partially superseded. The "one package, one binary" design was reversed in favor of splitting the agent into `@authmesh/cli`. Runtime-dependency argument (`Bun.spawn({ terminal })` is Bun-only) outweighed the install-confusion concern once prebuilt binaries landed. ### Fixed -- **`/docs/remote-shell` showed phantom commands and install methods.** The page displayed `amesh agent start` (command doesn't exist — it's `amesh-agent agent start` from a separate package), referenced `brew install ameshdev/tap/amesh-agent` (no such formula), and linked to an `amesh-agent-linux-x64.tar.gz` download (never built). All corrected, plus a Runtime Requirement callout was added until binaries are verified in the first tagged release. +- **`/docs/remote-shell` showed phantom commands and install methods.** The page displayed `amesh agent start` (command doesn't exist — it's `amesh agent start` from a separate package), referenced `brew install ameshdev/tap/amesh-agent` (no such formula), and linked to an `amesh-agent-linux-x64.tar.gz` download (never built). All corrected, plus a Runtime Requirement callout was added until binaries are verified in the first tagged release. - **`TableOfContents` magic-number margin** — replaced hardcoded `margin-left: 44rem` with a responsive `left: min(calc(50vw + 30rem), calc(100vw - 13.5rem))` calc, gated on the `2xl` breakpoint where there's actually room for a right-rail TOC. Between `lg` and `2xl`, the collapsible mobile-style TOC takes over instead of overlapping content. - **Sitemap missing `/docs/key-storage`** — added, along with all new routes. - **`docs/remote-shell-spec.md` and `docs/why-amesh.md`** updated to match the agent package split and the genericize-company-names policy. -- **`packages/cli/README.md`** — removed the stale `amesh agent start` command listing (that command lives in `@authmesh/agent`, not `@authmesh/cli`) and added a pointer to the separate agent package. +- **`packages/cli/README.md`** — removed the stale `amesh agent start` command listing (that command lives in `@authmesh/cli`, not `@authmesh/cli`) and added a pointer to the separate agent package. - **`packages/agent/README.md`** — dropped the phantom Homebrew tap reference, documented the postinstall binary download, listed the four supported platforms and the armv7 exclusion. ## [0.3.3] - 2026-04-04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4687ad..a22dfe4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ packages/ core/ — crypto primitives (sign, verify, HMAC, HKDF, ECDH) keystore/ — key storage drivers (Secure Enclave, TPM, encrypted file) cli/ — amesh CLI (oclif) - agent/ — amesh-agent daemon + remote shell + cli/ — unified amesh CLI + agent daemon sdk/ — signing fetch client + verification middleware relay/ — WebSocket relay for device pairing ``` diff --git a/README.md b/README.md index c00fc19..32e6740 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,7 @@ app.use(amesh.verify()); | Package | Description | |---------|-------------| | [`@authmesh/sdk`](./packages/sdk) | Signing fetch client + Express verification middleware | -| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell` | -| [`@authmesh/agent`](./packages/agent) | Agent daemon + full CLI: all CLI commands + `agent start` | +| [`@authmesh/cli`](./packages/cli) | CLI + agent: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell`, `agent start/stop`, `reset` | | [`@authmesh/core`](./packages/core) | Crypto primitives: sign, verify, canonical string, nonce, HMAC, HKDF, ECDH | | [`@authmesh/keystore`](./packages/keystore) | Key storage drivers: Secure Enclave, macOS Keychain, TPM 2.0, encrypted file | | [`@authmesh/relay`](./packages/relay) | WebSocket relay for device pairing handshakes | diff --git a/bun.lock b/bun.lock index b72e314..6f58578 100644 --- a/bun.lock +++ b/bun.lock @@ -47,32 +47,9 @@ "vite": "^7.3.1", }, }, - "packages/agent": { - "name": "@authmesh/agent", - "version": "0.3.3", - "bin": { - "amesh-agent": "./dist/index.js", - }, - "dependencies": { - "@authmesh/core": "workspace:*", - "@authmesh/keystore": "workspace:*", - "@noble/ciphers": "2.1.1", - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@oclif/core": "^4.0.0", - }, - "devDependencies": { - "@eslint/js": "^9.0.0", - "@types/bun": "^1.3.0", - "@types/node": "^22.0.0", - "eslint": "^9.0.0", - "typescript": "^5.7.0", - "typescript-eslint": "^8.0.0", - }, - }, "packages/cli": { "name": "@authmesh/cli", - "version": "0.3.3", + "version": "0.5.3", "bin": { "amesh": "./dist/index.js", }, @@ -95,7 +72,7 @@ }, "packages/core": { "name": "@authmesh/core", - "version": "0.3.3", + "version": "0.5.3", "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", @@ -110,7 +87,7 @@ }, "packages/keystore": { "name": "@authmesh/keystore", - "version": "0.3.3", + "version": "0.5.3", "dependencies": { "@authmesh/core": "workspace:*", "@noble/ciphers": "2.1.1", @@ -127,7 +104,7 @@ }, "packages/relay": { "name": "@authmesh/relay", - "version": "0.3.3", + "version": "0.5.3", "dependencies": { "@authmesh/core": "workspace:*", }, @@ -143,7 +120,7 @@ }, "packages/sdk": { "name": "@authmesh/sdk", - "version": "0.3.3", + "version": "0.5.3", "dependencies": { "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", @@ -164,8 +141,6 @@ }, }, "packages": { - "@authmesh/agent": ["@authmesh/agent@workspace:packages/agent"], - "@authmesh/cli": ["@authmesh/cli@workspace:packages/cli"], "@authmesh/core": ["@authmesh/core@workspace:packages/core"], diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index f01387e..fecc5c1 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -167,9 +167,9 @@ The controller CLI displays this code; the target CLI prompts the operator to en ## ADR-011: Remote shell in the CLI with explicit shell permission -> **Status: Partially superseded (2026-04-05).** The single-package design was reversed: the agent daemon now ships in a separate `@authmesh/agent` package exposing an `amesh-agent` binary, while `@authmesh/cli` (`amesh`) keeps the controller-side commands (`init`, `list`, `invite`, `shell`, etc.) without the daemon. +> **Status: Partially superseded (2026-04-05).** The single-package design was reversed: the agent daemon now ships in a separate `@authmesh/cli` package exposing an `amesh` binary, while `@authmesh/cli` (`amesh`) keeps the controller-side commands (`init`, `list`, `invite`, `shell`, etc.) without the daemon. > -> **Why the split:** the daemon uses `Bun.spawn({ terminal })` for PTY support, a Bun-only API. Bundling it with `@authmesh/cli` forced the entire controller install to depend on Bun even for users who only wanted `amesh init` + `amesh.fetch()`. Splitting lets `@authmesh/cli` ship a Node-compatible CLI and `@authmesh/agent` ship a Bun-dependent (or prebuilt-binary-only) daemon. The per-architecture prebuilt binaries are produced by the same release pipeline, so end users `brew install ameshdev/tap/amesh` to get both. +> **Why the split:** the daemon uses `Bun.spawn({ terminal })` for PTY support, a Bun-only API. Bundling it with `@authmesh/cli` forced the entire controller install to depend on Bun even for users who only wanted `amesh init` + `amesh.fetch()`. Splitting lets `@authmesh/cli` ship a Node-compatible CLI and `@authmesh/cli` ship a Bun-dependent (or prebuilt-binary-only) daemon. The per-architecture prebuilt binaries are produced by the same release pipeline, so end users `brew install ameshdev/tap/amesh` to get both. > > **The security argument below is unchanged:** `amesh grant --shell` is still the real boundary, not the package boundary. The split is purely a runtime-dependency concern. @@ -181,7 +181,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en 2. **Explicit consent:** Pairing for API authentication (`amesh invite`) does not grant shell access. A `permissions.shell` flag in the allow list defaults to `false`. The target admin must explicitly run `amesh grant --shell`. This is the security boundary, not the package boundary. -3. **The daemon is opt-in by invocation:** `amesh-agent agent start` must be explicitly run. It doesn't auto-start, doesn't install as a service, and refuses to run as root without `--allow-root`. +3. **The daemon is opt-in by invocation:** `amesh agent start` must be explicitly run. It doesn't auto-start, doesn't install as a service, and refuses to run as root without `--allow-root`. **Security design choices:** @@ -194,7 +194,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en - **Per-controller session limits** — prevents DoS by authorized-but-misbehaving peers **Rejected alternatives (at the time of the original decision):** -- ~~Separate `@authmesh/agent` package — adds install confusion without meaningful security benefit; the permission gate (`amesh grant --shell`) is the real security boundary, not the package boundary~~ *This was later reversed — see the Status note above. The runtime-dependency concern (Bun for PTY) outweighed the install-confusion concern once prebuilt binaries were shipped via the release pipeline.* +- ~~Separate `@authmesh/cli` package — adds install confusion without meaningful security benefit; the permission gate (`amesh grant --shell`) is the real security boundary, not the package boundary~~ *This was later reversed — see the Status note above. The runtime-dependency concern (Bun for PTY) outweighed the install-confusion concern once prebuilt binaries were shipped via the release pipeline.* - Auto-granting shell on pairing — violates principle of least privilege - Reusing pairing handshake's random-nonce encryption — birthday-bound risk over long sessions - Session resumption — complexity and nonce-reuse risk outweigh the latency benefit diff --git a/docs/remote-shell-security-review.md b/docs/remote-shell-security-review.md index 03424e9..7256c01 100644 --- a/docs/remote-shell-security-review.md +++ b/docs/remote-shell-security-review.md @@ -146,7 +146,7 @@ This is the same traffic analysis SSH faces over any network. It's not fixable w **Not in spec.** -**Problem:** The agent logs `[amesh-agent] shell opened by am_3d9f — command: uptime`. If the command contains ANSI escape sequences, it could corrupt log files or exploit log viewers (terminal escape injection). +**Problem:** The agent logs `[amesh agent] shell opened by am_3d9f — command: uptime`. If the command contains ANSI escape sequences, it could corrupt log files or exploit log viewers (terminal escape injection). **Fix:** Sanitize the logged command string (strip non-printable characters, truncate to 200 chars). diff --git a/docs/remote-shell-spec.md b/docs/remote-shell-spec.md index deedb9b..969b867 100644 --- a/docs/remote-shell-spec.md +++ b/docs/remote-shell-spec.md @@ -40,7 +40,7 @@ amesh already solves these problems for HTTP APIs. The remote shell extends the ``` Controller (laptop) Relay Target (server) ───────────────── ───── ─────────────── -amesh shell am_7f2e amesh-agent agent (daemon) +amesh shell am_7f2e amesh agent (daemon) │ │ │ │──── { type: 'shell', otc } ─────►│ │ │ │◄── { type: 'listen' } ────│ (agent is always connected) @@ -85,7 +85,7 @@ No new pairing ceremony is needed. If `amesh list` on the target shows the contr ## 5. Components -### 5.1 `amesh-agent agent` (target-side daemon) +### 5.1 `amesh agent` (target-side daemon) A long-running process on the target machine that: @@ -97,19 +97,19 @@ A long-running process on the target machine that: 6. Streams encrypted I/O between the PTY and the relay tunnel ```bash -amesh-agent agent start # start daemon (foreground) -amesh-agent agent start --daemon # start as background process -amesh-agent agent stop # stop the daemon -amesh-agent agent status # show running state + connected controllers +amesh agent start # start daemon (foreground) +amesh agent start --daemon # start as background process +amesh agent stop # stop the daemon +amesh agent status # show running state + connected controllers ``` -> **Note:** The daemon ships in a separate npm package (`@authmesh/agent`), installed on the target. The controller-side `amesh` command from `@authmesh/cli` does not include `agent start`. See [ADR: remote shell packaging](./architecture-decisions.md) for the rationale. +> **Note:** The daemon ships in a separate npm package (`@authmesh/cli`), installed on the target. The controller-side `amesh` command from `@authmesh/cli` does not include `agent start`. See [ADR: remote shell packaging](./architecture-decisions.md) for the rationale. **Daemon lifecycle:** - Reconnects to relay on disconnect (exponential backoff: 1s, 2s, 4s, ..., max 30s) - Heartbeat every 30 seconds to keep WebSocket alive - Supports multiple concurrent shell sessions (one per controller) -- Logs all shell connections: `[amesh-agent] shell opened by am_3d9f (alice-macbook)` +- Logs all shell connections: `[amesh agent] shell opened by am_3d9f (alice-macbook)` ### 5.2 `amesh shell` (controller-side CLI) @@ -271,8 +271,8 @@ With a 12-byte incrementing nonce and the high-bit split, each side can send 2^9 ### Starting the agent (target) ``` -$ amesh-agent agent start - amesh-agent listening on relay.authmesh.dev +$ amesh agent start + amesh agent listening on relay.authmesh.dev Device: am_7f2e8a1b (prod-api) Authorized controllers: 2 @@ -308,7 +308,7 @@ $ echo $? ``` $ amesh shell prod-api Error: agent not connected for prod-api (am_7f2e8a1b). - Start the agent on the target: amesh-agent agent start + Start the agent on the target: amesh agent start ``` --- @@ -328,7 +328,7 @@ $ amesh shell prod-api - Tests: handshake succeeds for paired devices, fails for unknown devices ### Phase 3 — Target agent daemon -- `amesh-agent agent start` command (oclif, shipped via `@authmesh/agent`) +- `amesh agent start` command (oclif, shipped via `@authmesh/cli`) - Persistent relay connection with reconnect - PTY spawning via `Bun.spawn({ terminal: ... })` - Encrypted I/O streaming (frame protocol) diff --git a/landpage/src/routes/docs/quickstart/+page.svelte b/landpage/src/routes/docs/quickstart/+page.svelte index 7778b59..2e4d53e 100644 --- a/landpage/src/routes/docs/quickstart/+page.svelte +++ b/landpage/src/routes/docs/quickstart/+page.svelte @@ -66,7 +66,7 @@ brew install ameshdev/tap/amesh bun add @authmesh/sdk`} />
- # Installs amesh + amesh-agent — no runtime needed + # Installs amesh CLI — no runtime needed curl -fsSL https://authmesh.dev/install | sh`} />
diff --git a/landpage/src/routes/docs/remote-shell/+page.svelte b/landpage/src/routes/docs/remote-shell/+page.svelte index fbfad6e..4f5a3a1 100644 --- a/landpage/src/routes/docs/remote-shell/+page.svelte +++ b/landpage/src/routes/docs/remote-shell/+page.svelte @@ -11,11 +11,8 @@ const { prev, next } = getDocNav('remote-shell'); - // Install method tabs. The Homebrew formula installs both `amesh` and - // `amesh-agent` from a single tap, and the release tarballs contain both - // binaries, so the controller and server sides just extract different - // binaries from the same archive. The npm tab shows two separate packages - // because @authmesh/cli and @authmesh/agent are published independently. + // Install method tabs. The same `amesh` binary is used on both controller + // and server — `amesh agent start` enables target mode on the server. const installMethods = [ { label: 'Homebrew', @@ -25,7 +22,7 @@ { label: 'npm', controller: 'npm install -g @authmesh/cli', - server: 'npm install -g @authmesh/agent', + server: 'npm install -g @authmesh/cli', }, { label: 'Shell', @@ -92,7 +89,7 @@

Install

-

Two binaries: amesh for the controller (your laptop), amesh-agent for the server.

+

One binary: amesh for both controller (your laptop) and server. Run amesh agent start on the server to enable remote access.

@@ -169,20 +166,20 @@ amesh list

3. Start the agent

# On the target (server) — start the agent daemon -amesh-agent agent start +amesh agent start # Or with options -amesh-agent agent start --relay wss://relay.authmesh.dev/ws --idle-timeout 60`} /> +amesh agent start --relay wss://relay.authmesh.dev/ws --idle-timeout 60`} />

- Note the binary name: controller commands run through amesh; the agent daemon runs through amesh-agent. They are separate packages (@authmesh/cli and @authmesh/agent), but brew install ameshdev/tap/amesh installs both. + The same amesh binary handles both controller and agent commands. Install once, use amesh agent start to enable target mode.

Platform Support

-

The amesh-agent daemon ships as a prebuilt binary on all supported platforms — no runtime install needed.

+

The amesh daemon ships as a prebuilt binary on all supported platforms — no runtime install needed.

@@ -222,7 +219,7 @@ amesh-agent agent start --relay wss://relay.authmesh.dev/ws --idle-timeout 60`}

- Linux armv7 (Raspberry Pi 3 and earlier): Bun does not ship for 32-bit ARM. If you must run the agent on these devices, install Bun manually (if a third-party build is available for your arch) and run as bun $(which amesh-agent) agent start. Everything else (Pi 4/5 on 64-bit Pi OS, all modern ARM servers) is supported out of the box. + Linux armv7 (Raspberry Pi 3 and earlier): Bun does not ship for 32-bit ARM. If you must run the agent on these devices, install Bun manually (if a third-party build is available for your arch) and run as bun $(which amesh) agent start. Everything else (Pi 4/5 on 64-bit Pi OS, all modern ARM servers) is supported out of the box.

@@ -305,11 +302,11 @@ Filesystem Size Used Avail Use% Mounted on
"Handshake failed" / connection timeout
-
The agent is not running on the target. Start it with amesh-agent agent start and verify the relay is reachable from both sides.
+
The agent is not running on the target. Start it with amesh agent start and verify the relay is reachable from both sides.
"The agent daemon requires Bun runtime for PTY support" (armv7 only)
-
You're on an unsupported architecture (typically Raspberry Pi 3 or earlier, 32-bit Pi OS). The postinstall couldn't find a prebuilt binary for your arch and fell back to the JS entry, which needs Bun for PTY. If a Bun build exists for your arch, install it and run as bun $(which amesh-agent) agent start. On supported architectures (macOS arm64/x64, Linux x64/arm64) this error should not appear — if it does, see the Troubleshooting page for the full diagnostic flow.
+
You're on an unsupported architecture (typically Raspberry Pi 3 or earlier, 32-bit Pi OS). The postinstall couldn't find a prebuilt binary for your arch and fell back to the JS entry, which needs Bun for PTY. If a Bun build exists for your arch, install it and run as bun $(which amesh) agent start. On supported architectures (macOS arm64/x64, Linux x64/arm64) this error should not appear — if it does, see the Troubleshooting page for the full diagnostic flow.
"Refusing to run as root"
diff --git a/landpage/src/routes/docs/troubleshooting/+page.svelte b/landpage/src/routes/docs/troubleshooting/+page.svelte index 77544bf..718abe8 100644 --- a/landpage/src/routes/docs/troubleshooting/+page.svelte +++ b/landpage/src/routes/docs/troubleshooting/+page.svelte @@ -143,7 +143,7 @@
"The agent daemon requires Bun runtime for PTY support" (unsupported architectures only)

- You should never see this on macOS (arm64/x64) or Linux (x64/arm64) — the npm postinstall downloads a prebuilt binary that bundles Bun, and amesh-agent agent start runs directly. If you do see it on a supported platform, the postinstall probably couldn't reach GitHub releases — check the install log for download errors and re-run npm rebuild @authmesh/agent with network access. + You should never see this on macOS (arm64/x64) or Linux (x64/arm64) — the npm postinstall downloads a prebuilt binary that bundles Bun, and amesh agent start runs directly. If you do see it on a supported platform, the postinstall probably couldn't reach GitHub releases — check the install log for download errors and re-run npm rebuild @authmesh/cli with network access.

On unsupported architectures (Raspberry Pi 3 and earlier, armv7 32-bit Pi OS), the postinstall falls back to the JS entry and the agent needs Bun for PTY. Bun does not ship for armv7, so you'd need a third-party Bun build. Most users should move to Pi 4/5 on 64-bit Pi OS or a different ARM host. @@ -151,7 +151,7 @@

"Handshake failed" / connection timeout
-

The agent is not running on the target. Start it with amesh-agent agent start, and verify the relay is reachable from both sides (port 443 or whatever your self-hosted relay uses).

+

The agent is not running on the target. Start it with amesh agent start, and verify the relay is reachable from both sides (port 443 or whatever your self-hosted relay uses).

"Refusing to run as root"
diff --git a/landpage/src/routes/use-cases/remote-shell/+page.svelte b/landpage/src/routes/use-cases/remote-shell/+page.svelte index 456f333..7963a5d 100644 --- a/landpage/src/routes/use-cases/remote-shell/+page.svelte +++ b/landpage/src/routes/use-cases/remote-shell/+page.svelte @@ -48,8 +48,8 @@ brew install ameshdev/tap/amesh # or: npm install -g @authmesh/cli # On the server (target) — agent daemon + all CLI commands -brew install ameshdev/tap/amesh-agent -# or: npm install -g @authmesh/agent` }, +brew install ameshdev/tap/amesh +# or: npm install -g @authmesh/cli` }, { filename: 'Terminal (target)', code: `# On the server — start the agent daemon $ amesh agent start diff --git a/landpage/static/install-agent b/landpage/static/install-agent index 92fb39f..1d55c4f 100644 --- a/landpage/static/install-agent +++ b/landpage/static/install-agent @@ -1,146 +1,20 @@ #!/bin/sh -# Install script for amesh-agent — the target daemon for remote shell. -# Usage: curl -fsSL https://authmesh.dev/install-agent | sh +# amesh-agent is now part of the main amesh binary. +# Redirecting to the unified install script. # -# Detects OS and architecture, downloads the latest release from GitHub, -# and installs amesh-agent into /usr/local/bin (or ~/.local/bin). -# -# Environment variables: -# AMESH_VERSION — install a specific version (e.g., "0.5.2") -# AMESH_INSTALL — custom install directory (default: /usr/local/bin or ~/.local/bin) - -set -e - -REPO="ameshdev/amesh" -BASE_URL="https://github.com/${REPO}/releases" - -# --- Helpers --- - -log() { - printf '%s\n' "$1" -} - -err() { - printf 'Error: %s\n' "$1" >&2 - exit 1 -} - -need() { - command -v "$1" >/dev/null 2>&1 || err "required command not found: $1" -} - -# --- Detect platform --- - -detect_os() { - case "$(uname -s)" in - Linux*) echo "linux" ;; - Darwin*) echo "darwin" ;; - *) err "unsupported OS: $(uname -s)" ;; - esac -} - -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "x64" ;; - aarch64|arm64) echo "arm64" ;; - *) err "unsupported architecture: $(uname -m)" ;; - esac -} - -# --- Resolve version --- - -resolve_version() { - if [ -n "${AMESH_VERSION}" ]; then - echo "${AMESH_VERSION}" - return - fi - - # Fetch latest release tag from GitHub API - need curl - tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name"' | head -1 | sed 's/.*"v\(.*\)".*/\1/') - [ -n "$tag" ] || err "could not determine latest version" - echo "$tag" -} - -# --- Choose install directory --- - -choose_install_dir() { - if [ -n "${AMESH_INSTALL}" ]; then - echo "${AMESH_INSTALL}" - return - fi - - if [ -w /usr/local/bin ]; then - echo "/usr/local/bin" - elif command -v sudo >/dev/null 2>&1; then - echo "/usr/local/bin" - else - mkdir -p "${HOME}/.local/bin" - echo "${HOME}/.local/bin" - fi -} - -needs_sudo() { - [ ! -w "$1" ] && command -v sudo >/dev/null 2>&1 -} - -# --- Main --- - -main() { - need curl - need tar - - OS=$(detect_os) - ARCH=$(detect_arch) - VERSION=$(resolve_version) - INSTALL_DIR=$(choose_install_dir) - - TARBALL="amesh-${VERSION}-${OS}-${ARCH}.tar.gz" - URL="${BASE_URL}/download/v${VERSION}/${TARBALL}" - - log "Installing amesh-agent v${VERSION} (${OS}/${ARCH})" - log " from: ${URL}" - log " to: ${INSTALL_DIR}" - - # Download and extract to a temp directory - TMPDIR=$(mktemp -d) - trap 'rm -rf "$TMPDIR"' EXIT - - curl -fsSL "$URL" -o "${TMPDIR}/${TARBALL}" \ - || err "download failed — check that v${VERSION} exists for ${OS}-${ARCH}" - - tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR" - - # Install binary - if needs_sudo "$INSTALL_DIR"; then - sudo install -m 755 "${TMPDIR}/amesh-agent" "${INSTALL_DIR}/amesh-agent" - else - install -m 755 "${TMPDIR}/amesh-agent" "${INSTALL_DIR}/amesh-agent" - fi - - # Verify - if "${INSTALL_DIR}/amesh-agent" --version >/dev/null 2>&1; then - INSTALLED_VERSION=$("${INSTALL_DIR}/amesh-agent" --version 2>&1) - log "" - log "amesh-agent installed: ${INSTALLED_VERSION}" - else - log "" - log "amesh-agent installed to ${INSTALL_DIR}/amesh-agent" - fi - - # Warn if install dir is not in PATH - case ":${PATH}:" in - *":${INSTALL_DIR}:"*) ;; - *) - log "" - log "Note: ${INSTALL_DIR} is not in your PATH." - log " Add it with: export PATH=\"${INSTALL_DIR}:\$PATH\"" - ;; - esac - - log "" - log "Get started: amesh-agent agent start" -} - -main +# Usage: curl -fsSL https://authmesh.dev/install | sh +# Then: amesh agent start + +echo "" +echo " amesh-agent has been merged into the main amesh binary." +echo " Use the unified installer instead:" +echo "" +echo " curl -fsSL https://authmesh.dev/install | sh" +echo "" +echo " After install, start the agent with:" +echo "" +echo " amesh agent start" +echo "" + +# Auto-redirect to main install script +exec sh -c "$(curl -fsSL https://authmesh.dev/install)" diff --git a/packages/agent/README.md b/packages/agent/README.md deleted file mode 100644 index 4b6b1e8..0000000 --- a/packages/agent/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @authmesh/agent - -Agent daemon for [amesh](https://github.com/ameshdev/amesh) remote shell — secure remote access using device-bound identity. Includes all CLI commands plus the agent daemon. One install on the server. - -## Install - -```bash -npm install -g @authmesh/agent -``` - -On install, a postinstall script downloads the prebuilt `amesh-agent` binary for your platform (macOS arm64/x64, Linux x64/arm64) from the matching GitHub release. The binary bundles Bun, so no runtime install is needed on supported platforms. - -**Platform support:** macOS (arm64, x64) and Linux (x64, arm64 — including Raspberry Pi 4/5 on 64-bit Pi OS). Linux armv7 (Raspberry Pi 3 and earlier, 32-bit Pi OS) is not supported because Bun does not ship for that architecture; on those systems, install [Bun](https://bun.com/docs/installation) manually and run as `bun $(which amesh-agent) agent start`. - -## Setup - -```bash -amesh-agent init --name "prod-api" -amesh-agent listen -# Controller runs: amesh invite - -amesh-agent grant am_3d9f1a2e --shell -amesh-agent agent start -``` - -## Commands - -All CLI commands plus the agent daemon: - -```bash -amesh-agent init --name "prod-api" # Create device identity -amesh-agent listen # Start pairing (target side) -amesh-agent invite # Join pairing -amesh-agent list # Show paired devices -amesh-agent revoke # Remove a device -amesh-agent grant --shell # Grant shell access -amesh-agent provision # Generate bootstrap tokens -amesh-agent shell # Open remote shell -amesh-agent agent start # Start the agent daemon -``` - -## License - -[MIT](https://github.com/ameshdev/amesh/blob/main/LICENSE) diff --git a/packages/agent/package.json b/packages/agent/package.json deleted file mode 100644 index 6d7d822..0000000 --- a/packages/agent/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@authmesh/agent", - "version": "0.5.3", - "description": "amesh agent daemon + CLI — remote shell target with device-bound identity", - "type": "module", - "license": "MIT", - "author": "Yair Etzion", - "repository": { - "type": "git", - "url": "https://github.com/ameshdev/amesh.git", - "directory": "packages/agent" - }, - "homepage": "https://github.com/ameshdev/amesh", - "keywords": [ - "authentication", - "agent", - "remote-shell", - "ssh-alternative", - "device-identity", - "pty", - "daemon" - ], - "publishConfig": { - "access": "public" - }, - "bin": { - "amesh-agent": "./scripts/launcher.mjs" - }, - "oclif": { - "commands": "./dist/commands", - "bin": "amesh-agent", - "topicSeparator": " " - }, - "files": [ - "dist", - "scripts" - ], - "scripts": { - "build": "tsc -b", - "test": "bun test src/__tests__/", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "clean": "rm -rf dist bin *.tsbuildinfo", - "postinstall": "node scripts/postinstall.mjs" - }, - "dependencies": { - "@authmesh/core": "0.5.3", - "@authmesh/keystore": "0.5.3", - "@noble/ciphers": "2.1.1", - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@oclif/core": "^4.0.0" - }, - "devDependencies": { - "@eslint/js": "^9.0.0", - "@types/bun": "^1.3.0", - "@types/node": "^22.0.0", - "eslint": "^9.0.0", - "typescript": "^5.7.0", - "typescript-eslint": "^8.0.0" - } -} diff --git a/packages/agent/scripts/launcher.mjs b/packages/agent/scripts/launcher.mjs deleted file mode 100644 index d48b6a9..0000000 --- a/packages/agent/scripts/launcher.mjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -/** - * Launcher for @authmesh/agent. - * - * This is the `bin` entry that npm symlinks as `amesh-agent`. If a prebuilt - * binary exists at ../bin/amesh-agent (placed there by the postinstall script), - * exec it directly and pass through all arguments. Otherwise fall back to the - * JS oclif entry which surfaces the Bun runtime requirement. - * - * Using a wrapper here is what makes the package work on platforms where we - * ship binaries (most users) while still leaving a working-ish install on - * unsupported platforms. - */ - -import { existsSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const pkgRoot = join(__dirname, '..'); - -const prebuilt = join(pkgRoot, 'bin', 'amesh-agent'); - -if (existsSync(prebuilt)) { - const result = spawnSync(prebuilt, process.argv.slice(2), { - stdio: 'inherit', - }); - process.exit(result.status ?? 1); -} - -// Fall back to the oclif JS entry point. This path requires Bun runtime and -// will surface a clear error if run under Node.js (see commands/agent/start.ts). -await import('../dist/index.js'); diff --git a/packages/agent/scripts/postinstall.mjs b/packages/agent/scripts/postinstall.mjs deleted file mode 100644 index 2b26aec..0000000 --- a/packages/agent/scripts/postinstall.mjs +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env node -/** - * Postinstall for @authmesh/agent. - * - * Downloads the prebuilt amesh-agent binary for the current platform from - * the matching GitHub release tarball, extracts it into bin/, and marks it - * executable. If no binary exists for this platform (e.g. linux-armv7, or a - * pre-release where binaries haven't been uploaded yet), falls back to the - * JS entry point which surfaces a helpful "requires Bun runtime" error. - * - * This script runs on `npm install @authmesh/agent`. It must exit 0 even on - * failure so install doesn't break CI — unsupported architectures still get - * a working package, just one that requires Bun manually installed. - */ - -import { createWriteStream, existsSync, mkdirSync, chmodSync, readFileSync, renameSync, unlinkSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { pipeline } from 'node:stream/promises'; -import { tmpdir } from 'node:os'; -import { execFileSync } from 'node:child_process'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const pkgRoot = join(__dirname, '..'); -const binDir = join(pkgRoot, 'bin'); - -// Load package version so we target the matching release tag. -const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); -const VERSION = pkg.version; - -// GitHub release URL pattern: amesh-{VERSION}-{platform}-{arch}.tar.gz -const REPO = 'ameshdev/amesh'; - -// Map Node.js process.platform + process.arch → the release tarball suffix. -// Any combination not in this map is considered unsupported. -const PLATFORM_MAP = { - 'darwin-arm64': 'darwin-arm64', - 'darwin-x64': 'darwin-x64', - 'linux-x64': 'linux-x64', - 'linux-arm64': 'linux-arm64', -}; - -function log(...args) { - console.log('[@authmesh/agent postinstall]', ...args); -} - -function warn(...args) { - console.warn('[@authmesh/agent postinstall]', ...args); -} - -async function main() { - // Skip in CI environments where lifecycle scripts are disabled or where the - // user explicitly opted out (same pattern esbuild uses). - if (process.env.AMESH_SKIP_POSTINSTALL === '1') { - log('AMESH_SKIP_POSTINSTALL=1 set, skipping binary download.'); - return; - } - - const key = `${process.platform}-${process.arch}`; - const suffix = PLATFORM_MAP[key]; - - if (!suffix) { - warn( - `No prebuilt binary available for ${key}. The JS entry point will be used,\n` + - ' which requires Bun runtime on this machine. Install Bun:\n' + - ' curl -fsSL https://bun.sh/install | bash\n' + - ` Then run as: bun $(which amesh-agent) agent start`, - ); - return; - } - - const tarballName = `amesh-${VERSION}-${suffix}.tar.gz`; - const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${tarballName}`; - - log(`Downloading prebuilt amesh-agent binary for ${key}...`); - log(` ${url}`); - - mkdirSync(binDir, { recursive: true }); - const tmpTar = join(tmpdir(), `${tarballName}.${process.pid}`); - const tmpExtract = join(tmpdir(), `amesh-agent-extract-${process.pid}`); - - try { - // Stream download via native fetch (Node 18+). - const res = await fetch(url); - if (!res.ok) { - throw new Error(`HTTP ${res.status} ${res.statusText}`); - } - if (!res.body) { - throw new Error('empty response body'); - } - await pipeline(res.body, createWriteStream(tmpTar)); - - // Extract the tarball to a tmp dir and move just the amesh-agent binary. - mkdirSync(tmpExtract, { recursive: true }); - execFileSync('tar', ['-xzf', tmpTar, '-C', tmpExtract]); - - const extractedBin = join(tmpExtract, 'amesh-agent'); - if (!existsSync(extractedBin)) { - throw new Error(`amesh-agent binary not found in ${tarballName}`); - } - - const finalBin = join(binDir, 'amesh-agent'); - renameSync(extractedBin, finalBin); - chmodSync(finalBin, 0o755); - log(`Installed → ${finalBin}`); - } catch (err) { - warn( - `Binary download failed: ${err instanceof Error ? err.message : String(err)}\n` + - ' Falling back to the JS entry point, which requires Bun runtime:\n' + - ' curl -fsSL https://bun.sh/install | bash\n' + - ' bun $(which amesh-agent) agent start', - ); - } finally { - if (existsSync(tmpTar)) unlinkSync(tmpTar); - } -} - -main().catch((err) => { - // Never fail the install. Print a warning and exit 0. - warn('Unexpected error:', err instanceof Error ? err.message : String(err)); - process.exit(0); -}); diff --git a/packages/agent/src/__tests__/bootstrap-token.test.ts b/packages/agent/src/__tests__/bootstrap-token.test.ts deleted file mode 100644 index 980d1f8..0000000 --- a/packages/agent/src/__tests__/bootstrap-token.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { p256 } from '@noble/curves/nist.js'; -import { signMessage } from '@authmesh/core'; -import type { KeyStore } from '@authmesh/keystore'; -import { - generateBootstrapToken, - validateBootstrapToken, - decodeBootstrapToken, -} from '../bootstrap-token.js'; - -/** - * Regression tests for M6 (iat/alg/scope/single_use enforcement) and - * hardening of validateBootstrapToken against malformed tokens. - */ -describe('validateBootstrapToken (M6)', () => { - function makeKeystoreAdapter(privateKey: Uint8Array, publicKey: Uint8Array) { - // Minimal KeyStore stub: only sign/getPublicKey are used by - // generateBootstrapToken. - const store: Partial = { - async sign(_deviceId: string, message: Uint8Array) { - return signMessage(privateKey, message); - }, - async getPublicKey(_deviceId: string) { - return publicKey; - }, - }; - return store as KeyStore; - } - - async function makeToken(overrides?: { ttlSeconds?: number }) { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const keyStore = makeKeystoreAdapter(privateKey, publicKey); - - const { token } = await generateBootstrapToken({ - issuerDeviceId: 'am_ctrl0123456789', - keyAlias: 'am_ctrl0123456789', - name: 'test-target', - ttlSeconds: overrides?.ttlSeconds ?? 600, - relay: 'wss://relay.example.com/ws', - keyStore, - }); - - return { token, privateKey, publicKey }; - } - - it('accepts a freshly generated token', async () => { - const { token, publicKey } = await makeToken(); - const payload = validateBootstrapToken(token, publicKey); - expect(payload.single_use).toBe(true); - expect(payload.scope).toBe('peer:add'); - }); - - it('rejects a token whose signature does not match the claimed pub key', async () => { - const { token } = await makeToken(); - const wrongPub = p256.getPublicKey(p256.utils.randomSecretKey(), true); - expect(() => validateBootstrapToken(token, wrongPub)).toThrow('invalid_signature'); - }); - - it('rejects a token whose iat is in the future beyond allowed skew', async () => { - const { token, publicKey } = await makeToken(); - // Re-encode with an iat 10 minutes in the future - const [prefix, headerB64, payloadB64, sigB64] = token.split('.'); - const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); - payload.iat = Math.floor(Date.now() / 1000) + 600; - const tamperedPayloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const tamperedToken = [prefix, headerB64, tamperedPayloadB64, sigB64].join('.'); - // Signature will no longer match, so validateBootstrapToken either throws - // token_not_yet_valid (our new iat check) or invalid_signature. We check - // the iat branch by first making the sig wrong — this is the same code - // path an attacker who forges iat would hit. - expect(() => validateBootstrapToken(tamperedToken, publicKey)).toThrow( - /token_not_yet_valid|invalid_signature/, - ); - }); - - it('rejects a token with iat far in the future (iat check fires before sig)', async () => { - // Generate a token by hand so iat is future but signature is valid - // against the future-iat payload. - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const futureIat = Math.floor(Date.now() / 1000) + 600; // 10 min in future - - const header = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; - const payload = { - iss: 'am_ctrl0123456789', - pub: Buffer.from(publicKey).toString('base64'), - iat: futureIat, - exp: futureIat + 3600, - jti: 'bt_future', - name: 'target', - relay: 'wss://relay.example.com/ws', - scope: 'peer:add', - single_use: true, - }; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = signMessage(privateKey, sigInput); - const sigB64 = Buffer.from(sig).toString('base64url'); - const token = `amesh-bt-v1.${headerB64}.${payloadB64}.${sigB64}`; - - expect(() => validateBootstrapToken(token, publicKey)).toThrow('token_not_yet_valid'); - }); - - it('rejects an expired token', async () => { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const past = Math.floor(Date.now() / 1000) - 3600; - - const header = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; - const payload = { - iss: 'am_ctrl0123456789', - pub: Buffer.from(publicKey).toString('base64'), - iat: past - 100, - exp: past, // expired an hour ago - jti: 'bt_expired', - name: 'target', - relay: 'wss://relay.example.com/ws', - scope: 'peer:add', - single_use: true, - }; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = signMessage(privateKey, sigInput); - const token = `amesh-bt-v1.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; - - expect(() => validateBootstrapToken(token, publicKey)).toThrow('token_expired'); - }); - - it('rejects a token with wrong scope', async () => { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const now = Math.floor(Date.now() / 1000); - - const header = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; - const payload = { - iss: 'am_ctrl', - pub: Buffer.from(publicKey).toString('base64'), - iat: now, - exp: now + 600, - jti: 'bt_badscope', - name: 'target', - relay: 'wss://relay.example.com/ws', - scope: 'peer:read', // wrong scope - single_use: true, - }; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = signMessage(privateKey, sigInput); - const token = `amesh-bt-v1.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; - - expect(() => validateBootstrapToken(token, publicKey)).toThrow('unsupported_token_scope'); - }); - - it('rejects a token with single_use=false', async () => { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const now = Math.floor(Date.now() / 1000); - - const header = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; - const payload = { - iss: 'am_ctrl', - pub: Buffer.from(publicKey).toString('base64'), - iat: now, - exp: now + 600, - jti: 'bt_multi', - name: 'target', - relay: 'wss://relay.example.com/ws', - scope: 'peer:add', - single_use: false, - }; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = signMessage(privateKey, sigInput); - const token = `amesh-bt-v1.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; - - expect(() => validateBootstrapToken(token, publicKey)).toThrow('token_must_be_single_use'); - }); - - it('rejects a token with unexpected alg', async () => { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - const now = Math.floor(Date.now() / 1000); - - const header = { typ: 'amesh-bootstrap', ver: '1', alg: 'none' }; // attack - const payload = { - iss: 'am_ctrl', - pub: Buffer.from(publicKey).toString('base64'), - iat: now, - exp: now + 600, - jti: 'bt_noalg', - name: 'target', - relay: 'wss://relay.example.com/ws', - scope: 'peer:add', - single_use: true, - }; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = signMessage(privateKey, sigInput); - const token = `amesh-bt-v1.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; - - // decodeBootstrapToken already rejects unknown alg via its own check, but - // we want to prove validateBootstrapToken also surfaces a clear error. - expect(() => validateBootstrapToken(token, publicKey)).toThrow('unsupported_token_alg'); - }); - - it('round-trip: generate and decode exposes payload fields', async () => { - const { token, publicKey } = await makeToken({ ttlSeconds: 120 }); - const { payload } = decodeBootstrapToken(token); - expect(payload.scope).toBe('peer:add'); - expect(payload.single_use).toBe(true); - expect(payload.exp - payload.iat).toBe(120); - expect(payload.pub).toBe(Buffer.from(publicKey).toString('base64')); - }); -}); diff --git a/packages/agent/src/__tests__/frame.test.ts b/packages/agent/src/__tests__/frame.test.ts deleted file mode 100644 index 192d283..0000000 --- a/packages/agent/src/__tests__/frame.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { - FrameType, - encodeDataFrame, - encodeResizeFrame, - encodeExitFrame, - encodePingFrame, - encodePongFrame, - encodeCommandFrame, - parseFrame, - parseResize, - parseExit, -} from '../frame.js'; - -describe('frame protocol', () => { - it('encodes and parses data frame', () => { - const data = new TextEncoder().encode('hello'); - const frame = encodeDataFrame(data); - const parsed = parseFrame(frame); - expect(parsed.type).toBe(FrameType.DATA); - expect(new TextDecoder().decode(parsed.payload)).toBe('hello'); - }); - - it('encodes and parses resize frame', () => { - const frame = encodeResizeFrame(120, 40); - const parsed = parseFrame(frame); - expect(parsed.type).toBe(FrameType.RESIZE); - const { cols, rows } = parseResize(parsed.payload); - expect(cols).toBe(120); - expect(rows).toBe(40); - }); - - it('encodes and parses exit frame', () => { - const frame = encodeExitFrame(42); - const parsed = parseFrame(frame); - expect(parsed.type).toBe(FrameType.EXIT); - const { code } = parseExit(parsed.payload); - expect(code).toBe(42); - }); - - it('handles negative exit codes', () => { - const frame = encodeExitFrame(-1); - const { code } = parseExit(parseFrame(frame).payload); - expect(code).toBe(-1); - }); - - it('encodes and parses ping/pong frames', () => { - expect(parseFrame(encodePingFrame()).type).toBe(FrameType.PING); - expect(parseFrame(encodePongFrame()).type).toBe(FrameType.PONG); - }); - - it('encodes and parses command frame', () => { - const frame = encodeCommandFrame('uptime'); - const parsed = parseFrame(frame); - expect(parsed.type).toBe(FrameType.COMMAND); - expect(new TextDecoder().decode(parsed.payload)).toBe('uptime'); - }); - - it('rejects empty frame', () => { - expect(() => parseFrame(new Uint8Array(0))).toThrow('Empty frame'); - }); -}); diff --git a/packages/agent/src/__tests__/message-reader-dispose.test.ts b/packages/agent/src/__tests__/message-reader-dispose.test.ts deleted file mode 100644 index a6cd5c3..0000000 --- a/packages/agent/src/__tests__/message-reader-dispose.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -// We import createMessageReader via the re-export in shell-handshake.ts — it -// isn't exported by name, so we use the module's internal binding. -import { createMessageReader } from '../shell-handshake.js'; - -/** - * Regression test for M4 — the handshake message reader installed a - * `message` listener on the WebSocket that was never removed after the - * handshake completed. On a long-lived shell session the reader's internal - * queue kept growing on every encrypted frame (unbounded memory growth). - * - * The fix exposes a `dispose()` method that removes the listener and drains - * any pending waiter. - */ -describe('createMessageReader dispose (M4)', () => { - function makeFakeWs() { - type Handler = (ev: MessageEvent) => void; - const listeners = new Map>(); - const ws = { - addEventListener(type: string, handler: Handler) { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)!.add(handler); - }, - removeEventListener(type: string, handler: Handler) { - listeners.get(type)?.delete(handler); - }, - // Helpers for tests - dispatchMessage(data: string) { - const event = { data } as MessageEvent; - for (const handler of listeners.get('message') ?? []) { - handler(event); - } - }, - listenerCount(type: string): number { - return listeners.get(type)?.size ?? 0; - }, - }; - return ws as unknown as WebSocket & { - dispatchMessage: (data: string) => void; - listenerCount: (type: string) => number; - }; - } - - it('dispose() removes the message listener', () => { - const ws = makeFakeWs(); - const reader = createMessageReader(ws); - expect(ws.listenerCount('message')).toBe(1); - reader.dispose(); - expect(ws.listenerCount('message')).toBe(0); - }); - - it('dispose() is idempotent', () => { - const ws = makeFakeWs(); - const reader = createMessageReader(ws); - reader.dispose(); - reader.dispose(); - expect(ws.listenerCount('message')).toBe(0); - }); - - it('messages received after dispose() do not grow the internal queue', () => { - const ws = makeFakeWs(); - const reader = createMessageReader(ws); - reader.dispose(); - // Dispatch 1000 messages; nothing should accumulate since the listener - // has been removed. - for (let i = 0; i < 1000; i++) { - ws.dispatchMessage(JSON.stringify({ type: 'data', seq: i })); - } - // read() after dispose should reject with reader_disposed if there's a - // pending waiter; with no waiter it just sits on the empty queue. - // We verify by calling read with a short timeout — no message available - // means it should time out (not resolve with a leaked queued message). - // Short-circuit: the queue was drained on dispose. - // We can't easily assert on private queue.length, but if the listener - // is gone, dispatched messages cannot reach it. - expect(ws.listenerCount('message')).toBe(0); - }); - - it('pending read() rejects with reader_disposed on dispose', async () => { - const ws = makeFakeWs(); - const reader = createMessageReader(ws); - const readPromise = reader.read(5000); - reader.dispose(); - await expect(readPromise).rejects.toThrow('reader_disposed'); - }); - - it('read() still works for messages enqueued before dispose', async () => { - const ws = makeFakeWs(); - const reader = createMessageReader(ws); - ws.dispatchMessage(JSON.stringify({ type: 'pre-dispose' })); - // The message is in the queue; read() should return it immediately. - const msg = await reader.read(100); - expect(msg.type).toBe('pre-dispose'); - reader.dispose(); - }); -}); diff --git a/packages/agent/src/__tests__/passphrase-location.test.ts b/packages/agent/src/__tests__/passphrase-location.test.ts deleted file mode 100644 index d6d3d2a..0000000 --- a/packages/agent/src/__tests__/passphrase-location.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, readFile, writeFile, stat, mkdir } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - resolvePassphrase, - savePassphrase, - deletePassphraseFile, - getPassphrasePath, - getAuthMeshDir, -} from '../paths.js'; - -/** - * Regression tests for H2 — encrypted-file passphrase stored separately from - * identity.json. - * - * These tests set AUTH_MESH_DIR to a temp directory so they don't collide - * with a real amesh install. - */ -describe('passphrase location (H2)', () => { - let tempDir: string; - let prevAuthMeshDir: string | undefined; - let prevEnvPass: string | undefined; - let prevPassFile: string | undefined; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'amesh-h2-')); - prevAuthMeshDir = process.env.AUTH_MESH_DIR; - prevEnvPass = process.env.AUTH_MESH_PASSPHRASE; - prevPassFile = process.env.AMESH_PASSPHRASE_FILE; - process.env.AUTH_MESH_DIR = tempDir; - delete process.env.AUTH_MESH_PASSPHRASE; - delete process.env.AMESH_PASSPHRASE_FILE; - await mkdir(tempDir, { recursive: true, mode: 0o700 }); - }); - - afterEach(async () => { - if (prevAuthMeshDir === undefined) delete process.env.AUTH_MESH_DIR; - else process.env.AUTH_MESH_DIR = prevAuthMeshDir; - if (prevEnvPass === undefined) delete process.env.AUTH_MESH_PASSPHRASE; - else process.env.AUTH_MESH_PASSPHRASE = prevEnvPass; - if (prevPassFile === undefined) delete process.env.AMESH_PASSPHRASE_FILE; - else process.env.AMESH_PASSPHRASE_FILE = prevPassFile; - await rm(tempDir, { recursive: true, force: true }); - }); - - it('getAuthMeshDir honours AUTH_MESH_DIR env var', () => { - expect(getAuthMeshDir()).toBe(tempDir); - }); - - it('savePassphrase writes to the dedicated file with mode 0o400', async () => { - await savePassphrase('my-secret-passphrase-xxx'); - const path = getPassphrasePath(); - const content = await readFile(path, 'utf-8'); - expect(content.trim()).toBe('my-secret-passphrase-xxx'); - const s = await stat(path); - // mode is stored in the low 9 bits - expect(s.mode & 0o777).toBe(0o400); - }); - - it('resolvePassphrase returns undefined when no source is available', async () => { - const result = await resolvePassphrase({}); - expect(result.passphrase).toBeUndefined(); - expect(result.migratedFromIdentity).toBe(false); - }); - - it('resolvePassphrase reads from AUTH_MESH_PASSPHRASE env var first', async () => { - process.env.AUTH_MESH_PASSPHRASE = 'env-pass'; - await savePassphrase('file-pass'); // should be ignored - const result = await resolvePassphrase({ passphrase: 'legacy-pass' }); - expect(result.passphrase).toBe('env-pass'); - expect(result.migratedFromIdentity).toBe(false); - }); - - it('resolvePassphrase reads from dedicated file when env var is absent', async () => { - await savePassphrase('file-pass'); - const result = await resolvePassphrase({}); - expect(result.passphrase).toBe('file-pass'); - expect(result.migratedFromIdentity).toBe(false); - }); - - it('resolvePassphrase migrates legacy identity.passphrase to the dedicated file', async () => { - const identity = { passphrase: 'legacy-from-identity-json' }; - const result = await resolvePassphrase(identity); - expect(result.passphrase).toBe('legacy-from-identity-json'); - expect(result.migratedFromIdentity).toBe(true); - // After migration, the field must be stripped from the identity object - expect(identity.passphrase).toBeUndefined(); - // And the dedicated file must now contain the migrated value - const fileContent = await readFile(getPassphrasePath(), 'utf-8'); - expect(fileContent.trim()).toBe('legacy-from-identity-json'); - }); - - it('deletePassphraseFile is idempotent', async () => { - // No error if the file doesn't exist - await deletePassphraseFile(); - // Creates, then deletes - await savePassphrase('temp'); - await deletePassphraseFile(); - // Reading after delete must fall back to undefined - const result = await resolvePassphrase({}); - expect(result.passphrase).toBeUndefined(); - }); - - it('getPassphrasePath honours AMESH_PASSPHRASE_FILE override', () => { - const customPath = join(tempDir, 'custom-location', 'secret'); - process.env.AMESH_PASSPHRASE_FILE = customPath; - expect(getPassphrasePath()).toBe(customPath); - }); - - it('identity.json and passphrase file are distinct paths', async () => { - const identityPath = join(tempDir, 'identity.json'); - await writeFile(identityPath, '{}', { mode: 0o600 }); - await savePassphrase('secret'); - const passPath = getPassphrasePath(); - expect(passPath).not.toBe(identityPath); - // And identity.json must not contain the passphrase after H2 fix - const identityContent = await readFile(identityPath, 'utf-8'); - expect(identityContent).not.toContain('secret'); - }); -}); diff --git a/packages/agent/src/__tests__/shell-cipher.test.ts b/packages/agent/src/__tests__/shell-cipher.test.ts deleted file mode 100644 index 5eb1cfa..0000000 --- a/packages/agent/src/__tests__/shell-cipher.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { ShellCipher } from '../shell-cipher.js'; -import { randomBytes } from '@noble/ciphers/utils.js'; - -const sessionKey = randomBytes(32); - -describe('ShellCipher', () => { - it('encrypts and decrypts a message (controller → target)', () => { - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - const plaintext = new TextEncoder().encode('hello world'); - const encrypted = controller.encrypt(plaintext); - const decrypted = target.decrypt(encrypted); - - expect(new TextDecoder().decode(decrypted)).toBe('hello world'); - - controller.close(); - target.close(); - }); - - it('encrypts and decrypts a message (target → controller)', () => { - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - const plaintext = new TextEncoder().encode('response data'); - const encrypted = target.encrypt(plaintext); - const decrypted = controller.decrypt(encrypted); - - expect(new TextDecoder().decode(decrypted)).toBe('response data'); - - controller.close(); - target.close(); - }); - - it('handles multiple messages in sequence', () => { - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - for (let i = 0; i < 100; i++) { - const msg = new TextEncoder().encode(`message ${i}`); - const encrypted = controller.encrypt(msg); - const decrypted = target.decrypt(encrypted); - expect(new TextDecoder().decode(decrypted)).toBe(`message ${i}`); - } - - controller.close(); - target.close(); - }); - - it('rejects out-of-order nonces', () => { - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - const msg1 = controller.encrypt(new TextEncoder().encode('first')); - controller.encrypt(new TextEncoder().encode('second')); // advance counter - - // Consume first, then replay it — should fail - target.decrypt(msg1); - expect(() => target.decrypt(msg1)).toThrow('Nonce mismatch'); - - controller.close(); - target.close(); - }); - - it('rejects decryption with wrong key', () => { - const key2 = randomBytes(32); - const controller = new ShellCipher(sessionKey, 'controller'); - const wrongTarget = new ShellCipher(key2, 'target'); - - const encrypted = controller.encrypt(new TextEncoder().encode('secret')); - expect(() => wrongTarget.decrypt(encrypted)).toThrow(); - - controller.close(); - wrongTarget.close(); - }); - - it('refuses operations after close', () => { - const cipher = new ShellCipher(sessionKey, 'controller'); - cipher.close(); - - expect(() => cipher.encrypt(new Uint8Array(1))).toThrow('Cipher is closed'); - }); - - it('rejects session key of wrong length', () => { - expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow( - 'Session key must be 32 bytes', - ); - }); - - it('survives an injected garbage frame without desyncing the session', () => { - // Adversarial test for H3: a relay forwarding a junk frame must not - // permanently break the session. The receiver must still decrypt the - // next legitimate frame after dropping the bad one. - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - const legitFrame = controller.encrypt(new TextEncoder().encode('hello')); - - // Forge a frame with a plausible-shaped nonce but garbage contents. - // 12 byte nonce + 16 byte Poly1305 tag minimum = 28 bytes of junk. - const garbage = new Uint8Array(32); - // Use a nonce that doesn't match the expected next receive nonce so the - // peek-compare rejects it before hitting AEAD. - garbage[0] = 0xff; - expect(() => target.decrypt(garbage)).toThrow('Nonce mismatch'); - - // The legitimate frame must still decrypt — the counter must not have - // advanced on the failed attempt above. - const decrypted = target.decrypt(legitFrame); - expect(new TextDecoder().decode(decrypted)).toBe('hello'); - - // And a second legitimate frame after a Poly1305-failing forgery must also - // still work (counter only advances on successful authentication). - const next = controller.encrypt(new TextEncoder().encode('world')); - const tamperedNonceMatch = new Uint8Array(next.length); - tamperedNonceMatch.set(next); - // Flip a ciphertext byte so Poly1305 rejects it; nonce matches expected. - tamperedNonceMatch[tamperedNonceMatch.length - 1] ^= 0x01; - expect(() => target.decrypt(tamperedNonceMatch)).toThrow(); - expect(new TextDecoder().decode(target.decrypt(next))).toBe('world'); - - controller.close(); - target.close(); - }); - - it('controller and target nonces do not overlap', () => { - const controller = new ShellCipher(sessionKey, 'controller'); - const target = new ShellCipher(sessionKey, 'target'); - - // Both encrypt — the nonces should be different (high bit split) - const enc1 = controller.encrypt(new TextEncoder().encode('a')); - const enc2 = target.encrypt(new TextEncoder().encode('b')); - - // First byte of nonce: controller=0x00, target=0x80 - expect(enc1[0]).toBe(0x00); - expect(enc2[0]).toBe(0x80); - - controller.close(); - target.close(); - }); -}); diff --git a/packages/agent/src/__tests__/shell-handshake-sig.test.ts b/packages/agent/src/__tests__/shell-handshake-sig.test.ts deleted file mode 100644 index a2b551c..0000000 --- a/packages/agent/src/__tests__/shell-handshake-sig.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { p256 } from '@noble/curves/nist.js'; -import { signMessage, verifyMessage } from '@authmesh/core'; -import { buildShellSigMessage } from '../shell-handshake.js'; - -/** - * Regression test for C1 — shell handshake MITM via unbound selfSig. - * - * A valid selfSig must verify ONLY against the ECDH transcript that the peer - * actually saw. A relay-MITM that substitutes its own ephemeral keys on each - * leg must not be able to replay a captured selfSig from one leg to the other. - */ -describe('shell handshake signature binding (C1)', () => { - function makeIdentity(friendlyName: string, deviceId: string) { - const privateKey = p256.utils.randomSecretKey(); - const publicKey = p256.getPublicKey(privateKey, true); - return { - privateKey, - publicKey, - publicKeyBase64: Buffer.from(publicKey).toString('base64'), - friendlyName, - deviceId, - }; - } - - function randomEphemeralPub(): Uint8Array { - return p256.getPublicKey(p256.utils.randomSecretKey(), true); - } - - it('a signature bound to ephemeral pair (A,B) does not verify against pair (A,C)', () => { - const controller = makeIdentity('ctrl', 'am_ctrl1234567890'); - - const legitControllerEph = randomEphemeralPub(); // what controller sent - const legitAgentEph = randomEphemeralPub(); // what agent sent (controller saw this) - const attackerEph = randomEphemeralPub(); // what MITM sent to the other leg - - const timestamp = new Date().toISOString(); - - // Controller signs over the transcript it saw: its own ephemeral + the - // agent's ephemeral as forwarded by the (possibly malicious) relay. - const msgForLegA = buildShellSigMessage({ - publicKey: controller.publicKeyBase64, - deviceId: controller.deviceId, - friendlyName: controller.friendlyName, - timestamp, - signerEphPub: legitControllerEph, - verifierEphPub: legitAgentEph, - }); - const sig = signMessage(controller.privateKey, msgForLegA); - - // Sanity: the signature verifies when the verifier reconstructs the same - // transcript (both sides saw identical ephemerals). - expect(verifyMessage(sig, msgForLegA, controller.publicKey)).toBe(true); - - // Attack: a MITM holds the controller's encrypted identity envelope from - // leg A and re-encrypts it to the agent on leg B. The agent computes the - // transcript from ITS ephemeral plus what the MITM actually sent on the - // wire (attackerEph), not the controller's real ephemeral. - const msgAsSeenByAgent = buildShellSigMessage({ - publicKey: controller.publicKeyBase64, - deviceId: controller.deviceId, - friendlyName: controller.friendlyName, - timestamp, - // Agent thinks the peer's ephemeral was attackerEph (what the MITM sent). - signerEphPub: attackerEph, - // Agent's own ephemeral — replace legitAgentEph with whatever the agent - // picked; in the attack scenario it's unchanged from the agent's view. - verifierEphPub: legitAgentEph, - }); - expect(verifyMessage(sig, msgAsSeenByAgent, controller.publicKey)).toBe(false); - }); - - it('flipping deviceId, friendlyName, or timestamp invalidates the signature', () => { - const id = makeIdentity('alice', 'am_alice1234567890'); - const signerEph = randomEphemeralPub(); - const verifierEph = randomEphemeralPub(); - const timestamp = new Date().toISOString(); - - const base = { - publicKey: id.publicKeyBase64, - deviceId: id.deviceId, - friendlyName: id.friendlyName, - timestamp, - signerEphPub: signerEph, - verifierEphPub: verifierEph, - }; - - const sig = signMessage(id.privateKey, buildShellSigMessage(base)); - expect(verifyMessage(sig, buildShellSigMessage(base), id.publicKey)).toBe(true); - - expect( - verifyMessage(sig, buildShellSigMessage({ ...base, deviceId: 'am_mallory1234' }), id.publicKey), - ).toBe(false); - - expect( - verifyMessage(sig, buildShellSigMessage({ ...base, friendlyName: 'mallory' }), id.publicKey), - ).toBe(false); - - expect( - verifyMessage( - sig, - buildShellSigMessage({ ...base, timestamp: new Date(Date.now() + 1000).toISOString() }), - id.publicKey, - ), - ).toBe(false); - }); - - it('domain separator prevents cross-protocol signature reuse', () => { - // A signature over the shell-binding transcript should never validate - // against a hand-crafted message that lacks the domain prefix. - const id = makeIdentity('bob', 'am_bob9999999999'); - const signerEph = randomEphemeralPub(); - const verifierEph = randomEphemeralPub(); - const timestamp = new Date().toISOString(); - - const shellMsg = buildShellSigMessage({ - publicKey: id.publicKeyBase64, - deviceId: id.deviceId, - friendlyName: id.friendlyName, - timestamp, - signerEphPub: signerEph, - verifierEphPub: verifierEph, - }); - const sig = signMessage(id.privateKey, shellMsg); - - // The OLD format (pre-C1 fix) was just pub+name+timestamp concatenated. - // A signature over the shell transcript must not be valid for the old - // message form, or a future refactor could re-introduce the MITM. - const oldFormatMsg = new TextEncoder().encode( - id.publicKeyBase64 + id.friendlyName + timestamp, - ); - expect(verifyMessage(sig, oldFormatMsg, id.publicKey)).toBe(false); - }); -}); diff --git a/packages/agent/src/bootstrap-token.ts b/packages/agent/src/bootstrap-token.ts deleted file mode 100644 index 5722138..0000000 --- a/packages/agent/src/bootstrap-token.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { verifyMessage } from '@authmesh/core'; -import { randomBytes } from '@noble/ciphers/utils.js'; -import type { KeyStore } from '@authmesh/keystore'; - -export interface BootstrapTokenHeader { - typ: 'amesh-bootstrap'; - ver: '1'; - alg: 'ES256'; -} - -export interface BootstrapTokenPayload { - iss: string; // controller device ID - pub: string; // controller public key (base64, compressed P-256) - iat: number; // issued at (unix seconds) - exp: number; // expiry (unix seconds) - jti: string; // unique token ID - name: string; // friendly name for the target - relay: string; // relay URL - scope: 'peer:add'; - single_use: true; -} - -const MAX_TTL = 86400; // 24 hours -const PREFIX = 'amesh-bt-v1'; - -function b64url(data: string): string { - return Buffer.from(data).toString('base64url'); -} - -function b64urlDecode(encoded: string): string { - return Buffer.from(encoded, 'base64url').toString(); -} - -/** - * Generate a bootstrap token signed by the controller's key. - */ -export async function generateBootstrapToken(opts: { - issuerDeviceId: string; - keyAlias?: string; - name: string; - ttlSeconds: number; - relay: string; - keyStore: KeyStore; -}): Promise<{ token: string; payload: BootstrapTokenPayload }> { - if (opts.ttlSeconds > MAX_TTL) { - throw new Error('ttl cannot exceed 24 hours'); - } - - const now = Math.floor(Date.now() / 1000); - const jti = `bt_${Buffer.from(randomBytes(16)).toString('hex')}`; - const keyAlias = opts.keyAlias ?? opts.issuerDeviceId; - const publicKey = await opts.keyStore.getPublicKey(keyAlias); - - const header: BootstrapTokenHeader = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; - const payload: BootstrapTokenPayload = { - iss: opts.issuerDeviceId, - pub: Buffer.from(publicKey).toString('base64'), - iat: now, - exp: now + opts.ttlSeconds, - jti, - name: opts.name, - relay: opts.relay, - scope: 'peer:add', - single_use: true, - }; - - const headerB64 = b64url(JSON.stringify(header)); - const payloadB64 = b64url(JSON.stringify(payload)); - const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const sig = await opts.keyStore.sign(keyAlias, sigInput); - - const token = `${PREFIX}.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; - return { token, payload }; -} - -/** - * Decode a bootstrap token without verifying the signature. - */ -export function decodeBootstrapToken(token: string): { - header: BootstrapTokenHeader; - payload: BootstrapTokenPayload; - signatureInput: string; - signature: Uint8Array; -} { - if (!token.startsWith(`${PREFIX}.`)) { - throw new Error('invalid_token_format'); - } - - const parts = token.slice(PREFIX.length + 1).split('.'); - if (parts.length !== 3) throw new Error('invalid_token_format'); - - const [headerB64, payloadB64, sigB64] = parts; - const header = JSON.parse(b64urlDecode(headerB64)) as BootstrapTokenHeader; - const payload = JSON.parse(b64urlDecode(payloadB64)) as BootstrapTokenPayload; - const signature = new Uint8Array(Buffer.from(sigB64, 'base64url')); - - if (header.typ !== 'amesh-bootstrap') throw new Error('invalid_token_type'); - if (header.ver !== '1') throw new Error('unsupported_token_version'); - - return { header, payload, signatureInput: `${headerB64}.${payloadB64}`, signature }; -} - -/** - * Allowed clock skew between token issuer and consumer, in seconds. - * Used when validating `iat` (not-before): a token whose `iat` is further - * than this in the future is rejected as clock-skewed or backdated. - */ -const IAT_CLOCK_SKEW_SECONDS = 60; - -/** - * Validate a bootstrap token: structural checks, expiry, not-before, and - * signature. Does NOT enforce single-use — callers must layer that on top via - * a consumed-jti registry. - */ -export function validateBootstrapToken( - token: string, - controllerPublicKey: Uint8Array, -): BootstrapTokenPayload { - const { header, payload, signatureInput, signature } = decodeBootstrapToken(token); - - // Pin `alg` so a future crypto swap cannot accept a token with an - // unexpected signing algorithm (and so "alg: none"-style attacks are - // impossible even in theory). - if (header.alg !== 'ES256') { - throw new Error('unsupported_token_alg'); - } - - // Enforce the structural invariants of the payload so consumers can trust - // that `single_use` and `scope` mean what they claim. - if (payload.scope !== 'peer:add') { - throw new Error('unsupported_token_scope'); - } - if (payload.single_use !== true) { - throw new Error('token_must_be_single_use'); - } - - const now = Math.floor(Date.now() / 1000); - // Not-before check: reject tokens issued in the future beyond the allowed - // skew. Guards against a backdated-clock issuer silently extending the - // effective lifetime, or payload tampering if signature verification is - // ever relaxed. - if (typeof payload.iat !== 'number' || payload.iat > now + IAT_CLOCK_SKEW_SECONDS) { - throw new Error('token_not_yet_valid'); - } - if (typeof payload.exp !== 'number' || payload.exp <= now) { - throw new Error('token_expired'); - } - - const message = new TextEncoder().encode(signatureInput); - if (!verifyMessage(signature, message, controllerPublicKey)) { - throw new Error('invalid_signature'); - } - - return payload; -} diff --git a/packages/agent/src/commands/grant.ts b/packages/agent/src/commands/grant.ts deleted file mode 100644 index cea5198..0000000 --- a/packages/agent/src/commands/grant.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Command, Args, Flags } from '@oclif/core'; -import { loadContext } from '../context.js'; - -export default class Grant extends Command { - static override description = 'Grant or revoke permissions for a paired device'; - - static override args = { - deviceId: Args.string({ - description: 'Device ID to modify (e.g., am_1a2b3c4d5e6f7a8b)', - required: true, - }), - }; - - static override flags = { - shell: Flags.boolean({ - description: 'Grant shell access (remote terminal)', - allowNo: true, - }), - }; - - async run(): Promise { - const { args, flags } = await this.parse(Grant); - - if (flags.shell === undefined) { - this.error( - 'Specify a permission to grant or revoke. Example: amesh grant --shell', - ); - } - - const { allowList } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - const data = await allowList.read(); - const device = data.devices.find((d) => d.deviceId === args.deviceId); - if (!device) { - this.error(`Device ${args.deviceId} not found in allow list.`); - } - - await allowList.updatePermissions(args.deviceId, { shell: flags.shell }); - - this.log(''); - this.log(` Device: ${device.friendlyName} (${args.deviceId})`); - if (flags.shell) { - this.log(' Shell access: granted'); - this.log(''); - this.log(' This device can now open remote shells via `amesh shell`.'); - } else { - this.log(' Shell access: revoked'); - } - this.log(''); - } -} diff --git a/packages/agent/src/commands/init.ts b/packages/agent/src/commands/init.ts deleted file mode 100644 index 6d27b99..0000000 --- a/packages/agent/src/commands/init.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Command, Flags } from '@oclif/core'; -import { - createForBackend, - detectAndCreate, - BACKEND_LABELS, - generatePassphrase, -} from '@authmesh/keystore'; -import type { StorageBackend } from '@authmesh/keystore'; -import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; -import { - getIdentityPath, - getKeysDir, - getPassphrasePath, - savePassphrase, - deletePassphraseFile, -} from '../paths.js'; -import { rename } from 'node:fs/promises'; -import { join } from 'node:path'; - -const deviceIdPlaceholder = 'am_init'; - -export default class Init extends Command { - static override description = 'Create a cryptographic identity for this device'; - - static override flags = { - name: Flags.string({ - char: 'n', - description: 'Friendly name for this device', - required: true, - }), - backend: Flags.string({ - char: 'b', - description: 'Force a specific storage backend', - options: ['secure-enclave', 'keychain', 'tpm2', 'encrypted-file'], - }), - force: Flags.boolean({ - description: 'Overwrite existing identity', - default: false, - }), - 'max-controllers': Flags.integer({ - description: 'Maximum number of controllers allowed (default: 1)', - default: 1, - min: 1, - }), - }; - - async run(): Promise { - const { flags } = await this.parse(Init); - - const identityPath = getIdentityPath(); - if (!flags.force && (await identityExists(identityPath))) { - this.error('Identity already exists. Use --force to overwrite.'); - } - - this.log(''); - this.log('Generating P-256 keypair...'); - - const keysDir = getKeysDir(); - let backend: StorageBackend; - let keyStore; - let resolvedPassphrase: string | undefined; - let warning: string | undefined; - - if (flags.backend) { - backend = flags.backend as StorageBackend; - if (backend === 'encrypted-file') { - // Prefer operator-supplied passphrase via env var so secrets never - // have to touch disk. Only auto-generate as a last resort. - resolvedPassphrase = process.env.AUTH_MESH_PASSPHRASE ?? generatePassphrase(); - warning = - 'Using encrypted-file backend — keys are SOFTWARE-PROTECTED only.\n' + - ' Private key is encrypted on disk but not bound to hardware.\n' + - ` Passphrase is stored in ${getPassphrasePath()} with mode 0o400.\n` + - ' For true hardware-level protection move this file to a secrets\n' + - ' manager / tmpfs / separate mount, or set AUTH_MESH_PASSPHRASE on\n' + - ' each run instead (see AMESH_PASSPHRASE_FILE).\n' + - ' For hardware-backed storage, use macOS (Keychain) or Linux with\n' + - ' TPM 2.0, then re-run `amesh init --force`.'; - } - keyStore = await createForBackend(backend, keysDir, resolvedPassphrase); - this.log(` Using backend: ${BACKEND_LABELS[backend]}`); - } else { - this.log(''); - this.log('Detecting key storage backend:'); - const result = await detectAndCreate(keysDir, (msg) => this.log(msg)); - backend = result.backend; - keyStore = result.keyStore; - resolvedPassphrase = result.passphrase; - warning = result.warning; - } - - // Generate key and derive device ID from public key - const { publicKey } = await keyStore.generateAndStore(deviceIdPlaceholder); - const deviceId = generateDeviceId(publicKey); - - let keyAlias: string; - - if (backend === 'encrypted-file') { - // Encrypted-file driver stores keys as files — rename to real device ID - const oldPath = join(keysDir, `${deviceIdPlaceholder}.key.json`); - const newPath = join(keysDir, `${deviceId}.key.json`); - await rename(oldPath, newPath); - keyAlias = deviceId; - } else { - // Hardware keystores can't rename keys — key stays stored under deviceIdPlaceholder. - // context.ts maps deviceId → internal key name via identity.keyAlias. - keyAlias = deviceIdPlaceholder; - } - - const identity = { - version: '2.0.0' as const, - deviceId, - keyAlias, - publicKey: Buffer.from(publicKey).toString('base64'), - friendlyName: flags.name, - createdAt: new Date().toISOString(), - storageBackend: backend, - ...(flags['max-controllers'] > 1 ? { maxControllers: flags['max-controllers'] } : {}), - }; - - await saveIdentity(identityPath, identity); - - // H2 — write the passphrase (if any) to its dedicated file, NOT - // identity.json. This way a leak of identity.json alone does not - // compromise the encrypted-file backend's key. On `--force`, clear any - // pre-existing passphrase file so a backend switch doesn't leave stale - // state behind. - await deletePassphraseFile(); - if (resolvedPassphrase) { - await savePassphrase(resolvedPassphrase); - } - - // Remove stale allow list — it was sealed with the old key and can't be verified - const { getAllowListPath } = await import('../paths.js'); - const { unlink } = await import('node:fs/promises'); - await unlink(getAllowListPath()).catch(() => {}); - - this.log(''); - this.log('Identity created.'); - this.log(''); - this.log(` Device ID : ${deviceId}`); - this.log(` Public Key : ${identity.publicKey.slice(0, 20)}...`); - this.log(` Backend : ${BACKEND_LABELS[backend]}`); - this.log(` Friendly Name : ${flags.name}`); - - if (warning) { - this.log(''); - this.warn(warning); - } - - this.log(''); - this.log('Next steps:'); - this.log(' Target: run `amesh listen`, then `amesh invite` from your controller'); - this.log(' Controller: run `amesh listen` on a target first, then `amesh invite` here'); - } -} diff --git a/packages/agent/src/commands/invite.ts b/packages/agent/src/commands/invite.ts deleted file mode 100644 index a0c4fdc..0000000 --- a/packages/agent/src/commands/invite.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Command, Args, Flags } from '@oclif/core'; -import { loadContext } from '../context.js'; -import { runControllerHandshake } from '../handshake.js'; -import { generateDeviceId } from '../identity.js'; -const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; - -export default class Invite extends Command { - static override description = 'Pair with a target device using its pairing code'; - - static override args = { - code: Args.string({ - description: '6-digit pairing code from the target device', - required: true, - }), - }; - - static override flags = { - relay: Flags.string({ - char: 'r', - description: 'Relay server URL', - default: DEFAULT_RELAY, - env: 'AMESH_RELAY_URL', - }), - }; - - async run(): Promise { - const { args, flags } = await this.parse(Invite); - - if (!/^\d{6}$/.test(args.code)) { - this.error('Pairing code must be exactly 6 digits.'); - } - - const { identity, keyStore, allowList, keyAlias } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - this.log(''); - this.log(` Connecting to relay with code ${args.code}...`); - - const signFn = async (message: Uint8Array) => { - return keyStore.sign(keyAlias, message); - }; - - let result; - try { - result = await runControllerHandshake( - flags.relay, - args.code, - identity.publicKey, - identity.friendlyName, - signFn, - ); - } catch (err) { - this.error(`Handshake failed: ${(err as Error).message}`); - } - - this.log(' Peer found.'); - this.log(' Ephemeral P-256 ECDH tunnel established.'); - this.log(' Keys exchanged and verified.'); - this.log(''); - this.log(' ┌──────────────────────────────────┐'); - this.log(` │ Verification code: ${result.sas} │`); - this.log(' │ Enter this code on the Target │'); - this.log(' │ device to complete pairing. │'); - this.log(' └──────────────────────────────────┘'); - this.log(''); - - await allowList.addDevice({ - deviceId: generateDeviceId(result.peerPublicKey), - publicKey: Buffer.from(result.peerPublicKey).toString('base64'), - friendlyName: result.peerFriendlyName, - addedAt: new Date().toISOString(), - addedBy: 'handshake', - role: 'target', - }); - - this.log(''); - this.log(` "${result.peerFriendlyName}" added as target.`); - this.log(''); - this.log(' Pairing complete. The relay connection is closed.'); - this.log(''); - } -} diff --git a/packages/agent/src/commands/list.ts b/packages/agent/src/commands/list.ts deleted file mode 100644 index cb3ce1c..0000000 --- a/packages/agent/src/commands/list.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command } from '@oclif/core'; -import { BACKEND_LABELS } from '@authmesh/keystore'; -import { loadContext } from '../context.js'; - -export default class List extends Command { - static override description = 'Show this device and trusted devices in the allow list'; - - async run(): Promise { - await this.parse(List); - const { identity, allowList } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - let data; - try { - data = await allowList.read(); - } catch (err: unknown) { - if ((err as Error).message.includes('integrity check failed')) { - this.error( - 'CRITICAL: Allow list integrity check failed — possible tampering.\n' + - 'The file may have been modified outside of amesh.', - ); - } - throw err; - } - - this.log(''); - this.log(' This device'); - this.log(' ' + '─'.repeat(55)); - this.log(` Device ID : ${identity.deviceId}`); - this.log(` Friendly Name : ${identity.friendlyName}`); - const backendLabel = - BACKEND_LABELS[identity.storageBackend as keyof typeof BACKEND_LABELS] ?? - identity.storageBackend; - const backendNote = - identity.storageBackend === 'encrypted-file' ? ' (software-only — not hardware-bound)' : ''; - this.log(` Backend : ${backendLabel}${backendNote}`); - this.log(` Created : ${identity.createdAt.split('T')[0]}`); - this.log(''); - - if (data.devices.length === 0) { - this.log(' No trusted devices yet.'); - this.log(' Pair with another device using `amesh listen` + `amesh invite`.'); - } else { - this.log(` Trusted Devices (${data.devices.length})`); - this.log(' ' + '─'.repeat(55)); - for (const device of data.devices) { - const date = device.addedAt.split('T')[0]; - const roleTag = device.role === 'controller' ? '[controller]' : '[target]'; - this.log( - ` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`, - ); - } - this.log(' ' + '─'.repeat(55)); - } - - this.log(''); - } -} diff --git a/packages/agent/src/commands/listen.ts b/packages/agent/src/commands/listen.ts deleted file mode 100644 index 13f057d..0000000 --- a/packages/agent/src/commands/listen.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Command, Flags } from '@oclif/core'; -import { loadContext } from '../context.js'; -import { generateOTC, runTargetHandshake, verifySAS } from '../handshake.js'; -import { generateDeviceId } from '../identity.js'; -import { createInterface } from 'node:readline'; - -const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; - -export default class Listen extends Command { - static override description = 'Wait for a pairing request from a controller device'; - - static override flags = { - relay: Flags.string({ - char: 'r', - description: 'Relay server URL', - default: DEFAULT_RELAY, - env: 'AMESH_RELAY_URL', - }), - }; - - async run(): Promise { - const { flags } = await this.parse(Listen); - - const { identity, keyStore, allowList, keyAlias } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - const otc = generateOTC(); - - this.log(''); - this.log(' Connecting to relay...'); - this.log(''); - this.log(' ┌─────────────────────────────┐'); - this.log(` │ Your pairing code: ${otc} │`); - this.log(' │ Expires in: 60 seconds │'); - this.log(' └─────────────────────────────┘'); - this.log(''); - this.log(' Share this code with your Controller device.'); - this.log(''); - - const signFn = async (message: Uint8Array) => { - return keyStore.sign(keyAlias, message); - }; - - let result; - try { - result = await runTargetHandshake( - flags.relay, - otc, - identity.publicKey, - identity.friendlyName, - signFn, - ); - } catch (err) { - this.error(`Handshake failed: ${(err as Error).message}`); - } - - this.log(' Controller connected.'); - this.log(' Ephemeral P-256 ECDH tunnel established.'); - this.log(' Keys exchanged and verified.'); - this.log(''); - this.log(' ┌──────────────────────────────────┐'); - this.log(' │ Enter the 6-digit code shown │'); - this.log(" │ on the Controller's screen. │"); - this.log(' └──────────────────────────────────┘'); - this.log(''); - - const entered = await this.prompt(' Verification code: '); - if (!verifySAS(entered.trim(), result.sas)) { - this.log(''); - this.log(' Code mismatch — possible MITM. Pairing aborted.'); - return; - } - - const newDevice = { - deviceId: generateDeviceId(result.peerPublicKey), - publicKey: Buffer.from(result.peerPublicKey).toString('base64'), - friendlyName: result.peerFriendlyName, - addedAt: new Date().toISOString(), - addedBy: 'handshake' as const, - role: 'controller' as const, - }; - - // Enforce maxControllers limit (default: 1) - const maxControllers = - (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; - const currentControllers = await allowList.countByRole('controller'); - - if (currentControllers >= maxControllers) { - this.log( - ` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`, - ); - const replace = await this.confirm(' Replace existing controller(s)? (Y/n): '); - if (!replace) { - this.log(''); - this.log(' Pairing cancelled. No changes made.'); - return; - } - await allowList.replaceByRole('controller', newDevice); - } else { - await allowList.addDevice(newDevice); - } - - this.log(''); - this.log(` "${result.peerFriendlyName}" added as controller.`); - this.log(''); - this.log(' You can now use amesh signing. The relay connection is closed.'); - this.log(''); - } - - private prompt(message: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(message, (answer) => { - rl.close(); - resolve(answer); - }); - }); - } - - private confirm(message: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(message, (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() !== 'n'); - }); - }); - } -} diff --git a/packages/agent/src/commands/provision.ts b/packages/agent/src/commands/provision.ts deleted file mode 100644 index 648d8be..0000000 --- a/packages/agent/src/commands/provision.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Command, Flags } from '@oclif/core'; -import { loadContext } from '../context.js'; -import { generateBootstrapToken } from '../bootstrap-token.js'; - -const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; - -function parseTTL(ttl: string): number { - const match = ttl.match(/^(\d+)(m|h)$/); - if (!match) throw new Error('Invalid TTL format. Use e.g. 30m, 1h, 24h'); - const [, num, unit] = match; - return parseInt(num) * (unit === 'h' ? 3600 : 60); -} - -export default class Provision extends Command { - static override description = 'Generate a bootstrap token for automated device pairing'; - - static override flags = { - name: Flags.string({ - char: 'n', - description: 'Friendly name for the device being provisioned', - required: true, - }), - ttl: Flags.string({ - char: 't', - description: 'Token validity period (e.g. 30m, 1h, 24h)', - default: '1h', - }), - relay: Flags.string({ - char: 'r', - description: 'Relay server URL', - default: DEFAULT_RELAY, - env: 'AMESH_RELAY_URL', - }), - output: Flags.string({ - char: 'o', - description: 'Output format', - options: ['text', 'json'], - default: 'text', - }), - }; - - async run(): Promise { - const { flags } = await this.parse(Provision); - - const { identity, keyStore, keyAlias } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - const ttlSeconds = parseTTL(flags.ttl); - - const { token, payload } = await generateBootstrapToken({ - issuerDeviceId: identity.deviceId, - keyAlias, - name: flags.name, - ttlSeconds, - relay: flags.relay, - keyStore, - }); - - if (flags.output === 'json') { - this.log( - JSON.stringify({ - token, - jti: payload.jti, - name: payload.name, - issuedAt: new Date(payload.iat * 1000).toISOString(), - expiresAt: new Date(payload.exp * 1000).toISOString(), - relay: payload.relay, - }), - ); - return; - } - - this.log(''); - this.log(' Bootstrap token generated.'); - this.log(''); - this.log(` Token (valid for ${flags.ttl}, single use):`); - this.log(''); - this.log(` ${token}`); - this.log(''); - this.log(' Usage:'); - this.log(' Set this as an environment variable on the target:'); - this.log(` AMESH_BOOTSTRAP_TOKEN=${token}`); - this.log(''); - this.log(' On first boot, the target will pair automatically.'); - this.log(''); - } -} diff --git a/packages/agent/src/commands/revoke.ts b/packages/agent/src/commands/revoke.ts deleted file mode 100644 index 85278a5..0000000 --- a/packages/agent/src/commands/revoke.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Command, Args } from '@oclif/core'; -import { loadContext } from '../context.js'; -import { createInterface } from 'node:readline'; - -export default class Revoke extends Command { - static override description = 'Remove a device from the allow list'; - - static override args = { - deviceId: Args.string({ - description: 'Device ID to revoke (e.g., am_1a2b3c4d5e6f7a8b)', - required: true, - }), - }; - - async run(): Promise { - const { args } = await this.parse(Revoke); - - const { allowList } = await loadContext().catch(() => { - this.error('No identity found. Run `amesh init` first.'); - }); - - const data = await allowList.read(); - const device = data.devices.find((d) => d.deviceId === args.deviceId); - if (!device) { - this.error(`Device ${args.deviceId} not found in allow list.`); - } - - this.log(''); - this.log(` Device: ${device.friendlyName}`); - this.log(` Added: ${device.addedAt.split('T')[0]}`); - this.log(''); - - const confirmed = await this.confirm( - ' Are you sure? This device will lose access immediately. (y/N): ', - ); - if (!confirmed) { - this.log(' Cancelled.'); - return; - } - - await allowList.removeDevice(args.deviceId); - - this.log(''); - this.log(` ${args.deviceId} removed from allow list.`); - this.log(' Allow list resealed.'); - this.log(''); - this.log(' Revocation is effective immediately on this machine.'); - this.log(' If this device authenticates to other machines, revoke it there too.'); - this.log(''); - } - - private confirm(prompt: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(prompt, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y'); - }); - }); - } -} diff --git a/packages/agent/src/commands/shell.ts b/packages/agent/src/commands/shell.ts deleted file mode 100644 index ffa1f55..0000000 --- a/packages/agent/src/commands/shell.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Command, Args, Flags } from '@oclif/core'; - -export default class Shell extends Command { - static override description = 'Open a remote shell to a paired device'; - - static override args = { - device: Args.string({ - description: 'Device ID (am_...) or friendly name of the target', - required: true, - }), - }; - - static override flags = { - command: Flags.string({ - char: 'c', - description: 'Run a single command and exit', - }), - relay: Flags.string({ - char: 'r', - description: 'Relay server URL', - default: 'wss://relay.authmesh.dev/ws', - env: 'AMESH_RELAY_URL', - }), - }; - - async run(): Promise { - const { args, flags } = await this.parse(Shell); - - const { connectShell } = await import('../shell-client.js'); - const exitCode = await connectShell({ - target: args.device, - relayUrl: flags.relay, - command: flags.command, - }); - - this.exit(exitCode); - } -} diff --git a/packages/agent/src/context.ts b/packages/agent/src/context.ts deleted file mode 100644 index 3d1e477..0000000 --- a/packages/agent/src/context.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createForBackend, AllowList } from '@authmesh/keystore'; -import type { KeyStore, StorageBackend } from '@authmesh/keystore'; -import { loadIdentity, saveIdentity } from './identity.js'; -import type { Identity } from './identity.js'; -import { getIdentityPath, getAllowListPath, getKeysDir, resolvePassphrase } from './paths.js'; - -export interface AmeshContext { - identity: Identity; - keyStore: KeyStore; - allowList: AllowList; - /** The internal key name in the keystore (may differ from deviceId for keychain/TPM) */ - keyAlias: string; -} - -export async function loadContext(): Promise { - const identity = await loadIdentity(getIdentityPath()); - - // H2 — passphrase lives in a dedicated file, not identity.json. For legacy - // installs we auto-migrate by reading identity.passphrase, writing it to - // the dedicated file, and clearing the field from identity.json on disk. - const { passphrase, migratedFromIdentity } = await resolvePassphrase(identity); - if (migratedFromIdentity) { - await saveIdentity(getIdentityPath(), identity); - } - - const keyStore = await createForBackend( - identity.storageBackend as StorageBackend, - getKeysDir(), - passphrase, - ); - - const keyAlias = identity.keyAlias ?? identity.deviceId; - - const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); - const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); - - return { identity, keyStore, allowList, keyAlias }; -} diff --git a/packages/agent/src/frame.ts b/packages/agent/src/frame.ts deleted file mode 100644 index b963b22..0000000 --- a/packages/agent/src/frame.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Shell frame protocol — binary frames over the encrypted tunnel. - * - * Each frame is: type_byte (1B) || payload (variable) - * The entire frame is then encrypted with ShellCipher before transmission. - */ - -export const FrameType = { - DATA: 0x01, // Raw terminal bytes (stdin/stdout) - RESIZE: 0x02, // Terminal resize: { cols: u16, rows: u16 } (4 bytes BE) - EXIT: 0x03, // Process exit: { code: i32 } (4 bytes BE) - PING: 0x04, // Keepalive ping (empty payload) - PONG: 0x05, // Keepalive pong (empty payload) - COMMAND: 0x06, // Single command for -c mode (UTF-8 string) -} as const; - -export type FrameTypeValue = (typeof FrameType)[keyof typeof FrameType]; - -export function encodeDataFrame(data: Uint8Array): Uint8Array { - const frame = new Uint8Array(1 + data.length); - frame[0] = FrameType.DATA; - frame.set(data, 1); - return frame; -} - -export function encodeResizeFrame(cols: number, rows: number): Uint8Array { - const frame = new Uint8Array(5); - frame[0] = FrameType.RESIZE; - const view = new DataView(frame.buffer); - view.setUint16(1, cols, false); - view.setUint16(3, rows, false); - return frame; -} - -export function encodeExitFrame(code: number): Uint8Array { - const frame = new Uint8Array(5); - frame[0] = FrameType.EXIT; - const view = new DataView(frame.buffer); - view.setInt32(1, code, false); - return frame; -} - -export function encodePingFrame(): Uint8Array { - return new Uint8Array([FrameType.PING]); -} - -export function encodePongFrame(): Uint8Array { - return new Uint8Array([FrameType.PONG]); -} - -export function encodeCommandFrame(command: string): Uint8Array { - const encoded = new TextEncoder().encode(command); - const frame = new Uint8Array(1 + encoded.length); - frame[0] = FrameType.COMMAND; - frame.set(encoded, 1); - return frame; -} - -const VALID_FRAME_TYPES = new Set([ - FrameType.DATA, - FrameType.RESIZE, - FrameType.EXIT, - FrameType.PING, - FrameType.PONG, - FrameType.COMMAND, -]); - -export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } { - if (frame.length < 1) throw new Error('Empty frame'); - if (!VALID_FRAME_TYPES.has(frame[0])) - throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`); - return { - type: frame[0] as FrameTypeValue, - payload: frame.subarray(1), - }; -} - -export function parseResize(payload: Uint8Array): { cols: number; rows: number } { - if (payload.byteLength < 4) throw new Error('RESIZE frame too short'); - const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - return { cols: view.getUint16(0, false), rows: view.getUint16(2, false) }; -} - -export function parseExit(payload: Uint8Array): { code: number } { - if (payload.byteLength < 4) throw new Error('EXIT frame too short'); - const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - return { code: view.getInt32(0, false) }; -} diff --git a/packages/agent/src/handshake.ts b/packages/agent/src/handshake.ts deleted file mode 100644 index 54d194c..0000000 --- a/packages/agent/src/handshake.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { sha256 } from '@noble/hashes/sha2.js'; -import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; -import { randomBytes } from '@noble/ciphers/utils.js'; -import { - generateEphemeralKeyPair, - computeSharedSecret, - deriveSessionKey, - verifyMessage, -} from '@authmesh/core'; - -interface PeerIdentity { - publicKey: string; // base64 - friendlyName: string; - timestamp: string; - selfSig: string; // base64 -} - -/** - * Send a JSON message over WebSocket. - */ -function send(ws: WebSocket, msg: object): void { - ws.send(JSON.stringify(msg)); -} - -/** - * Create a buffered message reader for a WebSocket. - * Messages that arrive before read() is called are queued. - * Uses the standard WebSocket API (works in Bun and browsers). - */ -function createMessageReader(ws: WebSocket) { - const queue: Record[] = []; - let waiter: { - resolve: (msg: Record) => void; - reject: (err: Error) => void; - } | null = null; - - ws.addEventListener('message', (event: MessageEvent) => { - const raw = typeof event.data === 'string' ? event.data : String(event.data); - const msg = JSON.parse(raw); - if (waiter) { - const w = waiter; - waiter = null; - w.resolve(msg); - } else { - queue.push(msg); - } - }); - - return { - read(timeoutMs = 30_000): Promise> { - if (queue.length > 0) { - return Promise.resolve(queue.shift()!); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - waiter = null; - reject(new Error('Timeout waiting for message')); - }, timeoutMs); - waiter = { - resolve: (msg) => { - clearTimeout(timer); - resolve(msg); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }; - }); - }, - }; -} - -/** - * Encrypt a message with ChaCha20-Poly1305. - */ -function encrypt(sessionKey: Uint8Array, plaintext: Uint8Array): string { - const nonce = randomBytes(12); - const cipher = chacha20poly1305(sessionKey, nonce); - const ciphertext = cipher.encrypt(plaintext); - // Prepend nonce to ciphertext - const combined = new Uint8Array(12 + ciphertext.length); - combined.set(nonce, 0); - combined.set(ciphertext, 12); - return Buffer.from(combined).toString('base64'); -} - -/** - * Decrypt a message with ChaCha20-Poly1305. - */ -function decrypt(sessionKey: Uint8Array, encoded: string): Uint8Array { - const combined = Buffer.from(encoded, 'base64'); - const nonce = combined.subarray(0, 12); - const ciphertext = combined.subarray(12); - const cipher = chacha20poly1305(sessionKey, nonce); - return cipher.decrypt(ciphertext); -} - -/** - * Compute SAS (Short Authentication String) for MITM detection. - * SAS = truncate(SHA-256(targetPub || controllerPub || sharedSecret), 6 digits) - */ -export function computeSAS( - targetPubKey: Uint8Array, - controllerPubKey: Uint8Array, - sharedSecret: Uint8Array, -): string { - const combined = new Uint8Array( - targetPubKey.length + controllerPubKey.length + sharedSecret.length, - ); - combined.set(targetPubKey, 0); - combined.set(controllerPubKey, targetPubKey.length); - combined.set(sharedSecret, targetPubKey.length + controllerPubKey.length); - const hash = sha256(combined); - const num = ((hash[0] << 16) | (hash[1] << 8) | hash[2]) % 1_000_000; - return num.toString().padStart(6, '0'); -} - -/** - * Generate a 6-digit OTC. - */ -export function generateOTC(): string { - const bytes = randomBytes(4); - const num = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; - return ((num % 900_000) + 100_000).toString(); -} - -/** - * Verify selfSig from a peer. - */ -function verifySelfSig(peer: PeerIdentity): boolean { - const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64')); - const message = new TextEncoder().encode(peer.publicKey + peer.friendlyName + peer.timestamp); - const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64')); - return verifyMessage(sig, message, publicKey); -} - -/** - * Constant-time comparison for SAS codes. - * Prevents timing side-channels during code entry verification. - */ -export function verifySAS(entered: string, computed: string): boolean { - if (entered.length !== computed.length) return false; - let diff = 0; - for (let i = 0; i < entered.length; i++) { - diff |= entered.charCodeAt(i) ^ computed.charCodeAt(i); - } - return diff === 0; -} - -export interface HandshakeResult { - peerPublicKey: Uint8Array; - peerFriendlyName: string; - sas: string; -} - -/** - * Run the TARGET side of the handshake (Step 1-11 from spec). - */ -export async function runTargetHandshake( - relayUrl: string, - otc: string, - myPublicKeyBase64: string, - myFriendlyName: string, - signFn: (message: Uint8Array) => Promise, -): Promise { - const ws = new WebSocket(relayUrl); - await new Promise((resolve, reject) => { - ws.addEventListener('open', () => resolve()); - ws.addEventListener('error', (e) => reject(e)); - }); - const reader = createMessageReader(ws); - - try { - // Step 1: Connect with OTC - send(ws, { type: 'listen', otc }); - const ack = await reader.read(); - if (ack.type === 'error') throw new Error(`Relay error: ${ack.code}`); - - // Step 2-4: Wait for controller - const peerFound = await reader.read(60_000); - if (peerFound.type !== 'peer_found') throw new Error(`Unexpected: ${peerFound.type}`); - - // Step 5: ECDH ephemeral exchange — send our ephemeral public key - const ephemeral = generateEphemeralKeyPair(); - send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); - - // Receive controller's ephemeral public key - const peerEphMsg = await reader.read(); - const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); - - // Step 6: Derive session key - const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); - const sessionKey = deriveSessionKey(sharedSecret); - - // Step 7: Receive controller's permanent identity (encrypted) - const encPeerIdentity = await reader.read(); - const peerIdentity = JSON.parse( - new TextDecoder().decode(decrypt(sessionKey, encPeerIdentity.payload as string)), - ) as PeerIdentity; - - if (!verifySelfSig(peerIdentity)) { - throw new Error('selfSig verification failed — peer identity is invalid'); - } - - // Step 8: Send our permanent identity (encrypted) - const timestamp = new Date().toISOString(); - const selfSig = await signFn( - new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), - ); - - const myIdentity: PeerIdentity = { - publicKey: myPublicKeyBase64, - friendlyName: myFriendlyName, - timestamp, - selfSig: Buffer.from(selfSig).toString('base64'), - }; - - const encMyIdentity = encrypt(sessionKey, new TextEncoder().encode(JSON.stringify(myIdentity))); - send(ws, { type: 'data', payload: encMyIdentity }); - - // Step 9: Compute SAS - const myPub = new Uint8Array(Buffer.from(myPublicKeyBase64, 'base64')); - const peerPub = new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')); - const sas = computeSAS(myPub, peerPub, sharedSecret); - - // Step 10: Done - send(ws, { type: 'done' }); - - return { - peerPublicKey: peerPub, - peerFriendlyName: peerIdentity.friendlyName, - sas, - }; - } finally { - ws.close(); - } -} - -/** - * Run the CONTROLLER side of the handshake. - */ -export async function runControllerHandshake( - relayUrl: string, - otc: string, - myPublicKeyBase64: string, - myFriendlyName: string, - signFn: (message: Uint8Array) => Promise, -): Promise { - const ws = new WebSocket(relayUrl); - await new Promise((resolve, reject) => { - ws.addEventListener('open', () => resolve()); - ws.addEventListener('error', (e) => reject(e)); - }); - const reader = createMessageReader(ws); - - try { - // Step 3: Connect with OTC - send(ws, { type: 'connect', otc }); - const peerFound = await reader.read(); - if (peerFound.type === 'error') throw new Error(`Relay error: ${peerFound.code}`); - if (peerFound.type !== 'peer_found') throw new Error(`Unexpected: ${peerFound.type}`); - - // Step 5: Receive target's ephemeral public key - const peerEphMsg = await reader.read(); - const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); - - // Send our ephemeral public key - const ephemeral = generateEphemeralKeyPair(); - send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); - - // Step 6: Derive session key - const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); - const sessionKey = deriveSessionKey(sharedSecret); - - // Step 7: Send our permanent identity (encrypted) - const timestamp = new Date().toISOString(); - const selfSig = await signFn( - new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), - ); - - const myIdentity: PeerIdentity = { - publicKey: myPublicKeyBase64, - friendlyName: myFriendlyName, - timestamp, - selfSig: Buffer.from(selfSig).toString('base64'), - }; - - const encMyIdentity = encrypt(sessionKey, new TextEncoder().encode(JSON.stringify(myIdentity))); - send(ws, { type: 'data', payload: encMyIdentity }); - - // Step 8: Receive target's permanent identity (encrypted) - const encPeerIdentity = await reader.read(); - const peerIdentity = JSON.parse( - new TextDecoder().decode(decrypt(sessionKey, encPeerIdentity.payload as string)), - ) as PeerIdentity; - - if (!verifySelfSig(peerIdentity)) { - throw new Error('selfSig verification failed — peer identity is invalid'); - } - - // Step 9: Compute SAS - const peerPub = new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')); - const myPub = new Uint8Array(Buffer.from(myPublicKeyBase64, 'base64')); - const sas = computeSAS(peerPub, myPub, sharedSecret); - - // Step 10: Done - send(ws, { type: 'done' }); - - return { - peerPublicKey: peerPub, - peerFriendlyName: peerIdentity.friendlyName, - sas, - }; - } finally { - ws.close(); - } -} diff --git a/packages/agent/src/identity.ts b/packages/agent/src/identity.ts deleted file mode 100644 index e1fff94..0000000 --- a/packages/agent/src/identity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { sha256 } from '@noble/hashes/sha2.js'; -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -export interface Identity { - version: '2.0.0'; - deviceId: string; - keyAlias?: string; // internal key name in the keystore (may differ from deviceId for keychain/TPM) - publicKey: string; // base64 - friendlyName: string; - createdAt: string; // ISO 8601 - storageBackend: string; - maxControllers?: number; // default 1 — max controllers allowed on this target - /** - * DEPRECATED (H2) — passphrase lives in a dedicated file (`getPassphrasePath`) - * with mode 0o400, not in identity.json. This field only exists for legacy - * pre-H2 installs; `resolvePassphrase()` auto-migrates it to the new file - * and strips it from identity.json on next load. - */ - passphrase?: string; -} - -/** - * Generate a device ID from a compressed P-256 public key. - * deviceId = "am_" + Base64URL(SHA-256(compressedPublicKey)).slice(0, 16) - */ -export function generateDeviceId(publicKey: Uint8Array): string { - const hash = sha256(publicKey); - const b64url = Buffer.from(hash).toString('base64url'); - return `am_${b64url.slice(0, 16)}`; -} - -export async function loadIdentity(path: string): Promise { - const content = await readFile(path, 'utf-8'); - return JSON.parse(content) as Identity; -} - -export async function saveIdentity(path: string, identity: Identity): Promise { - const tmpPath = `${path}.tmp`; - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await writeFile(tmpPath, JSON.stringify(identity, null, 2), { encoding: 'utf-8', mode: 0o600 }); - await rename(tmpPath, path); -} - -export async function identityExists(path: string): Promise { - try { - await readFile(path); - return true; - } catch { - return false; - } -} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts deleted file mode 100644 index 886f324..0000000 --- a/packages/agent/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { execute } from '@oclif/core'; - -await execute({ dir: import.meta.url }); diff --git a/packages/agent/src/paths.ts b/packages/agent/src/paths.ts deleted file mode 100644 index aaac0dc..0000000 --- a/packages/agent/src/paths.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { homedir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { readFile, writeFile, mkdir, chmod, unlink, rename } from 'node:fs/promises'; - -const AUTH_MESH_DIR = join(homedir(), '.amesh'); - -export function getAuthMeshDir(): string { - return process.env.AUTH_MESH_DIR ?? AUTH_MESH_DIR; -} - -export function getIdentityPath(): string { - return join(getAuthMeshDir(), 'identity.json'); -} - -export function getAllowListPath(): string { - return join(getAuthMeshDir(), 'allow_list.json'); -} - -export function getKeysDir(): string { - return join(getAuthMeshDir(), 'keys'); -} - -/** - * Location of the encrypted-file backend passphrase, stored separately from - * identity.json so a leak of identity.json alone does not compromise the key. - * - * Prior to the H2 fix the passphrase was written into identity.json next to - * the encrypted key file, which gave the "encrypted-file" backend no real - * protection against filesystem-level attackers — any read of one implied a - * read of the other. Operators can relocate this file outside the amesh dir - * (different mount, secrets manager tmpfs, etc.) via AMESH_PASSPHRASE_FILE. - */ -export function getPassphrasePath(): string { - return process.env.AMESH_PASSPHRASE_FILE ?? join(getAuthMeshDir(), '.passphrase'); -} - -/** - * Write the encrypted-file backend passphrase to its dedicated file with - * restrictive permissions (0o400 — read-only owner). Uses an atomic - * tmp+rename so a crash mid-write cannot leave a half-written file. - */ -export async function savePassphrase(passphrase: string): Promise { - const path = getPassphrasePath(); - const tmpPath = `${path}.tmp`; - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await writeFile(tmpPath, passphrase, { encoding: 'utf-8', mode: 0o600 }); - await rename(tmpPath, path); - // Tighten to read-only owner after rename so a subsequent overwrite still - // requires an explicit unlink+rename cycle (avoids accidental append). - await chmod(path, 0o400); -} - -/** - * Read the passphrase from (in priority order): - * 1. AUTH_MESH_PASSPHRASE env var (operator-supplied, never touches disk) - * 2. The dedicated passphrase file (getPassphrasePath) - * 3. The legacy `identity.passphrase` field (pre-H2, auto-migrated) - * - * When (3) is used, the passphrase is automatically migrated to (2) and the - * field is cleared from the returned identity object. Callers are responsible - * for re-saving the identity to persist the migration. - * - * Returns undefined if no passphrase is available from any source. - */ -export async function resolvePassphrase( - identity: { passphrase?: string } = {}, -): Promise<{ passphrase: string | undefined; migratedFromIdentity: boolean }> { - const envPass = process.env.AUTH_MESH_PASSPHRASE; - if (envPass) return { passphrase: envPass, migratedFromIdentity: false }; - - try { - const fileContent = await readFile(getPassphrasePath(), 'utf-8'); - return { passphrase: fileContent.trim(), migratedFromIdentity: false }; - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - - // Legacy fallback: identity.passphrase (pre-H2) - if (identity.passphrase) { - await savePassphrase(identity.passphrase); - const migrated = identity.passphrase; - delete identity.passphrase; - console.warn( - '[amesh] migrated legacy passphrase from identity.json to dedicated file. ' + - 'The identity.json file should be re-saved to clear the deprecated field.', - ); - return { passphrase: migrated, migratedFromIdentity: true }; - } - - return { passphrase: undefined, migratedFromIdentity: false }; -} - -/** - * Delete the passphrase file, if present. Used by `amesh init --force` so a - * backend change (e.g. encrypted-file → keychain) doesn't leave stale state. - */ -export async function deletePassphraseFile(): Promise { - try { - await unlink(getPassphrasePath()); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } -} diff --git a/packages/agent/src/sea.ts b/packages/agent/src/sea.ts deleted file mode 100644 index 3a41311..0000000 --- a/packages/agent/src/sea.ts +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env bun - -/** - * SEA (Single Executable Application) entry point for amesh-agent. - * - * Statically imports all commands to bypass oclif's filesystem-based - * command discovery, which doesn't work inside a Bun-compiled binary - * (/$bunfs has no package.json for oclif to locate). - * - * Mirrors packages/cli/src/sea.ts but adds the nested `agent start` subcommand. - * The shebang is #!/usr/bin/env bun because the agent requires Bun for PTY - * support (Bun.spawn with terminal mode) — the compiled binary bundles Bun - * directly so end users don't need it installed. - */ - -import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import Grant from './commands/grant.js'; -import Init from './commands/init.js'; -import Invite from './commands/invite.js'; -import List from './commands/list.js'; -import Listen from './commands/listen.js'; -import Provision from './commands/provision.js'; -import Revoke from './commands/revoke.js'; -import Shell from './commands/shell.js'; -import AgentStart from './commands/agent/start.js'; - -declare const __VERSION__: string; -const VERSION = __VERSION__; // replaced at build time by bun - -interface CommandMeta { - run(argv?: string[], opts?: string): Promise; - description?: string; - flags?: Record< - string, - { - char?: string; - description?: string; - required?: boolean; - default?: unknown; - options?: readonly string[]; - } - >; - args?: Record; -} - -const topLevelCommands: Record = { - grant: Grant, - init: Init, - invite: Invite, - list: List, - listen: Listen, - provision: Provision, - revoke: Revoke, - shell: Shell, -}; - -// Nested commands under `amesh-agent `. -const nestedCommands: Record> = { - agent: { - start: AgentStart, - }, -}; - -function getOclifRoot(): string { - const root = join(tmpdir(), 'amesh-agent-oclif'); - const pjsonPath = join(root, 'package.json'); - if (!existsSync(pjsonPath)) { - mkdirSync(root, { recursive: true }); - writeFileSync( - pjsonPath, - JSON.stringify({ - name: '@authmesh/agent', - version: VERSION, - oclif: { bin: 'amesh-agent' }, - }), - ); - } - return root; -} - -function showHelp(): void { - console.log(`amesh-agent v${VERSION} — Remote shell target daemon + CLI\n`); - console.log('Usage: amesh-agent [flags]\n'); - console.log('Commands:'); - for (const [name, cmd] of Object.entries(topLevelCommands)) { - console.log(` ${name.padEnd(14)}${cmd.description ?? ''}`); - } - for (const [topic, subs] of Object.entries(nestedCommands)) { - for (const [sub, cmd] of Object.entries(subs)) { - console.log(` ${`${topic} ${sub}`.padEnd(14)}${cmd.description ?? ''}`); - } - } - console.log('\nRun "amesh-agent --help" for details on a specific command.'); -} - -function showCommandHelp(name: string, cmd: CommandMeta): void { - console.log(`amesh-agent ${name} — ${cmd.description ?? ''}\n`); - console.log(`Usage: amesh-agent ${name} [flags]\n`); - - if (cmd.args && Object.keys(cmd.args).length > 0) { - console.log('Arguments:'); - for (const [argName, argDef] of Object.entries(cmd.args)) { - const req = argDef.required ? ' (required)' : ''; - console.log(` ${argName.padEnd(18)}${argDef.description ?? ''}${req}`); - } - console.log(''); - } - - if (cmd.flags && Object.keys(cmd.flags).length > 0) { - console.log('Flags:'); - for (const [flagName, flagDef] of Object.entries(cmd.flags)) { - const short = flagDef.char ? `-${flagDef.char}, ` : ' '; - const req = flagDef.required ? ' (required)' : ''; - const def = flagDef.default !== undefined ? ` [default: ${flagDef.default}]` : ''; - const opts = flagDef.options ? ` [${flagDef.options.join('|')}]` : ''; - console.log( - ` ${short}--${flagName.padEnd(16)}${flagDef.description ?? ''}${opts}${req}${def}`, - ); - } - console.log(''); - } -} - -async function main(): Promise { - const args = process.argv.slice(2); - const first = args[0]; - - if (!first || first === '--help' || first === '-h' || first === 'help') { - showHelp(); - process.exit(0); - } - - if (first === '--version' || first === '-V') { - console.log(`amesh-agent/${VERSION}`); - process.exit(0); - } - - const oclifRoot = getOclifRoot(); - - // Nested commands: `amesh-agent agent start [flags]` - const nested = nestedCommands[first]; - if (nested) { - const sub = args[1]; - if (!sub || sub === '--help' || sub === '-h') { - console.log(`amesh-agent ${first} — subcommands:\n`); - for (const [name, cmd] of Object.entries(nested)) { - console.log(` ${name.padEnd(14)}${cmd.description ?? ''}`); - } - process.exit(0); - } - const Cmd = nested[sub]; - if (!Cmd) { - console.error(`Unknown subcommand: ${first} ${sub}`); - console.error(`Run "amesh-agent ${first} --help" to see available subcommands.`); - process.exit(1); - } - const rest = args.slice(2); - if (rest.includes('--help') || rest.includes('-h')) { - showCommandHelp(`${first} ${sub}`, Cmd); - process.exit(0); - } - await Cmd.run(rest, oclifRoot); - return; - } - - // Top-level commands - const Cmd = topLevelCommands[first]; - if (!Cmd) { - console.error(`Unknown command: ${first}`); - console.error('Run "amesh-agent --help" to see available commands.'); - process.exit(1); - } - - const rest = args.slice(1); - if (rest.includes('--help') || rest.includes('-h')) { - showCommandHelp(first, Cmd); - process.exit(0); - } - - await Cmd.run(rest, oclifRoot); -} - -main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(message); - process.exit(1); -}); diff --git a/packages/agent/src/shell-cipher.ts b/packages/agent/src/shell-cipher.ts deleted file mode 100644 index 9b2019c..0000000 --- a/packages/agent/src/shell-cipher.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; - -const NONCE_LEN = 12; - -/** - * Encrypted shell session cipher using ChaCha20-Poly1305 with incrementing nonces. - * - * Each side maintains its own send counter: - * - Controller starts at 0x00...00 - * - Target starts at 0x80...00 (high bit set) - * - * This ensures the two sides never produce the same nonce, and provides - * ordering guarantees. Nonce reuse with ChaCha20-Poly1305 is catastrophic - * (XOR of ciphertexts leaks plaintext), so this design eliminates it. - * - * MUST NOT be confused with the random-nonce encrypt()/decrypt() in handshake.ts. - * That code is for one-shot pairing messages. This is for long-lived shell sessions. - */ -export class ShellCipher { - private readonly sessionKey: Uint8Array; - private readonly sendNonce: Uint8Array; - private readonly recvNonceStart: Uint8Array; - private sendCounter: bigint; - private recvCounter: bigint; - private closed = false; - - /** - * @param sessionKey - 32-byte key from deriveShellSessionKey() - * @param role - 'controller' starts send nonce at 0x00, 'target' starts at 0x80 - */ - constructor(sessionKey: Uint8Array, role: 'controller' | 'target') { - if (sessionKey.length !== 32) throw new Error('Session key must be 32 bytes'); - this.sessionKey = new Uint8Array(sessionKey); - this.sendNonce = new Uint8Array(NONCE_LEN); - this.recvNonceStart = new Uint8Array(NONCE_LEN); - - if (role === 'controller') { - // Controller sends with nonces starting at 0x00..., receives 0x80... - this.recvNonceStart[0] = 0x80; - } else { - // Target sends with nonces starting at 0x80..., receives 0x00... - this.sendNonce[0] = 0x80; - } - - this.sendCounter = 0n; - this.recvCounter = 0n; - } - - encrypt(plaintext: Uint8Array): Uint8Array { - if (this.closed) throw new Error('Cipher is closed'); - const nonce = this.nextSendNonce(); - const cipher = chacha20poly1305(this.sessionKey, nonce); - const ciphertext = cipher.encrypt(plaintext); - // Prepend nonce so receiver can verify ordering - const out = new Uint8Array(NONCE_LEN + ciphertext.length); - out.set(nonce, 0); - out.set(ciphertext, NONCE_LEN); - return out; - } - - decrypt(data: Uint8Array): Uint8Array { - if (this.closed) throw new Error('Cipher is closed'); - if (data.length < NONCE_LEN + 16) throw new Error('Ciphertext too short'); // 16 = Poly1305 tag - const nonce = data.subarray(0, NONCE_LEN); - const ciphertext = data.subarray(NONCE_LEN); - - // Peek at expected nonce WITHOUT advancing the counter. Advancing before - // authentication succeeds lets any injected/malformed frame permanently - // desync the session — a one-packet DoS from an untrusted relay. - const expected = this.peekRecvNonce(); - if (!constantTimeEqual(nonce, expected)) { - throw new Error('Nonce mismatch — possible replay or out-of-order frame'); - } - - const cipher = chacha20poly1305(this.sessionKey, nonce); - const plaintext = cipher.decrypt(ciphertext); // throws on Poly1305 auth failure - // Only advance the receive counter after the frame is fully authenticated. - this.recvCounter++; - return plaintext; - } - - close(): void { - this.closed = true; - this.sessionKey.fill(0); - this.sendNonce.fill(0); - this.recvNonceStart.fill(0); - } - - private static readonly MAX_COUNTER = 2n ** 64n - 1n; - - private nextSendNonce(): Uint8Array { - if (this.sendCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); - const nonce = new Uint8Array(this.sendNonce); - this.incrementCounter(nonce, this.sendCounter); - this.sendCounter++; - return nonce; - } - - /** - * Compute the currently-expected receive nonce without mutating the counter. - * The counter is advanced by decrypt() only after successful AEAD verification. - */ - private peekRecvNonce(): Uint8Array { - if (this.recvCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); - const nonce = new Uint8Array(this.recvNonceStart); - this.incrementCounter(nonce, this.recvCounter); - return nonce; - } - - /** - * Write counter into nonce bytes 4-11 (big-endian), preserving the role prefix in bytes 0-3. - */ - private incrementCounter(nonce: Uint8Array, counter: bigint): void { - const view = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength); - // Write 64-bit counter into bytes 4-11 - view.setBigUint64(4, counter, false); // big-endian - } -} - -function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a[i] ^ b[i]; - } - return diff === 0; -} diff --git a/packages/agent/src/shell-client.ts b/packages/agent/src/shell-client.ts deleted file mode 100644 index c93003c..0000000 --- a/packages/agent/src/shell-client.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { ShellCipher } from './shell-cipher.js'; -import { AllowList, createForBackend } from '@authmesh/keystore'; -import type { StorageBackend } from '@authmesh/keystore'; -import { loadIdentity, saveIdentity } from './identity.js'; -import { getIdentityPath, getKeysDir, getAllowListPath, resolvePassphrase } from './paths.js'; -import { runControllerShellHandshake, createMessageReader, send } from './shell-handshake.js'; -import { - FrameType, - encodeDataFrame, - encodeResizeFrame, - encodePingFrame, - encodeCommandFrame, - parseFrame, - parseExit, -} from './frame.js'; - -interface ShellOptions { - target: string; // device ID or friendly name - relayUrl: string; - command?: string; // -c mode -} - -export async function connectShell(opts: ShellOptions): Promise { - const identity = await loadIdentity(getIdentityPath()); - - // H2 — passphrase lives in a dedicated file, not identity.json. - const { passphrase, migratedFromIdentity } = await resolvePassphrase(identity); - if (migratedFromIdentity) { - await saveIdentity(getIdentityPath(), identity); - } - const keyStore = await createForBackend( - identity.storageBackend as StorageBackend, - getKeysDir(), - passphrase, - ); - - const keyAlias = identity.keyAlias ?? identity.deviceId; - const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); - const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); - const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); - - // Resolve target: by device ID or friendly name - const data = await allowList.read(); - const targetDevice = data.devices.find( - (d) => (d.deviceId === opts.target || d.friendlyName === opts.target) && d.role === 'target', - ); - if (!targetDevice) { - console.error(`Error: target "${opts.target}" not found in allow list.`); - console.error('Run `amesh list` to see paired devices.'); - return 1; - } - - console.error(`Connecting to ${targetDevice.friendlyName} (${targetDevice.deviceId})...`); - - // Connect to relay - const ws = new WebSocket(opts.relayUrl); - await new Promise((resolve, reject) => { - ws.addEventListener('open', () => resolve()); - ws.addEventListener('error', (e) => reject(e)); - }); - - // Request shell (C3 fix — include targetPublicKey for relay matching) - send(ws, { - type: 'shell', - targetDeviceId: targetDevice.deviceId, - targetPublicKey: targetDevice.publicKey, - }); - - const reader = createMessageReader(ws); - const peerFound = await reader.read(30_000); - if (peerFound.type === 'error') { - console.error(`Relay error: ${peerFound.code}`); - reader.dispose(); - ws.close(); - return 1; - } - - // Shell handshake - let result; - try { - result = await runControllerShellHandshake( - ws, - reader, - identity.deviceId, - identity.publicKey, - identity.friendlyName, - signFn, - allowList, - ); - } catch (err) { - console.error(`Handshake failed: ${(err as Error).message}`); - console.error('Is the agent running on the target? Start it with: amesh agent start'); - reader.dispose(); - ws.close(); - return 1; - } - - // M4 — drop the handshake reader. The encrypted frame loop below installs - // its own listener; without dispose() the reader's queue would grow on - // every frame for the lifetime of the shell session. - reader.dispose(); - - console.error(`Connected. Shell session started.\n`); - - const cipher = new ShellCipher(result.sessionKey, 'controller'); - result.sessionKey.fill(0); // L3 fix — zero handshake result copy - const startTime = Date.now(); - let exitCode = 0; - - return new Promise((resolve) => { - // If -c mode, send command frame - if (opts.command) { - const frame = cipher.encrypt(encodeCommandFrame(opts.command)); - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') })); - } else { - // Interactive mode — raw terminal - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.on('data', (chunk: Buffer) => { - const frame = cipher.encrypt(encodeDataFrame(chunk)); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') })); - } - }); - - // Handle terminal resize - process.stdout.on('resize', () => { - const frame = cipher.encrypt( - encodeResizeFrame(process.stdout.columns, process.stdout.rows), - ); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') })); - } - }); - - // Send initial resize - if (process.stdout.columns && process.stdout.rows) { - const frame = cipher.encrypt( - encodeResizeFrame(process.stdout.columns, process.stdout.rows), - ); - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') })); - } - } - - // Keepalive ping - const pingInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - const frame = cipher.encrypt(encodePingFrame()); - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') })); - } - }, 30_000); - - // Receive frames from agent - ws.addEventListener('message', (event: MessageEvent) => { - const raw = typeof event.data === 'string' ? event.data : String(event.data); - let msg; - try { - msg = JSON.parse(raw); - } catch { - return; - } - - if (msg.type !== 'data' || !msg.payload) return; - - try { - const decrypted = cipher.decrypt(Buffer.from(msg.payload, 'base64')); - const { type, payload } = parseFrame(decrypted); - - switch (type) { - case FrameType.DATA: - process.stdout.write(payload); - break; - case FrameType.EXIT: { - exitCode = parseExit(payload).code; - cleanup(); - break; - } - case FrameType.PONG: - break; - } - } catch (err) { - console.error(`\nFrame error: ${(err as Error).message}`); - } - }); - - ws.addEventListener('close', () => { - cleanup(); - }); - - function cleanup() { - clearInterval(pingInterval); - cipher.close(); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - ws.close(); - const duration = Math.round((Date.now() - startTime) / 1000); - console.error(`\nSession closed (exit code ${exitCode}, duration ${duration}s).`); - resolve(exitCode); - } - }); -} diff --git a/packages/agent/src/shell-handshake.ts b/packages/agent/src/shell-handshake.ts deleted file mode 100644 index a226744..0000000 --- a/packages/agent/src/shell-handshake.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; -import { randomBytes } from '@noble/ciphers/utils.js'; -import { sha256 } from '@noble/hashes/sha2.js'; -import { - generateEphemeralKeyPair, - computeSharedSecret, - deriveShellSessionKey, - verifyMessage, -} from '@authmesh/core'; -import type { AllowList } from '@authmesh/keystore'; - -const SHELL_SIG_DOMAIN = 'amesh-shell-v1'; - -interface PeerIdentity { - publicKey: string; // base64 - deviceId: string; - friendlyName: string; - timestamp: string; - selfSig: string; // base64 -} - -export interface ShellHandshakeResult { - sessionKey: Uint8Array; - peerDeviceId: string; - peerFriendlyName: string; - peerPublicKey: Uint8Array; -} - -function send(ws: WebSocket, msg: object): void { - ws.send(JSON.stringify(msg)); -} - -function createMessageReader(ws: WebSocket) { - const queue: Record[] = []; - let waiter: { - resolve: (msg: Record) => void; - reject: (err: Error) => void; - } | null = null; - let disposed = false; - - const handler = (event: MessageEvent) => { - if (disposed) return; - const raw = typeof event.data === 'string' ? event.data : String(event.data); - const msg = JSON.parse(raw); - if (waiter) { - const w = waiter; - waiter = null; - w.resolve(msg); - } else { - queue.push(msg); - } - }; - - ws.addEventListener('message', handler); - - return { - read(timeoutMs = 30_000): Promise> { - if (queue.length > 0) return Promise.resolve(queue.shift()!); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - waiter = null; - reject(new Error('Timeout waiting for message')); - }, timeoutMs); - waiter = { - resolve: (msg) => { - clearTimeout(timer); - resolve(msg); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }; - }); - }, - /** - * Remove the message listener and drain any pending waiter. Must be - * called once the caller is done reading messages, otherwise the handler - * keeps appending to `queue` on every incoming frame (M4 memory leak). - */ - dispose() { - if (disposed) return; - disposed = true; - ws.removeEventListener('message', handler); - queue.length = 0; - if (waiter) { - const w = waiter; - waiter = null; - w.reject(new Error('reader_disposed')); - } - }, - }; -} - -function encrypt(sessionKey: Uint8Array, plaintext: Uint8Array): string { - const nonce = randomBytes(12); - const cipher = chacha20poly1305(sessionKey, nonce); - const ciphertext = cipher.encrypt(plaintext); - const combined = new Uint8Array(12 + ciphertext.length); - combined.set(nonce, 0); - combined.set(ciphertext, 12); - return Buffer.from(combined).toString('base64'); -} - -function decrypt(sessionKey: Uint8Array, encoded: string): Uint8Array { - const combined = Buffer.from(encoded, 'base64'); - const nonce = combined.subarray(0, 12); - const ciphertext = combined.subarray(12); - const cipher = chacha20poly1305(sessionKey, nonce); - return cipher.decrypt(ciphertext); -} - -/** - * Canonical message bound to the current ECDH handshake. - * - * The signature covers (domain, peer identity fields, AND both ephemeral - * public keys as observed on the wire). This prevents a MITM relay that holds - * an ECDH secret on each leg from replaying a peer's encrypted selfSig - * envelope across the two legs — the ephemeral keys differ per leg, so a - * signature produced for one leg won't verify on the other. - * - * Format: - * "amesh-shell-v1\n" || pubB64 || "\n" || deviceId || "\n" || friendlyName || - * "\n" || timestamp || "\n" || sha256(signerEph || verifierEph) - * - * `signerEph` is the ephemeral pub key that the peer signing this message - * put on the wire; `verifierEph` is the one it received. The verifier - * reconstructs the same transcript using what IT saw on the wire, with the - * roles swapped. - */ -export function buildShellSigMessage(params: { - publicKey: string; - deviceId: string; - friendlyName: string; - timestamp: string; - signerEphPub: Uint8Array; - verifierEphPub: Uint8Array; -}): Uint8Array { - const transcript = new Uint8Array( - params.signerEphPub.length + params.verifierEphPub.length, - ); - transcript.set(params.signerEphPub, 0); - transcript.set(params.verifierEphPub, params.signerEphPub.length); - const transcriptHash = sha256(transcript); - const header = new TextEncoder().encode( - `${SHELL_SIG_DOMAIN}\n${params.publicKey}\n${params.deviceId}\n${params.friendlyName}\n${params.timestamp}\n`, - ); - const out = new Uint8Array(header.length + transcriptHash.length); - out.set(header, 0); - out.set(transcriptHash, header.length); - return out; -} - -function verifySelfSig( - peer: PeerIdentity, - signerEphPub: Uint8Array, - verifierEphPub: Uint8Array, -): boolean { - const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64')); - const message = buildShellSigMessage({ - publicKey: peer.publicKey, - deviceId: peer.deviceId, - friendlyName: peer.friendlyName, - timestamp: peer.timestamp, - signerEphPub, - verifierEphPub, - }); - const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64')); - return verifyMessage(sig, message, publicKey); -} - -const MAX_TIMESTAMP_SKEW_MS = 60_000; // 60 seconds - -function validateTimestamp(timestamp: string): void { - const ts = new Date(timestamp).getTime(); - if (isNaN(ts)) throw new Error('Invalid timestamp in peer identity'); - if (Math.abs(Date.now() - ts) > MAX_TIMESTAMP_SKEW_MS) { - throw new Error('Peer identity timestamp out of range'); - } -} - -/** - * Run the TARGET (agent) side of the shell handshake. - * No OTC, no SAS — trust is pre-established via allow list. - * Returns the session key for encrypted shell I/O. - */ -export async function runAgentShellHandshake( - ws: WebSocket, - reader: ReturnType, - myDeviceId: string, - myPublicKeyBase64: string, - myFriendlyName: string, - signFn: (message: Uint8Array) => Promise, - allowList: AllowList, -): Promise { - // Step 1: ECDH ephemeral exchange - const ephemeral = generateEphemeralKeyPair(); - send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); - - const peerEphMsg = await reader.read(); - const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); - - // Step 2: Derive session key (BOUND to device IDs — separate domain from pairing) - const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); - - // Step 3: Receive controller identity (encrypted with temp key for initial exchange) - const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp'); - const encPeerIdentity = await reader.read(); - const peerIdentity = JSON.parse( - new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)), - ) as PeerIdentity; - - // The peer's selfSig must be bound to the ephemeral keys WE observed on the - // wire: peerEphPub was the one they claim they sent, ephemeral.publicKey was - // the one we sent (which they should have received). A MITM that substitutes - // ephemeral keys cannot replay a signature captured from the other leg. - if (!verifySelfSig(peerIdentity, peerEphPub, ephemeral.publicKey)) { - throw new Error('selfSig verification failed'); - } - validateTimestamp(peerIdentity.timestamp); // H1 fix - - // Step 4: Authorization — check allow list - const device = await allowList.findByPublicKey(peerIdentity.publicKey); - if (!device) throw new Error('Device not in allow list'); - if (device.role !== 'controller') throw new Error('Device is not a controller'); - if (!device.permissions?.shell) throw new Error('Shell access not granted for this device'); - - // Step 5: Send our identity, signed over the current ECDH transcript. - const timestamp = new Date().toISOString(); - const selfSig = await signFn( - buildShellSigMessage({ - publicKey: myPublicKeyBase64, - deviceId: myDeviceId, - friendlyName: myFriendlyName, - timestamp, - signerEphPub: ephemeral.publicKey, - verifierEphPub: peerEphPub, - }), - ); - const myIdentity: PeerIdentity = { - publicKey: myPublicKeyBase64, - deviceId: myDeviceId, - friendlyName: myFriendlyName, - timestamp, - selfSig: Buffer.from(selfSig).toString('base64'), - }; - send(ws, { - type: 'data', - payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))), - }); - - // Step 6: Derive final session key bound to actual device IDs - const sessionKey = deriveShellSessionKey(sharedSecret, myDeviceId, peerIdentity.deviceId); - - // H2 fix — zero key material - ephemeral.privateKey.fill(0); - sharedSecret.fill(0); - tempKey.fill(0); - - return { - sessionKey, - peerDeviceId: peerIdentity.deviceId, - peerFriendlyName: peerIdentity.friendlyName, - peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')), - }; -} - -/** - * Run the CONTROLLER side of the shell handshake. - */ -export async function runControllerShellHandshake( - ws: WebSocket, - reader: ReturnType, - myDeviceId: string, - myPublicKeyBase64: string, - myFriendlyName: string, - signFn: (message: Uint8Array) => Promise, - allowList: AllowList, -): Promise { - // Step 1: Receive agent ephemeral key - const peerEphMsg = await reader.read(); - const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); - - // Send our ephemeral key - const ephemeral = generateEphemeralKeyPair(); - send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); - - // Step 2: Derive shared secret - const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); - const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp'); - - // Step 3: Send our identity, signed over the current ECDH transcript. - const timestamp = new Date().toISOString(); - const selfSig = await signFn( - buildShellSigMessage({ - publicKey: myPublicKeyBase64, - deviceId: myDeviceId, - friendlyName: myFriendlyName, - timestamp, - signerEphPub: ephemeral.publicKey, - verifierEphPub: peerEphPub, - }), - ); - const myIdentity: PeerIdentity = { - publicKey: myPublicKeyBase64, - deviceId: myDeviceId, - friendlyName: myFriendlyName, - timestamp, - selfSig: Buffer.from(selfSig).toString('base64'), - }; - send(ws, { - type: 'data', - payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))), - }); - - // Step 4: Receive agent identity - const encPeerIdentity = await reader.read(); - const peerIdentity = JSON.parse( - new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)), - ) as PeerIdentity; - - // Agent must have signed over the ephemeral keys WE observed: peerEphPub is - // what they put on the wire (their ephemeral), ephemeral.publicKey is what - // we sent (which they should have received as verifierEph on their side). - if (!verifySelfSig(peerIdentity, peerEphPub, ephemeral.publicKey)) { - throw new Error('selfSig verification failed'); - } - validateTimestamp(peerIdentity.timestamp); // H1 fix - - // Step 5: Verify agent is in our allow list - const device = await allowList.findByPublicKey(peerIdentity.publicKey); - if (!device) throw new Error('Device not in allow list'); - if (device.role !== 'target') throw new Error('Device is not a target'); - - // Step 6: Derive final session key bound to actual device IDs - const sessionKey = deriveShellSessionKey(sharedSecret, peerIdentity.deviceId, myDeviceId); - - // H2 fix — zero key material - ephemeral.privateKey.fill(0); - sharedSecret.fill(0); - tempKey.fill(0); - - return { - sessionKey, - peerDeviceId: peerIdentity.deviceId, - peerFriendlyName: peerIdentity.friendlyName, - peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')), - }; -} - -export { createMessageReader, send }; diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json deleted file mode 100644 index 1d4a86a..0000000 --- a/packages/agent/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/__tests__"], - "references": [{ "path": "../core" }, { "path": "../keystore" }] -} diff --git a/packages/cli/README.md b/packages/cli/README.md index 2fc2c0c..a1f2c95 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @authmesh/cli -CLI for [amesh](https://github.com/ameshdev/amesh) device identity management. +Unified CLI for [amesh](https://github.com/ameshdev/amesh) — device identity, remote shell, and file transfer. ## Install @@ -12,28 +12,30 @@ npm install -g @authmesh/cli ```bash amesh init --name "prod-api" # Create a device identity -amesh listen # Start pairing (target side) +amesh listen --shell # Start pairing + grant shell access amesh invite # Join pairing (controller side) amesh list # Show trusted devices amesh revoke # Remove a trusted device amesh provision # Generate bootstrap tokens amesh grant --shell # Grant shell access to a controller amesh shell # Open remote shell to a target +amesh agent start # Start the agent daemon (target side) +amesh agent stop # Stop the agent daemon +amesh reset # Clear stale sessions ``` -> **Target-side agent daemon:** The remote shell daemon lives in a separate package, [`@authmesh/agent`](https://www.npmjs.com/package/@authmesh/agent) (`amesh-agent agent start`). Install it on the server; install `@authmesh/cli` on your laptop. - ## Pairing flow On the target machine: ```bash -$ amesh listen +$ amesh listen --shell Pairing code: 482916 Controller connected. Enter the 6-digit code shown on the Controller. Verification code: 847291 "Dev Laptop" added as controller. + Shell access: granted ``` On the controller: @@ -41,7 +43,7 @@ On the controller: $ amesh invite 482916 Connected to relay. Verification code: 847291 - Enter this code on the Target device. + Waiting for target to confirm verification code... "prod-api" added as target. ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 3e27a6c..e26232e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@authmesh/cli", "version": "0.5.3", - "description": "CLI for amesh device identity management — init, pair, list, revoke", + "description": "CLI for amesh — device identity, remote shell, file transfer", "type": "module", "license": "MIT", "author": "Yair Etzion", @@ -27,7 +27,8 @@ }, "oclif": { "commands": "./dist/commands", - "bin": "amesh" + "bin": "amesh", + "topicSeparator": " " }, "files": [ "dist" diff --git a/packages/cli/src/__tests__/shell-handshake-sig.test.ts b/packages/cli/src/__tests__/shell-handshake-sig.test.ts index c88640f..516379f 100644 --- a/packages/cli/src/__tests__/shell-handshake-sig.test.ts +++ b/packages/cli/src/__tests__/shell-handshake-sig.test.ts @@ -78,7 +78,11 @@ describe('shell handshake signature binding (C1)', () => { expect(verifyMessage(sig, buildShellSigMessage(base), id.publicKey)).toBe(true); expect( - verifyMessage(sig, buildShellSigMessage({ ...base, deviceId: 'am_mallory1234' }), id.publicKey), + verifyMessage( + sig, + buildShellSigMessage({ ...base, deviceId: 'am_mallory1234' }), + id.publicKey, + ), ).toBe(false); expect( @@ -110,9 +114,7 @@ describe('shell handshake signature binding (C1)', () => { }); const sig = signMessage(id.privateKey, shellMsg); - const oldFormatMsg = new TextEncoder().encode( - id.publicKeyBase64 + id.friendlyName + timestamp, - ); + const oldFormatMsg = new TextEncoder().encode(id.publicKeyBase64 + id.friendlyName + timestamp); expect(verifyMessage(sig, oldFormatMsg, id.publicKey)).toBe(false); }); }); diff --git a/packages/agent/src/agent.ts b/packages/cli/src/agent.ts similarity index 84% rename from packages/agent/src/agent.ts rename to packages/cli/src/agent.ts index 9cf49ca..f56985e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -3,7 +3,15 @@ import { AllowList, createForBackend } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; import { loadIdentity, saveIdentity } from './identity.js'; import type { Identity } from './identity.js'; -import { getIdentityPath, getKeysDir, getAllowListPath, resolvePassphrase } from './paths.js'; +import { writeFile, unlink, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { + getIdentityPath, + getKeysDir, + getAllowListPath, + resolvePassphrase, + getPidPath, +} from './paths.js'; import { runAgentShellHandshake, createMessageReader, send } from './shell-handshake.js'; import { FrameType, @@ -28,7 +36,7 @@ function sanitizeForLog(str: string, maxLen = 200): string { export async function startAgent(opts: AgentOptions): Promise { // Root guard (M1 fix) if (typeof process.getuid === 'function' && process.getuid() === 0 && !opts.allowRoot) { - console.error('[amesh-agent] ERROR: refusing to run as root.'); + console.error('[amesh agent] ERROR: refusing to run as root.'); console.error(' Running as root grants root shells to all authorized controllers.'); console.error(' Use --allow-root to override.'); process.exit(1); @@ -59,7 +67,11 @@ export async function startAgent(opts: AgentOptions): Promise { * bash process doesn't orphan and `sessionActive` is correctly reset. */ interface ActiveSession { - proc: { kill: () => void; exited: Promise; terminal?: { write: (_: unknown) => void; resize: (_c: number, _r: number) => void } }; + proc: { + kill: () => void; + exited: Promise; + terminal?: { write: (_: unknown) => void; resize: (_c: number, _r: number) => void }; + }; cipher: ShellCipher; idleCheck: ReturnType; messageHandler: (event: MessageEvent) => void; @@ -72,20 +84,39 @@ export async function startAgent(opts: AgentOptions): Promise { function teardownActiveSession(reason: string): void { if (!activeSession) return; - console.log(`[amesh-agent] Tearing down active session (${reason})`); + console.log(`[amesh agent] Tearing down active session (${reason})`); try { activeSession.proc.kill(); - } catch { /* already exited */ } + } catch { + /* already exited */ + } clearInterval(activeSession.idleCheck); try { activeSession.cipher.close(); - } catch { /* already closed */ } + } catch { + /* already closed */ + } activeSession = null; sessionActive = false; } - console.log(`[amesh-agent] Device: ${identity.deviceId} (${identity.friendlyName})`); - console.log(`[amesh-agent] Connecting to relay: ${opts.relayUrl}`); + // Write PID file for `agent stop` + const pidPath = getPidPath(); + await mkdir(dirname(pidPath), { recursive: true }); + await writeFile(pidPath, String(process.pid), { mode: 0o600 }); + + // Graceful shutdown on SIGTERM / SIGINT + const shutdown = async () => { + console.log('[amesh agent] Shutting down...'); + if (activeSession) teardownActiveSession('shutdown'); + await unlink(pidPath).catch(() => {}); + process.exit(0); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + console.log(`[amesh agent] Device: ${identity.deviceId} (${identity.friendlyName})`); + console.log(`[amesh agent] Connecting to relay: ${opts.relayUrl}`); // Connect to relay with reconnect let reconnectDelay = 1000; @@ -136,12 +167,12 @@ export async function startAgent(opts: AgentOptions): Promise { } if (msg.type === 'agent_registered') { - console.log('[amesh-agent] Registered with relay (identity verified).'); + console.log('[amesh agent] Registered with relay (identity verified).'); const data = await allowList.read(); const shellControllers = data.devices.filter( (d) => d.role === 'controller' && d.permissions?.shell, ).length; - console.log(`[amesh-agent] Authorized controllers with shell access: ${shellControllers}`); + console.log(`[amesh agent] Authorized controllers with shell access: ${shellControllers}`); return; } @@ -149,7 +180,7 @@ export async function startAgent(opts: AgentOptions): Promise { if (msg.type === 'peer_found') { if (sessionActive) { - console.error('[amesh-agent] Session already active, rejecting'); + console.error('[amesh agent] Session already active, rejecting'); return; } sessionActive = true; @@ -170,7 +201,7 @@ export async function startAgent(opts: AgentOptions): Promise { if (activeSession) { teardownActiveSession('ws_disconnect'); } - console.log(`[amesh-agent] Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`); + console.log(`[amesh agent] Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`); setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); }); @@ -208,7 +239,7 @@ export async function startAgent(opts: AgentOptions): Promise { const startTime = Date.now(); console.log( - `[amesh-agent] Shell opened by ${result.peerDeviceId} (${result.peerFriendlyName})`, + `[amesh agent] Shell opened by ${result.peerDeviceId} (${result.peerFriendlyName})`, ); // Set up encrypted cipher + zero the handshake result copy (L3 fix) @@ -243,7 +274,7 @@ export async function startAgent(opts: AgentOptions): Promise { let lastActivity = Date.now(); const idleCheck = setInterval(() => { if (Date.now() - lastActivity > idleTimeoutMin * 60_000) { - console.log(`[amesh-agent] Idle timeout for ${result.peerDeviceId}`); + console.log(`[amesh agent] Idle timeout for ${result.peerDeviceId}`); proc.kill(); } }, 30_000); @@ -284,14 +315,14 @@ export async function startAgent(opts: AgentOptions): Promise { case FrameType.COMMAND: { const cmd = new TextDecoder().decode(payload); console.log( - `[amesh-agent] Command from ${result.peerDeviceId}: ${sanitizeForLog(cmd)}`, + `[amesh agent] Command from ${result.peerDeviceId}: ${sanitizeForLog(cmd)}`, ); proc.terminal?.write(cmd + '\nexit\n'); break; } } } catch (err) { - console.error('[amesh-agent] Frame decryption error:', (err as Error).message); + console.error('[amesh agent] Frame decryption error:', (err as Error).message); } }; ws.addEventListener('message', messageHandler); @@ -325,7 +356,7 @@ export async function startAgent(opts: AgentOptions): Promise { const duration = Math.round((Date.now() - startTime) / 1000); console.log( - `[amesh-agent] Shell closed for ${result.peerDeviceId} (exit=${exitCode}, duration=${duration}s)`, + `[amesh agent] Shell closed for ${result.peerDeviceId} (exit=${exitCode}, duration=${duration}s)`, ); cipher.close(); @@ -333,7 +364,7 @@ export async function startAgent(opts: AgentOptions): Promise { // sessionActive reset by .finally() in caller } catch (err) { reader.dispose(); - console.error('[amesh-agent] Shell handshake failed:', (err as Error).message); + console.error('[amesh agent] Shell handshake failed:', (err as Error).message); // sessionActive reset by .finally() in caller } } diff --git a/packages/agent/src/commands/agent/start.ts b/packages/cli/src/commands/agent/start.ts similarity index 96% rename from packages/agent/src/commands/agent/start.ts rename to packages/cli/src/commands/agent/start.ts index f5f6c0c..fd18f0a 100644 --- a/packages/agent/src/commands/agent/start.ts +++ b/packages/cli/src/commands/agent/start.ts @@ -27,7 +27,7 @@ export default class AgentStart extends Command { this.error( 'The agent daemon requires Bun runtime for PTY support.\n' + ' Install Bun: curl -fsSL https://bun.sh/install | bash\n' + - ' Then run: bun amesh-agent agent start', + ' Then run: bun amesh agent start', ); } diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts new file mode 100644 index 0000000..9dcfdf0 --- /dev/null +++ b/packages/cli/src/commands/agent/stop.ts @@ -0,0 +1,43 @@ +import { Command } from '@oclif/core'; +import { readFile, unlink } from 'node:fs/promises'; +import { getPidPath } from '../../paths.js'; + +export default class AgentStop extends Command { + static override description = 'Stop the running amesh agent daemon'; + + async run(): Promise { + await this.parse(AgentStop); + + const pidPath = getPidPath(); + let pid: number; + try { + const content = await readFile(pidPath, 'utf-8'); + pid = parseInt(content.trim(), 10); + if (isNaN(pid)) throw new Error('invalid pid'); + } catch { + this.error( + 'No running agent found.\n' + + 'The agent may not be running, or the PID file is missing.\n' + + `Expected PID file at: ${pidPath}`, + ); + } + + try { + process.kill(pid, 'SIGTERM'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ESRCH') { + // Process doesn't exist — clean up stale PID file + await unlink(pidPath).catch(() => {}); + this.error(`Agent process ${pid} is not running (stale PID file removed).`); + } + throw err; + } + + // Clean up PID file (agent's SIGTERM handler also removes it, but be safe) + await unlink(pidPath).catch(() => {}); + + this.log(''); + this.log(` Agent (PID ${pid}) stopped.`); + this.log(''); + } +} diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts index 409deb7..2e5860c 100644 --- a/packages/cli/src/commands/grant.ts +++ b/packages/cli/src/commands/grant.ts @@ -23,7 +23,8 @@ export default class Grant extends Command { if (flags.shell === undefined) { this.error( - 'Specify a permission to grant or revoke. Example: amesh grant --shell', + 'Specify a permission to grant or revoke.\n' + + ' Example: amesh grant --shell', ); } @@ -45,13 +46,7 @@ export default class Grant extends Command { this.log(''); this.log(` Device: ${device.friendlyName} (${args.deviceId})`); - if (flags.shell) { - this.log(' Shell access: granted'); - this.log(''); - this.log(' This device can now open remote shells via `amesh shell`.'); - } else { - this.log(' Shell access: revoked'); - } + this.log(` Shell access: ${flags.shell ? 'granted' : 'revoked'}`); this.log(''); } } diff --git a/packages/cli/src/commands/invite.ts b/packages/cli/src/commands/invite.ts index c673055..4184d76 100644 --- a/packages/cli/src/commands/invite.ts +++ b/packages/cli/src/commands/invite.ts @@ -77,6 +77,18 @@ export default class Invite extends Command { this.log(' │ device to complete pairing. │'); this.log(' └──────────────────────────────────┘'); this.log(''); + this.log(' Waiting for target to confirm verification code...'); + + // Wait for the target to verify the SAS code before committing + const confirmed = await result.connection.waitForConfirmation(90_000); + result.connection.close(); + + if (!confirmed) { + this.log(''); + this.log(' Target rejected the verification code or disconnected.'); + this.log(' No changes were made. Run `amesh listen` + `amesh invite` to retry.'); + return; + } const newDevice = { deviceId: generateDeviceId(result.peerPublicKey), diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index 5de66f9..faf556d 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -16,6 +16,10 @@ export default class Listen extends Command { default: DEFAULT_RELAY, env: 'AMESH_RELAY_URL', }), + shell: Flags.boolean({ + description: 'Auto-grant shell access to the controller after pairing', + default: false, + }), }; async run(): Promise { @@ -80,12 +84,18 @@ export default class Listen extends Command { const entered = await this.prompt(' Verification code: '); if (!verifySAS(entered.trim(), result.sas)) { + result.connection.sendConfirmation(false); + result.connection.close(); this.log(''); this.log(' Code mismatch — possible MITM attack. Pairing aborted.'); this.log(' No changes were made. Run `amesh listen` again to retry.'); return; } + // Inform the controller that SAS was verified before committing locally + result.connection.sendConfirmation(true); + result.connection.close(); + const newDevice = { deviceId: generateDeviceId(result.peerPublicKey), publicKey: Buffer.from(result.peerPublicKey).toString('base64'), @@ -117,8 +127,14 @@ export default class Listen extends Command { this.log(''); this.log(` "${result.peerFriendlyName}" added as controller.`); + + if (flags.shell) { + await allowList.updatePermissions(newDevice.deviceId, { shell: true }); + this.log(' Shell access: granted'); + } + this.log(''); - this.log(' You can now use amesh signing. The relay connection is closed.'); + this.log(' Pairing complete. The relay connection is closed.'); this.log(''); } diff --git a/packages/cli/src/commands/reset.ts b/packages/cli/src/commands/reset.ts new file mode 100644 index 0000000..4c566ca --- /dev/null +++ b/packages/cli/src/commands/reset.ts @@ -0,0 +1,46 @@ +import { Command } from '@oclif/core'; +import { readFile, unlink } from 'node:fs/promises'; +import { getAuthMeshDir, getPidPath } from '../paths.js'; + +export default class Reset extends Command { + static override description = + 'Reset ephemeral state (stops agent, clears stale sessions without affecting identity or pairings)'; + + async run(): Promise { + await this.parse(Reset); + + this.log(''); + this.log(' Resetting agent state...'); + + // Stop running agent if any + const pidPath = getPidPath(); + try { + const content = await readFile(pidPath, 'utf-8'); + const pid = parseInt(content.trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + this.log(` Stopped running agent (PID ${pid}).`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ESRCH') { + this.log(` Stale PID file found (process ${pid} not running).`); + } + } + } + } catch { + // No PID file — agent not running + } + + await unlink(pidPath).catch(() => {}); + + this.log(''); + this.log(' Reset complete. Ephemeral session state cleared.'); + this.log(' Your identity and pairings are unchanged.'); + this.log(''); + this.log(' To reconnect:'); + this.log(' amesh agent start'); + this.log(''); + this.log(` Config directory: ${getAuthMeshDir()}`); + this.log(''); + } +} diff --git a/packages/cli/src/handshake.ts b/packages/cli/src/handshake.ts index 54d194c..ea3e248 100644 --- a/packages/cli/src/handshake.ts +++ b/packages/cli/src/handshake.ts @@ -148,10 +148,22 @@ export function verifySAS(entered: string, computed: string): boolean { return diff === 0; } +/** Handle for the relay connection kept open after key exchange for SAS confirmation. */ +export interface HandshakeConnection { + /** Target calls this after SAS verification to inform the controller. */ + sendConfirmation(confirmed: boolean): void; + /** Controller calls this to wait for the target's SAS verdict. */ + waitForConfirmation(timeoutMs?: number): Promise; + /** Close the relay connection. Both sides must call this when done. */ + close(): void; +} + export interface HandshakeResult { peerPublicKey: Uint8Array; peerFriendlyName: string; sas: string; + /** Relay connection kept open for SAS confirmation round-trip. */ + connection: HandshakeConnection; } /** @@ -224,16 +236,29 @@ export async function runTargetHandshake( const peerPub = new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')); const sas = computeSAS(myPub, peerPub, sharedSecret); - // Step 10: Done - send(ws, { type: 'done' }); - + // Keep connection open for SAS confirmation round-trip return { peerPublicKey: peerPub, peerFriendlyName: peerIdentity.friendlyName, sas, + connection: { + sendConfirmation(confirmed: boolean) { + send(ws, { type: 'data', payload: confirmed ? 'sas_confirmed' : 'sas_rejected' }); + }, + waitForConfirmation(timeoutMs = 90_000): Promise { + return reader.read(timeoutMs).then( + (msg) => msg.payload === 'sas_confirmed', + () => false, + ); + }, + close() { + ws.close(); + }, + }, }; - } finally { + } catch (err) { ws.close(); + throw err; } } @@ -304,15 +329,28 @@ export async function runControllerHandshake( const myPub = new Uint8Array(Buffer.from(myPublicKeyBase64, 'base64')); const sas = computeSAS(peerPub, myPub, sharedSecret); - // Step 10: Done - send(ws, { type: 'done' }); - + // Keep connection open for SAS confirmation round-trip return { peerPublicKey: peerPub, peerFriendlyName: peerIdentity.friendlyName, sas, + connection: { + sendConfirmation(confirmed: boolean) { + send(ws, { type: 'data', payload: confirmed ? 'sas_confirmed' : 'sas_rejected' }); + }, + waitForConfirmation(timeoutMs = 90_000): Promise { + return reader.read(timeoutMs).then( + (msg) => msg.payload === 'sas_confirmed', + () => false, + ); + }, + close() { + ws.close(); + }, + }, }; - } finally { + } catch (err) { ws.close(); + throw err; } } diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts index aaac0dc..2bb36ca 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -20,6 +20,10 @@ export function getKeysDir(): string { return join(getAuthMeshDir(), 'keys'); } +export function getPidPath(): string { + return join(getAuthMeshDir(), 'agent.pid'); +} + /** * Location of the encrypted-file backend passphrase, stored separately from * identity.json so a leak of identity.json alone does not compromise the key. diff --git a/packages/cli/src/sea.ts b/packages/cli/src/sea.ts index 3331ac2..b1ff4f8 100644 --- a/packages/cli/src/sea.ts +++ b/packages/cli/src/sea.ts @@ -21,8 +21,11 @@ import Invite from './commands/invite.js'; import List from './commands/list.js'; import Listen from './commands/listen.js'; import Provision from './commands/provision.js'; +import Reset from './commands/reset.js'; import Revoke from './commands/revoke.js'; import Shell from './commands/shell.js'; +import AgentStart from './commands/agent/start.js'; +import AgentStop from './commands/agent/stop.js'; declare const __VERSION__: string; const VERSION = __VERSION__; // replaced at build time by bun @@ -43,17 +46,25 @@ interface CommandMeta { args?: Record; } -const commands: Record = { +const topLevelCommands: Record = { grant: Grant, init: Init, invite: Invite, list: List, listen: Listen, provision: Provision, + reset: Reset, revoke: Revoke, shell: Shell, }; +const nestedCommands: Record> = { + agent: { + start: AgentStart, + stop: AgentStop, + }, +}; + /** * Create a minimal oclif root so Config.load() works in compiled binaries. * Without this, oclif tries to find package.json at the build-time path. @@ -79,9 +90,14 @@ function showHelp(): void { console.log(`amesh v${VERSION} — Device-bound M2M authentication\n`); console.log('Usage: amesh [flags]\n'); console.log('Commands:'); - for (const [name, cmd] of Object.entries(commands)) { + for (const [name, cmd] of Object.entries(topLevelCommands)) { console.log(` ${name.padEnd(14)}${cmd.description ?? ''}`); } + for (const [topic, subs] of Object.entries(nestedCommands)) { + for (const [sub, cmd] of Object.entries(subs)) { + console.log(` ${`${topic} ${sub}`.padEnd(14)}${cmd.description ?? ''}`); + } + } console.log('\nRun "amesh --help" for details on a specific command.'); } @@ -115,32 +131,60 @@ function showCommandHelp(name: string, cmd: CommandMeta): void { async function main(): Promise { const args = process.argv.slice(2); - const cmdName = args[0]; + const first = args[0]; - if (!cmdName || cmdName === '--help' || cmdName === '-h' || cmdName === 'help') { + if (!first || first === '--help' || first === '-h' || first === 'help') { showHelp(); process.exit(0); } - if (cmdName === '--version' || cmdName === '-V') { + if (first === '--version' || first === '-V') { console.log(`amesh/${VERSION}`); process.exit(0); } - const Cmd = commands[cmdName]; + const oclifRoot = getOclifRoot(); + + // Nested commands: `amesh agent start [flags]` + const nested = nestedCommands[first]; + if (nested) { + const sub = args[1]; + if (!sub || sub === '--help' || sub === '-h') { + console.log(`amesh ${first} — subcommands:\n`); + for (const [name, cmd] of Object.entries(nested)) { + console.log(` ${name.padEnd(14)}${cmd.description ?? ''}`); + } + process.exit(0); + } + const Cmd = nested[sub]; + if (!Cmd) { + console.error(`Unknown subcommand: ${first} ${sub}`); + console.error(`Run "amesh ${first} --help" to see available subcommands.`); + process.exit(1); + } + const rest = args.slice(2); + if (rest.includes('--help') || rest.includes('-h')) { + showCommandHelp(`${first} ${sub}`, Cmd); + process.exit(0); + } + await Cmd.run(rest, oclifRoot); + return; + } + + // Top-level commands + const Cmd = topLevelCommands[first]; if (!Cmd) { - console.error(`Unknown command: ${cmdName}`); + console.error(`Unknown command: ${first}`); console.error('Run "amesh --help" to see available commands.'); process.exit(1); } const rest = args.slice(1); if (rest.includes('--help') || rest.includes('-h')) { - showCommandHelp(cmdName, Cmd); + showCommandHelp(first, Cmd); process.exit(0); } - const oclifRoot = getOclifRoot(); await Cmd.run(rest, oclifRoot); } diff --git a/packages/cli/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts index d6be146..b40e794 100644 --- a/packages/cli/src/shell-handshake.ts +++ b/packages/cli/src/shell-handshake.ts @@ -131,9 +131,7 @@ export function buildShellSigMessage(params: { signerEphPub: Uint8Array; verifierEphPub: Uint8Array; }): Uint8Array { - const transcript = new Uint8Array( - params.signerEphPub.length + params.verifierEphPub.length, - ); + const transcript = new Uint8Array(params.signerEphPub.length + params.verifierEphPub.length); transcript.set(params.signerEphPub, 0); transcript.set(params.verifierEphPub, params.signerEphPub.length); const transcriptHash = sha256(transcript); diff --git a/packages/keystore/src/__tests__/allow-list.test.ts b/packages/keystore/src/__tests__/allow-list.test.ts index a49a71e..2000b44 100644 --- a/packages/keystore/src/__tests__/allow-list.test.ts +++ b/packages/keystore/src/__tests__/allow-list.test.ts @@ -382,7 +382,11 @@ describe('AllowList', () => { const legacyHmac = computeHmac(hmacKey, new TextEncoder().encode(legacyCanonical)); await writeFile( filePath(), - JSON.stringify({ ...legacyData, hmac: Buffer.from(legacyHmac).toString('base64') }, null, 2), + JSON.stringify( + { ...legacyData, hmac: Buffer.from(legacyHmac).toString('base64') }, + null, + 2, + ), ); // Read must accept the legacy HMAC diff --git a/packages/keystore/src/__tests__/der-parser.test.ts b/packages/keystore/src/__tests__/der-parser.test.ts index 971dc12..1403f49 100644 --- a/packages/keystore/src/__tests__/der-parser.test.ts +++ b/packages/keystore/src/__tests__/der-parser.test.ts @@ -86,8 +86,10 @@ describe('derToRaw DER signature parser (L4)', () => { it('throws when r length is absurd (33+)', () => { // Build a SEQUENCE with r length = 40 (impossible for P-256) const bad = new Uint8Array(50); - bad[0] = 0x30; bad[1] = 48; - bad[2] = 0x02; bad[3] = 40; // r length = 40, invalid + bad[0] = 0x30; + bad[1] = 48; + bad[2] = 0x02; + bad[3] = 40; // r length = 40, invalid expect(() => derToRaw(bad)).toThrow(/r length out of range/); }); @@ -106,8 +108,11 @@ describe('derToRaw DER signature parser (L4)', () => { it('throws when s tag is missing after r', () => { const bad = new Uint8Array(10); - bad[0] = 0x30; bad[1] = 0x08; - bad[2] = 0x02; bad[3] = 0x01; bad[4] = 0x01; // r = [0x01] + bad[0] = 0x30; + bad[1] = 0x08; + bad[2] = 0x02; + bad[3] = 0x01; + bad[4] = 0x01; // r = [0x01] bad[5] = 0x05; // not 0x02 where s tag should be expect(() => derToRaw(bad)).toThrow(/INTEGER tag for s/); }); diff --git a/packages/keystore/src/__tests__/tpm-parsers.test.ts b/packages/keystore/src/__tests__/tpm-parsers.test.ts index 597d823..006f8ac 100644 --- a/packages/keystore/src/__tests__/tpm-parsers.test.ts +++ b/packages/keystore/src/__tests__/tpm-parsers.test.ts @@ -144,10 +144,15 @@ describe('parseTpmtSignature (M7)', () => { const r = new Uint8Array(33); // too big for P-256 const s = new Uint8Array(32); const tpmt = new Uint8Array(4 + 2 + 33 + 2 + 32); - tpmt[0] = 0x00; tpmt[1] = 0x18; tpmt[2] = 0x00; tpmt[3] = 0x0b; - tpmt[4] = 0x00; tpmt[5] = 0x21; // size_r = 33 + tpmt[0] = 0x00; + tpmt[1] = 0x18; + tpmt[2] = 0x00; + tpmt[3] = 0x0b; + tpmt[4] = 0x00; + tpmt[5] = 0x21; // size_r = 33 tpmt.set(r, 6); - tpmt[39] = 0x00; tpmt[40] = 0x20; // size_s = 32 + tpmt[39] = 0x00; + tpmt[40] = 0x20; // size_s = 32 tpmt.set(s, 41); expect(() => parseTpmtSignature(tpmt)).toThrow('exceeds 32 bytes'); }); diff --git a/packages/keystore/src/drivers/tpm.ts b/packages/keystore/src/drivers/tpm.ts index 712bab9..93d3704 100644 --- a/packages/keystore/src/drivers/tpm.ts +++ b/packages/keystore/src/drivers/tpm.ts @@ -83,24 +83,23 @@ export class TPMKeyStore implements KeyStore { let wantedPlain = true; try { await tpm2('sign', [ - '-c', handle, - '-g', 'sha256', - '-s', 'ecdsa', - '-f', 'plain', - '-o', sigPath, + '-c', + handle, + '-g', + 'sha256', + '-s', + 'ecdsa', + '-f', + 'plain', + '-o', + sigPath, msgPath, ]); } catch { // Older tpm2-tools (4.x on Ubuntu 20.04) lack --format=plain. Retry // with default structured format and parse the TPMT_SIGNATURE below. wantedPlain = false; - await tpm2('sign', [ - '-c', handle, - '-g', 'sha256', - '-s', 'ecdsa', - '-o', sigPath, - msgPath, - ]); + await tpm2('sign', ['-c', handle, '-g', 'sha256', '-s', 'ecdsa', '-o', sigPath, msgPath]); } const raw = new Uint8Array(await readFile(sigPath)); return wantedPlain && raw.length === 64 ? raw : parseTpmtSignature(raw); diff --git a/packages/relay/src/__tests__/bootstrap-single-use.test.ts b/packages/relay/src/__tests__/bootstrap-single-use.test.ts index 71490da..53f85cb 100644 --- a/packages/relay/src/__tests__/bootstrap-single-use.test.ts +++ b/packages/relay/src/__tests__/bootstrap-single-use.test.ts @@ -110,12 +110,16 @@ describe('relay bootstrap single-use enforcement (H4)', () => { await waitForMessage(watcherB); const targetA = await openWs(); - targetA.send(JSON.stringify({ type: 'bootstrap_init', jti: jtiA, token: 't', targetPubKey: 'p' })); + targetA.send( + JSON.stringify({ type: 'bootstrap_init', jti: jtiA, token: 't', targetPubKey: 'p' }), + ); const msgA = await waitForMessage(watcherA); expect(msgA.type).toBe('bootstrap_init'); const targetB = await openWs(); - targetB.send(JSON.stringify({ type: 'bootstrap_init', jti: jtiB, token: 't', targetPubKey: 'p' })); + targetB.send( + JSON.stringify({ type: 'bootstrap_init', jti: jtiB, token: 't', targetPubKey: 'p' }), + ); const msgB = await waitForMessage(watcherB); expect(msgB.type).toBe('bootstrap_init'); @@ -137,9 +141,7 @@ describe('relay bootstrap single-use enforcement (H4)', () => { await waitForMessage(watcher); const target1 = await openWs(); - target1.send( - JSON.stringify({ type: 'bootstrap_init', jti, token: 't', targetPubKey: 'p' }), - ); + target1.send(JSON.stringify({ type: 'bootstrap_init', jti, token: 't', targetPubKey: 'p' })); await waitForMessage(watcher); // Simulate the target going away without completing bootstrap. diff --git a/packages/relay/src/__tests__/handshake.integration.test.ts b/packages/relay/src/__tests__/handshake.integration.test.ts index 3c7ee37..1784d59 100644 --- a/packages/relay/src/__tests__/handshake.integration.test.ts +++ b/packages/relay/src/__tests__/handshake.integration.test.ts @@ -77,6 +77,10 @@ describe('full handshake integration', () => { // SAS codes match (proves no MITM) expect(targetResult.sas).toBe(controllerResult.sas); expect(targetResult.sas).toMatch(/^\d{6}$/); + + // Clean up connections (handshake no longer auto-closes for SAS confirmation) + targetResult.connection.close(); + controllerResult.connection.close(); }, 15_000); it('controller gets error for nonexistent OTC', async () => { diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index aeec123..47ff9ff 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,6 +1,6 @@ import type { ServerWebSocket } from 'bun'; import { verifyMessage } from '@authmesh/core'; -import { SessionStore } from './session.js'; +import { SessionStore, SESSION_MAX_BYTES } from './session.js'; import { RateLimiter, OTCAttemptTracker } from './rate-limit.js'; import { AgentStore } from './agent-store.js'; @@ -137,7 +137,7 @@ export function createRelayServer(opts?: { const sessions = new SessionStore(opts?.maxSessions); const agentStore = new AgentStore(); const rateLimiter = new RateLimiter(5, 60_000); - const shellRateLimiter = new RateLimiter(5, 60_000); + const shellRateLimiter = new RateLimiter(2, 60_000); // Dedicated limiter for bootstrap_watch (M3). 10 per minute per IP is // generous for legitimate fleet provisioning and tight enough to stop an // attacker from brute-forcing jti claims. Kept separate from the OTC @@ -254,6 +254,21 @@ export function createRelayServer(opts?: { const session = sessions.get(otc); if (!session) return; + // Track bytes forwarded — enforce per-session cap + const payloadSize = typeof msg.payload === 'string' ? msg.payload.length : 0; + session.bytesForwarded += payloadSize; + if (session.bytesForwarded > SESSION_MAX_BYTES) { + ws.send(JSON.stringify({ type: 'error', code: 'session_data_limit' })); + sessions.remove(otc); + try { + session.target.close(); + } catch { /* ignore */ } + try { + session.controller?.close(); + } catch { /* ignore */ } + return; + } + // Forward to the other peer (opaque blob forwarding) const peer = ws === session.target ? session.controller : session.target; if (peer && peer.readyState === WebSocket.OPEN) { diff --git a/packages/relay/src/session.ts b/packages/relay/src/session.ts index 57f55d0..23c49a5 100644 --- a/packages/relay/src/session.ts +++ b/packages/relay/src/session.ts @@ -1,12 +1,20 @@ import type { ServerWebSocket } from 'bun'; import type { WebSocketData } from './server.js'; +/** Maximum bytes forwarded per session (5 MB). Prevents relay cost abuse. + * 5 MB ≈ 100,000 lines of terminal output — generous for interactive shell, + * tight enough to prevent bulk data streaming. Self-hosted relays can + * override by setting a higher value. */ +export const SESSION_MAX_BYTES = 5 * 1024 * 1024; + export interface PairingSession { otc: string; target: ServerWebSocket; controller: ServerWebSocket | null; createdAt: number; expiresAt: number; + /** Total bytes forwarded through this session (both directions). */ + bytesForwarded: number; } /** @@ -56,6 +64,7 @@ export class SessionStore { controller: null, createdAt: now, expiresAt: now + ttlSeconds * 1000, + bytesForwarded: 0, }; this.sessions.set(otc, session); diff --git a/packaging/build-bun.mjs b/packaging/build-bun.mjs index 92a234c..7680b83 100644 --- a/packaging/build-bun.mjs +++ b/packaging/build-bun.mjs @@ -4,15 +4,14 @@ * Usage: * bun packaging/build-bun.mjs [--target bun-darwin-arm64] * - * Compiles two standalone binaries via `bun build --compile`: - * - packages/cli/src/sea.ts → dist/amesh (controller CLI) - * - packages/agent/src/sea.ts → dist/amesh-agent (target daemon + CLI) + * Compiles a standalone binary via `bun build --compile`: + * - packages/cli/src/sea.ts → dist/amesh (unified CLI + agent) * * On macOS targets, also compiles the Swift Secure Enclave helper. * Default target: current platform. */ -import { readFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { readFileSync, mkdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -25,9 +24,6 @@ const distDir = join(__dirname, 'dist'); const cliPkg = JSON.parse( readFileSync(join(root, 'packages/cli/package.json'), 'utf-8'), ); -const agentPkg = JSON.parse( - readFileSync(join(root, 'packages/agent/package.json'), 'utf-8'), -); // Parse --target flag from argv (e.g., --target bun-darwin-arm64) const args = process.argv.slice(2); @@ -54,7 +50,7 @@ function compile({ label, version, entry, outfile }) { console.log(`Done → ${outfile}`); } -// --- Build the main CLI binary (amesh) --- +// --- Build the unified amesh binary --- compile({ label: 'amesh', version: cliPkg.version, @@ -62,14 +58,6 @@ compile({ outfile: join(distDir, 'amesh'), }); -// --- Build the agent binary (amesh-agent) --- -compile({ - label: 'amesh-agent', - version: agentPkg.version, - entry: join(root, 'packages/agent/src/sea.ts'), - outfile: join(distDir, 'amesh-agent'), -}); - // --- Build Swift helper for macOS targets --- if (isDarwinTarget) { const swiftDir = join(root, 'packages/keystore/swift'); diff --git a/packaging/homebrew/amesh.rb b/packaging/homebrew/amesh.rb index b4f2c1d..4d1ef58 100644 --- a/packaging/homebrew/amesh.rb +++ b/packaging/homebrew/amesh.rb @@ -30,12 +30,10 @@ class Amesh < Formula def install bin.install "amesh" - bin.install "amesh-agent" if File.exist?("amesh-agent") bin.install "amesh-se-helper" if File.exist?("amesh-se-helper") end test do assert_match "amesh", shell_output("#{bin}/amesh --help") - assert_match "amesh-agent", shell_output("#{bin}/amesh-agent --help") if File.exist?("#{bin}/amesh-agent") end end diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 58d702f..59101a7 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -11,7 +11,3 @@ contents: dst: /usr/bin/amesh file_info: mode: 0755 - - src: ./dist/amesh-agent - dst: /usr/bin/amesh-agent - file_info: - mode: 0755