diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a04bd..1bb4219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,29 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [0.6.0] - 2026-04-09 +## [0.7.0] - 2026-04-12 ### Breaking -- **`@authmesh/agent` merged into `@authmesh/cli`** — single `amesh` binary replaces `amesh` + `amesh-agent`. Install `@authmesh/cli` only. The `@authmesh/agent` npm package is deprecated. +- **Remote shell and agent daemon removed** — `amesh agent start/stop`, `amesh shell`, `amesh grant`, and `amesh reset` commands removed. The `@authmesh/agent` npm package is deprecated. amesh now focuses exclusively on device-bound M2M authentication. + +### Security + +- **Reduced attack surface** — removing the agent daemon (PTY spawning, shell cipher, frame protocol) eliminates the highest-risk component +- **Relay simplified** — agent registration, challenge-response, and shell routing handlers removed + +## [0.6.0] - 2026-04-09 ### Added -- **`amesh agent start`** / **`amesh agent stop`** — daemon management with PID file and graceful shutdown via SIGTERM -- **`amesh listen --shell`** — auto-grants shell permission to the new controller after pairing completes -- **`amesh reset`** — clears stale session state (stops running agent, removes PID file) without affecting identity or pairings - **SAS confirmation protocol** — controller now waits for target to verify the 6-digit code before adding to allow list, preventing one-sided trust when the target rejects or disconnects ### Security - **Relay per-session data cap** — 5 MB maximum forwarded per session, prevents bulk data streaming abuse -- **Relay shell rate limit** tightened to 2 sessions/min per IP (was 5) - -### Changed - -- **Unified binary** — all commands (`init`, `listen`, `invite`, `shell`, `agent start/stop`, `reset`, etc.) in one `amesh` binary -- **`install-agent` script** now redirects to the main install script -- **Packaging** — single binary in .deb, Homebrew, and release tarballs ## [0.5.3] - 2026-04-08 diff --git a/README.md b/README.md index 32e6740..fe574b8 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ app.use(amesh.verify()); | Package | Description | |---------|-------------| | [`@authmesh/sdk`](./packages/sdk) | Signing fetch client + Express verification middleware | -| [`@authmesh/cli`](./packages/cli) | CLI + agent: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell`, `agent start/stop`, `reset` | +| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision` | | [`@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/docs/architecture-decisions.md b/docs/architecture-decisions.md index fecc5c1..540bb93 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -165,39 +165,21 @@ 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 +## ADR-011: Remote shell removed (v0.7.0) -> **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/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. +> **Status: Superseded (2026-04-12).** The remote shell feature (agent daemon, shell client, grant/reset commands) was removed entirely in v0.7.0. amesh now focuses exclusively on device-bound M2M authentication. -**Original decision (superseded):** The remote shell feature is part of `@authmesh/cli` — one package, one binary. `amesh shell` connects to a remote target. `amesh agent start` runs the daemon. Shell access requires explicit `amesh grant --shell` after pairing. +**Why it was removed:** -**Why:** +1. **Mission dilution.** The remote shell was tangential to the core value proposition (replacing static API keys with device-bound identities). It confused the product story. -1. **One install:** Developers install one thing (`@authmesh/cli`) and get everything — identity management, pairing, API auth, shell client, and agent daemon. +2. **Attack surface.** The agent daemon spawned bash processes and was the highest-risk component. The v0.5.0 security audit found 4 critical/high issues in shell code alone. Removing it eliminates an entire class of risk. -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. **Maintenance weight.** The shell feature touched ~25 files across every package (CLI, relay, core, keystore, docs, landing page). Each release required building, testing, and distributing agent binaries. -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`. +4. **Bun runtime dependency.** The agent required `Bun.spawn({ terminal })` for PTY support, creating friction that the core SDK didn't have. -**Security design choices:** - -- **Incrementing nonce counters** (not random) for shell encryption — eliminates birthday-bound collision risk over long sessions -- **Device-ID-bound HKDF** (`amesh-shell-v1` salt + both device IDs) — cryptographic separation from pairing sessions -- **No session resumption** — dropped connection = full new ECDH handshake -- **Authenticated agent registration** — relay stores public key, controllers must match it (prevents squatting) -- **Uniform relay responses** — no `agent_not_found` message (prevents device enumeration) -- **Root guard** — agent refuses `root` without `--allow-root` -- **Per-controller session limits** — prevents DoS by authorized-but-misbehaving peers - -**Rejected alternatives (at the time of the original decision):** -- ~~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 +If remote shell demand materializes, it could return as a separate companion package with its own release cycle. --- diff --git a/docs/guide.md b/docs/guide.md index 867fcbb..4442b77 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -256,9 +256,8 @@ The handshake establishes trust between two machines. Run it once per device pai On the **target** machine (the server being secured): ```bash -amesh listen --shell +amesh listen # ✔ "Dev Laptop" added as controller. -# Shell access: granted ``` On the **controller** machine (your laptop), using the 6-digit code displayed by the target: @@ -314,39 +313,6 @@ Health check: `curl http://localhost:3001/health` --- -## 9. Remote Shell - -Once paired with `--shell`, you can open a remote terminal to the target. - -On the **target** (server): -```bash -amesh agent start -# [amesh agent] Registered with relay (identity verified). -# [amesh agent] Authorized controllers with shell access: 1 -``` - -On the **controller** (your laptop): -```bash -amesh shell prod-api -# Connected. Shell session started. - -amesh shell prod-api -c "uptime" # single command mode -``` - -Stop the agent: -```bash -amesh agent stop -# Agent (PID 12345) stopped. -``` - -If you have persistent connection issues: -```bash -amesh reset # clears stale sessions, stops running agent -amesh agent start # reconnect -``` - ---- - ## What the Authorization Header Looks Like ```http diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index 0040c1b..d66084c 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -822,25 +822,25 @@ Sort query parameters alphabetically before including in canonical string `M`. T ### Security audit — April 2026 -A full external-pen-tester-style audit was performed on 2026-04-05 covering the crypto primitives, keystore, SDK middleware, relay, and agent/cli shell flows. All critical and high-severity findings have been fixed; the mediums and informational items have also landed in the same branch. Summary: +A full external-pen-tester-style audit was performed on 2026-04-05 covering the crypto primitives, keystore, SDK middleware, relay, and agent/cli shell flows. All critical and high-severity findings have been fixed. **Note:** The remote shell feature was removed in v0.7.0 — findings marked with *(shell — removed)* are no longer applicable. Summary: | Severity | Finding | Fix | |---|---|---| -| Critical | **C1** — Shell handshake MITM via unbound `selfSig` | Transcript-bound signature: `selfSig` now covers `"amesh-shell-v1"` domain prefix + peer identity fields + `sha256(signerEph \|\| verifierEph)`. A MITM relay forwarding an encrypted identity envelope across two ECDH legs no longer produces a signature that verifies on the receiving leg. See Remote Shell Spec §7.1. | +| Critical | **C1** — Shell handshake MITM via unbound `selfSig` *(shell — removed)* | Was fixed with transcript-bound signatures. Feature removed in v0.7.0. | | Critical | **C2** — Encrypted-file passphrase stored next to the key | Passphrase moved to a dedicated file (`~/.amesh/.passphrase`, mode 0o400) with legacy auto-migration. Preferred source is `AUTH_MESH_PASSPHRASE` env var so secrets can stay off disk. Operators can relocate via `AMESH_PASSPHRASE_FILE`. | | High | **H1** — Rate limiter used LB peer IP | Relay extracts client IP from left-most `X-Forwarded-For` entry when `AMESH_TRUST_PROXY=1`, with bounded-format validation. | | High | **H2** — Passphrase colocation (see C2) | — | -| High | **H3** — ShellCipher DoS via counter desync on injected frame | `recvCounter` now only advances after successful Poly1305 verification. | +| High | **H3** — ShellCipher DoS via counter desync *(shell — removed)* | Feature removed in v0.7.0. | | High | **H4** — Bootstrap token `single_use` not enforced | Relay keeps a 25h consumed-jti set; duplicate `bootstrap_init` is rejected with `token_already_used`. Payload `scope`/`single_use`/`alg`/`iat` are now enforced at decode time. | | Medium | **M1** — Relay connection counter double-decrement | Rejected sockets marked via `ws.data.rejected`; `close()` handles the single decrement. | | Medium | **M2** — SessionStore unbounded | 50,000-session cap with distinct `relay_capacity` error code. | | Medium | **M3** — Bootstrap watcher race | `jti_already_watched` rejection + dedicated rate limiter + jti length cap. | -| Medium | **M4** — Agent listener leak + orphan bash on reconnect | `createMessageReader().dispose()` + outer-scope `activeSession` torn down on relay disconnect. | +| Medium | **M4** — Agent listener leak *(shell — removed)* | Feature removed in v0.7.0. | | Medium | **M5** — Middleware re-serialized parsed bodies | Middleware now hashes raw bytes only (`rawBody` → Buffer → string → stream). Parsed-object bodies without `rawBody` return `500 body_parser_ordering_error`. | | Medium | **M6** — Bootstrap token `iat`/`alg`/`scope`/`single_use` unchecked | `validateBootstrapToken` enforces all four invariants with distinct error codes. | | Medium | **M7** — TPM driver returned wrong formats | `tpm2_sign --format=plain` with TPMT_SIGNATURE fallback parser; `pemToRaw` now properly decodes P-256 SubjectPublicKeyInfo into a 33-byte compressed point. | | Low | **L2** — Auth header parser laxity | Reject duplicate keys, unknown keys, oversized headers, per-field length caps. | -| Low | **L3** — AgentStore pubkey compare not constant-time | Replaced with `constantTimeStringEqual`. | +| Low | **L3** — AgentStore pubkey compare *(shell — removed)* | Feature removed in v0.7.0. | | Low | **L4** — macOS DER parser had no bounds checks | Bounds-checked every field, rejected long-form lengths, enforced r/s ≤ 32 bytes. | | Low | **L5** — Allow-list canonical JSON was insertion-order dependent | Deterministic `stableStringify` with recursive key sorting; legacy canonical accepted on read with auto re-seal. | | Low | **L6** — Bootstrap ack message had no delimiter | Ack message now `"amesh-bootstrap-ack-v1\n" + pubB64 + "\n" + jti`. | diff --git a/docs/remote-shell-security-review.md b/docs/remote-shell-security-review.md deleted file mode 100644 index 7256c01..0000000 --- a/docs/remote-shell-security-review.md +++ /dev/null @@ -1,185 +0,0 @@ -# Remote Shell Spec — Security Review - -Review of `docs/remote-shell-spec.md` against the existing codebase (`handshake.ts`, `server.ts`, protocol spec, ADRs). - ---- - -## Verdict: Cryptographic foundation is solid. Protocol-level and operational gaps need addressing before implementation. - ---- - -## CRITICAL — Must fix before building - -### C1. Agent registration is unauthenticated - -**Spec says:** Agent sends `{ type: 'agent', deviceId: 'am_...' }` to register with the relay. - -**Problem:** Any attacker can send `{ type: 'agent', deviceId: 'am_VICTIM' }` and hijack the agent slot. When the controller runs `amesh shell am_VICTIM`, the relay routes them to the attacker, who receives the ECDH ephemeral key. The ECDH handshake will fail (the attacker can't produce a valid selfSig for the victim's permanent key), so no data leaks. But: - -- **DoS:** Attacker squats on a deviceId, blocking the real agent from receiving shell connections. -- **Timing oracle:** Attacker learns *when* someone tries to connect to a specific device. - -**Fix:** Agent registration must include a proof of identity: -``` -{ type: 'agent', deviceId: 'am_...', timestamp: '...', sig: '' } -``` -The relay can't verify this (it doesn't have the public key), but the **controller can reject** if the subsequent handshake fails. For the DoS vector, require agents to periodically re-prove liveness, and allow the controller to retry if the first connection fails with an identity mismatch. - -Better fix: the relay stores the agent's public key on registration (sent alongside deviceId) and requires controllers to include the target's public key in the `shell` request. The relay only routes if the public keys match. This prevents squatting entirely. - -### C2. No shell-specific permission gate - -**Spec says:** "If `amesh list` on the target shows the controller, shell access is authorized." - -**Problem:** Pairing was designed for HTTP API authentication. Granting a shell is a much higher privilege level. Today, `amesh invite` pairs a controller for signing HTTP requests. After the remote shell feature ships, that same pairing silently grants full shell access. Users who paired devices for API auth did not consent to shell access. - -**Fix:** Add a `shell` permission flag to allow list entries. Default to `false` for existing pairings and new pairings. Require explicit opt-in: -```bash -amesh grant am_3d9f --shell # enable shell for this controller -amesh revoke-shell am_3d9f # or: amesh grant am_3d9f --no-shell -``` -The agent daemon checks `device.permissions.shell === true` before spawning the PTY. This is the single most important design change — without it, the feature has an implicit privilege escalation. - -### C3. Relay becomes a persistent presence oracle - -**Current relay:** Stateless. Sessions last up to 60 seconds (OTC expiry). An attacker monitoring the relay learns nothing about device availability. - -**Shell relay:** The `agentStore` is a persistent map of `deviceId → WebSocket`. An attacker who can enumerate this (via brute-force `shell` requests) learns which devices are online, when they come online/offline, and their device IDs. - -**Fix:** -- Rate-limit `shell` requests per IP (same as OTC rate limiting: 5 per minute per IP). -- Do not return `agent_not_found` vs `agent_found` — return the same response regardless and let the handshake timeout naturally if the agent isn't there. This prevents enumeration. -- Alternatively: require the controller to include a signed challenge in the `shell` request. The relay forwards it to the agent, and the agent decides whether to accept. This makes the relay a dumb pipe again. - ---- - -## HIGH — Should fix before production use - -### H1. No session idle timeout in the spec - -**Spec says:** Session key lives "for the duration of the shell." - -**Problem:** An abandoned shell session (controller crashes, network drops) keeps the PTY alive and the session key in memory indefinitely. The encrypted tunnel stays open on the relay, consuming resources. On the target, the PTY process runs forever. - -**Fix (already in Phase 5, but should be Phase 1):** -- Idle timeout: 30 minutes default. Agent closes PTY if no frames received. -- Application-level ping/pong (spec has `0x04`/`0x05`) with 30-second interval and 90-second deadline. -- On controller disconnect: relay notifies agent, agent kills PTY immediately. -- On agent disconnect: relay notifies controller, controller restores terminal and exits. - -### H2. The `-c` command mode is an injection vector - -**Spec says:** `amesh shell prod-api -c "uptime"` runs a single command. - -**Problem:** The command string is sent from the controller to the agent. If the agent passes it directly to a shell (`bash -c "..."`) without any validation, this is command injection by design — but that's the intended behavior (like `ssh host command`). The real risk: **the command crosses an encrypted channel, but the agent has no way to distinguish between an authorized command and a replayed/injected one.** - -This is fine because: -- The channel is authenticated (ECDH + allow list check) -- Each session has a unique session key (PFS) -- Nonces are monotonic counters (no replay within a session) - -However: if command whitelisting is ever added (per the permissions discussion), the whitelist check must happen on the agent side, not the controller side. The controller is untrusted — a modified client could send any command. - -**No fix needed now**, but document this trust boundary clearly in the spec. - -### H3. Handshake doesn't bind to device ID - -**Spec says:** Controller sends `{ type: 'shell', targetDeviceId: 'am_...' }` to the relay, then does ECDH + identity exchange. - -**Problem:** The relay routes based on `targetDeviceId`, but the ECDH handshake doesn't include the device IDs in the key derivation. The session key is derived from: -``` -HKDF(sharedSecret, 'amesh-handshake-v1', 'session-key', 32) -``` - -If a MITM relay substitutes a different target (one the attacker controls), the controller would detect it during identity exchange (the permanent public key wouldn't match the allow list). So this isn't exploitable. But it would be defense-in-depth to bind the session key to the expected device IDs: -``` -HKDF(sharedSecret, 'amesh-shell-v1', targetDeviceId + controllerDeviceId, 32) -``` - -This ensures the session key is only valid between the two intended parties, even if the ECDH shared secret were somehow identical (astronomically unlikely but theoretically possible with implementation bugs). - -**Fix:** Use a different HKDF context for shell sessions (separate from pairing) that includes both device IDs. Low effort, high defense-in-depth value. - -### H4. Nonce counter persisted across reconnects? - -**Spec says:** Incrementing 12-byte counter starting at 0 (controller) or 0x80... (target). - -**Problem:** If the WebSocket drops and reconnects, does the nonce counter reset to 0? If yes, nonce reuse with the same session key = catastrophic (ChaCha20-Poly1305 nonce reuse leaks plaintext via XOR of ciphertexts). - -**Fix:** A new session = new ECDH = new session key = nonce counter resets safely. The spec should explicitly state: **there is no session resumption. A dropped connection requires a full new handshake.** This is the simplest and most secure approach. Session resumption (reusing an existing session key) is complex and error-prone — don't do it. - ---- - -## MEDIUM — Good to fix - -### M1. Agent daemon as root is too easy to do accidentally - -**Spec says:** "The agent runs as the user who started it." - -**Problem:** `sudo amesh agent start` gives every controller root shells. This is one `sudo` away from total compromise. SSH mitigates this with `PermitRootLogin no` in sshd_config. - -**Fix:** Agent should refuse to run as root by default. Add `--allow-root` flag to override (like Docker's `--privileged`). Print a warning: "Running as root grants root shells to all authorized controllers." - -### M2. Concurrent session limit needs to be per-controller - -**Spec says (Phase 5):** "Max concurrent sessions per agent (configurable, default 5)." - -**Problem:** If 5 is the global limit and a malicious controller (whose key is in the allow list) opens 5 sessions, legitimate controllers are locked out. DoS by an authorized-but-misbehaving peer. - -**Fix:** Max sessions per controller (default 1) AND max total sessions per agent (default 5). A single controller can't monopolize all slots. - -### M3. Relay traffic analysis - -**Not in spec.** - -**Problem:** Even though content is encrypted, the relay can observe: -- Frame sizes (maps roughly to command output length) -- Frame timing (interactive typing has a distinctive pattern) -- Session duration -- Connection times (when the user is active) - -This is the same traffic analysis SSH faces over any network. It's not fixable without padding (which adds bandwidth cost). - -**Recommendation:** Document this as a known limitation. For high-security use, recommend a self-hosted relay on trusted infrastructure. This matches the existing recommendation for the pairing relay. - -### M4. The `-c` mode should sanitize shell metacharacters in the log - -**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). - -**Fix:** Sanitize the logged command string (strip non-printable characters, truncate to 200 chars). - ---- - -## LOW — Nice to have - -### L1. No session transcript/audit log beyond connection events - -The spec logs connection open/close but not what commands were run. For SOC2/compliance, session recording (like `script(1)` or Teleport's session recording) would be valuable. Not needed for v1 but worth designing the hook point. - -### L2. No forward secrecy for the agent registration - -The agent registers with the relay using its device ID. If the relay is compromised, the attacker knows which devices are connected. The registration itself doesn't need encryption (no secrets are transmitted), but consider using the relay's TLS connection as the confidentiality layer (wss://). - -This is already the case — the default relay URL is `wss://relay.authmesh.dev/ws`. - ---- - -## Summary Table - -| ID | Severity | Issue | Effort | -|----|----------|-------|--------| -| C1 | Critical | Agent registration unauthenticated — DoS/squatting | Medium | -| C2 | Critical | No shell permission gate — implicit privilege escalation | Low | -| C3 | Critical | Relay becomes presence oracle — device enumeration | Medium | -| H1 | High | No idle timeout — resource exhaustion | Low | -| H2 | High | `-c` mode trust boundary undocumented | Low (doc only) | -| H3 | High | Session key not bound to device IDs | Low | -| H4 | High | Nonce counter reset on reconnect unclear | Low (doc only) | -| M1 | Medium | Agent as root too easy | Low | -| M2 | Medium | Session limit should be per-controller | Low | -| M3 | Medium | Traffic analysis on relay | None (doc) | -| M4 | Medium | Log injection via command string | Low | - -**Recommendation:** Fix C1, C2, C3 in the spec before any code is written. They are design-level issues, not implementation bugs. H1-H4 should be addressed in Phase 1, not deferred to Phase 5. diff --git a/docs/remote-shell-spec.md b/docs/remote-shell-spec.md deleted file mode 100644 index 969b867..0000000 --- a/docs/remote-shell-spec.md +++ /dev/null @@ -1,376 +0,0 @@ -# amesh Remote Shell Specification - -**Status:** Draft -**Goal:** Replace SSH for machine access using amesh device identity. No SSH keys, no `sshd_config`, no `authorized_keys`. If two devices are paired, one can open a shell on the other. - ---- - -## 1. Why This Exists - -SSH key management is the last bastion of static secrets for many teams. You generate a key, copy it to `~/.ssh/authorized_keys`, and hope it doesn't get stolen or forgotten when someone leaves. SSH keys are: - -- **Not device-bound** — a private key can be copied to any machine -- **Not revocable per-device** — removing access means editing `authorized_keys` on every server -- **Not auditable** — logs show "key fingerprint X connected" but not which human or machine -- **Painful to provision** — new server? Copy keys. New team member? Add their key everywhere. - -amesh already solves these problems for HTTP APIs. The remote shell extends the same model to interactive terminal access. - ---- - -## 2. Scope - -**In scope:** -- Interactive shell (PTY) from controller to target over encrypted relay -- Reuses existing amesh device identity and trust model -- Terminal resize, stdin/stdout streaming, exit code propagation -- Works through NAT/firewalls via the relay - -**Out of scope (future):** -- File transfer (SCP/SFTP equivalent) -- Port forwarding -- Agent forwarding -- Session multiplexing (multiple shells over one connection) -- Direct P2P connection (NAT traversal without relay) - ---- - -## 3. Architecture - -``` -Controller (laptop) Relay Target (server) -───────────────── ───── ─────────────── -amesh shell am_7f2e amesh agent (daemon) - │ │ │ - │──── { type: 'shell', otc } ─────►│ │ - │ │◄── { type: 'listen' } ────│ (agent is always connected) - │◄──── { type: 'peer_found' } ────►│ │ - │ │ │ - │ ── Ephemeral ECDH key exchange (reuse existing pattern) ── │ - │ ── ChaCha20-Poly1305 encrypted tunnel established ─────── │ - │ ── Identity + selfSig exchange (encrypted) ────────────── │ - │ │ │ - │ ══ Encrypted shell session ════════════════════════════ ══ │ - │ stdin ──────────────────────────────────────────────► PTY │ - │ stdout ◄──────────────────────────────────────────── PTY │ - │ resize ─────────────────────────────────────────────► PTY │ - │ ◄──────────────────────────────────────────────── exit code │ - │ │ │ - │──── { type: 'done' } ───────────►│◄── { type: 'done' } ────│ -``` - -### Key differences from the pairing handshake: - -| Pairing handshake | Shell session | -|---|---| -| Both sides connect to relay on demand | Target agent maintains persistent relay connection | -| Ephemeral ECDH, session key discarded | Ephemeral ECDH, session key used for entire shell session | -| Tunnel closes after key exchange (~30s) | Tunnel stays open for shell duration (minutes/hours) | -| OTC generated by target, displayed to user | OTC not needed — controller specifies target device ID | -| SAS verification (user compares codes) | No SAS — trust already established via allow list | - ---- - -## 4. Trust Model - -Shell access reuses the existing one-way trust: - -- **Controller → Target:** A controller can open a shell on a paired target. The controller is already in the target's allow list with `role: "controller"`. -- **Target → Controller:** Cannot open a shell. One-way trust prevents compromised servers from accessing developer machines. -- **Authorization:** The agent daemon checks the allow list before spawning a PTY. Only devices with `role: "controller"` are permitted. - -No new pairing ceremony is needed. If `amesh list` on the target shows the controller, shell access is authorized. - ---- - -## 5. Components - -### 5.1 `amesh agent` (target-side daemon) - -A long-running process on the target machine that: - -1. Maintains a persistent WebSocket connection to the relay -2. Registers with a new message type: `{ type: 'agent', deviceId: 'am_...' }` -3. Waits for incoming shell requests from controllers -4. On connection: performs ECDH handshake, verifies controller identity against allow list -5. Spawns a PTY using `Bun.spawn()` with `terminal:` option -6. Streams encrypted I/O between the PTY and the relay tunnel - -```bash -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/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)` - -### 5.2 `amesh shell` (controller-side CLI) - -Opens an interactive shell to a paired target device. - -```bash -amesh shell am_7f2e8a1b # by device ID -amesh shell prod-api # by friendly name -amesh shell prod-api -c "uptime" # run single command, print output, exit -``` - -**Flow:** -1. Look up target in local allow list (must have `role: "target"`) -2. Connect to relay -3. Send `{ type: 'shell', targetDeviceId: 'am_...' }` to relay -4. Relay routes to the target's agent connection -5. Perform ECDH handshake (same as pairing, but no SAS — trust exists) -6. Verify target identity against allow list -7. Set local terminal to raw mode (`process.stdin.setRawMode(true)`) -8. Stream stdin → encrypted → relay → target PTY -9. Stream target PTY → encrypted → relay → stdout -10. On PTY exit: display exit code, restore terminal, close connection - -### 5.3 Relay extensions - -The relay needs minimal changes: - -**New message types:** -```typescript -| { type: 'agent', deviceId: string } // Agent registers with relay -| { type: 'shell', targetDeviceId: string } // Controller requests shell -| { type: 'agent_found' } // Relay confirms agent is online -| { type: 'agent_not_found' } // Target agent not connected -``` - -**New relay state:** -- `agentStore: Map` — tracks connected agents -- When a `shell` request arrives, relay looks up `targetDeviceId` in `agentStore` -- If found: links the controller WebSocket to the agent WebSocket (same as pairing) -- If not found: returns `{ type: 'agent_not_found' }` immediately - -**Agent heartbeat:** -- Agent sends `{ type: 'ping' }` every 30 seconds -- Relay responds with `{ type: 'pong' }` -- If no ping for 90 seconds, relay removes agent from `agentStore` - -**No changes to the `data` forwarding logic.** The relay still forwards opaque blobs. It doesn't know or care that the encrypted content is terminal I/O. - ---- - -## 6. Shell Protocol (over encrypted tunnel) - -All messages inside the encrypted tunnel use a binary frame format: - -``` -┌──────────┬──────────────────────────────┐ -│ type (1B)│ payload (variable) │ -└──────────┴──────────────────────────────┘ -``` - -**Frame types:** - -| Type byte | Name | Payload | Direction | -|-----------|------|---------|-----------| -| `0x01` | data | Raw terminal bytes | Both directions (stdin/stdout) | -| `0x02` | resize | `{ cols: u16, rows: u16 }` (4 bytes, big-endian) | Controller → Target | -| `0x03` | exit | `{ code: i32 }` (4 bytes, big-endian) | Target → Controller | -| `0x04` | ping | (empty) | Both directions | -| `0x05` | pong | (empty) | Both directions | - -**Encryption:** Each frame is encrypted as a single ChaCha20-Poly1305 message: -``` -encrypt(sessionKey, nonce, type_byte || payload) → ciphertext -``` - -**Nonce strategy:** Incrementing 12-byte counter (not random). Each side maintains its own send counter starting at 0. This provides ordering guarantees and prevents nonce reuse. -- Controller send nonce: starts at `000000000000`, increments by 1 -- Target send nonce: starts at `800000000000` (high bit set), increments by 1 -- This ensures the two sides never produce the same nonce - ---- - -## 7. ECDH Handshake (Shell Variant) - -The shell handshake is a simplified version of the pairing handshake: - -1. **Ephemeral key exchange** — both sides generate ephemeral P-256 keypairs, exchange public keys over the relay, compute the shared secret via ECDH, and derive a session key via HKDF. -2. **Identity exchange with transcript-bound signature** — both sides send an encrypted PeerIdentity envelope containing a `selfSig` that covers BOTH the peer identity fields AND a hash of the current ECDH transcript (both ephemeral public keys). See §7.1 below for the exact signed bytes. -3. **Authorization check** — each side verifies the peer is in its allow list: - - Target checks: controller's permanent public key is in allow list with `role: "controller"` and `permissions.shell: true` - - Controller checks: target's permanent public key is in allow list with `role: "target"` -4. **No interactive SAS** — trust is pre-established via the pairing ceremony. Re-verifying SAS on every shell connection would be unusable. -5. **Session begins** — tunnel transitions from handshake mode to shell mode (frame protocol above) - -### 7.1 Transcript-bound `selfSig` - -**Security-critical.** The `selfSig` in the shell handshake's PeerIdentity envelope is NOT a signature over the identity fields alone — it is a signature over the identity fields concatenated with a hash of the ECDH transcript each side actually observed. This is the fix for audit finding **C1** (pre-fix, the selfSig covered only `pub + name + timestamp`, letting a MITM relay replay a captured envelope between the two ECDH legs). - -**Canonical signed bytes** — domain prefix `amesh-shell-v1`: - -``` -signedBytes = - "amesh-shell-v1\n" || - publicKeyBase64 || "\n" || - deviceId || "\n" || - friendlyName || "\n" || - timestamp || "\n" || - sha256(signerEphPub || verifierEphPub) -``` - -Where: -- `signerEphPub` is the ephemeral public key the signer PUT on the wire (its own ephemeral) -- `verifierEphPub` is the ephemeral public key the signer RECEIVED from the peer - -The verifier reconstructs the same transcript using the ephemeral keys IT observed: its peer's ephemeral as `signerEphPub`, its own ephemeral as `verifierEphPub`. A MITM forwarding between two ECDH legs sees different ephemerals on each leg, so a signature produced for one leg will not verify on the other. - -**Domain separation.** The `amesh-shell-v1` prefix prevents cross-protocol signature reuse with the pairing handshake (which uses SAS for an orthogonal purpose) or any future selfSig format. - ---- - -## 8. Security Considerations - -### What the agent daemon exposes - -The agent grants shell access to any controller in the allow list. This is equivalent to having the controller's SSH public key in `authorized_keys`. The security boundary is the allow list: - -- **Revoking access:** `amesh revoke ` on the target removes the controller. Next shell attempt is rejected. -- **No root by default:** The agent runs as the user who started it. The spawned shell inherits that user's permissions. There is no privilege escalation. -- **Audit trail:** Every shell connection is logged with controller device ID, friendly name, timestamp, and duration. - -### Relay trust model (unchanged) - -The relay cannot read shell content — it's encrypted with ChaCha20-Poly1305. The relay cannot impersonate either side because the transcript-bound `selfSig` (§7.1) prevents it from replaying a captured identity envelope across its two ECDH legs. The relay can: -- Know that a shell session exists between two device IDs -- Measure session duration and data volume -- Drop or delay traffic (DoS, not data theft) - -This matches the existing relay trust model for pairing. - -### Frame cipher desync resistance (H3) - -The ShellCipher wrapping each tunnel uses a ChaCha20-Poly1305 stream with per-direction incrementing nonces. Receive-counter advancement happens ONLY after successful Poly1305 verification — an injected or malformed frame from an untrusted relay is rejected without shifting the counter, so the next legitimate frame still decrypts cleanly. (Fix for audit finding H3; see `packages/agent/src/shell-cipher.ts`.) - -### Session key lifecycle - -- Ephemeral ECDH keys are generated per shell session (PFS) -- Session key exists in memory only for the duration of the shell -- Closing the shell zeros the session key -- A new shell connection generates a new session key (no reuse across sessions) - -### Nonce exhaustion - -With a 12-byte incrementing nonce and the high-bit split, each side can send 2^95 frames before nonce exhaustion. At 1 million frames per second, that's ~10^22 years. Not a concern. - ---- - -## 9. CLI UX - -### Starting the agent (target) - -``` -$ amesh agent start - amesh agent listening on relay.authmesh.dev - Device: am_7f2e8a1b (prod-api) - Authorized controllers: 2 - - Press Ctrl+C to stop. -``` - -### Opening a shell (controller) - -``` -$ amesh shell prod-api - Connecting to prod-api (am_7f2e8a1b)... - Connected. Shell session started. - -user@prod-api:~$ whoami -user -user@prod-api:~$ exit - Session closed (exit code 0, duration 2m 14s). -``` - -### Running a single command - -``` -$ amesh shell prod-api -c "df -h" -Filesystem Size Used Avail Use% Mounted on -/dev/sda1 50G 12G 35G 26% / - -$ echo $? -0 -``` - -### Agent not running - -``` -$ amesh shell prod-api - Error: agent not connected for prod-api (am_7f2e8a1b). - Start the agent on the target: amesh agent start -``` - ---- - -## 10. Implementation Plan - -### Phase 1 — Relay extensions -- Add `agentStore` to relay -- Handle `agent`, `shell`, `agent_found`, `agent_not_found` message types -- Add ping/pong heartbeat -- Tests: agent registration, shell routing, agent timeout - -### Phase 2 — Shell handshake -- Extract ECDH + identity exchange from `handshake.ts` into reusable module -- Create `shell-handshake.ts` with the simplified flow (no OTC, no SAS) -- Add allow list authorization check -- Tests: handshake succeeds for paired devices, fails for unknown devices - -### Phase 3 — Target agent daemon -- `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) -- Tests: agent starts, accepts shell, streams I/O, handles disconnect - -### Phase 4 — Controller shell command -- `amesh shell ` command (oclif) -- Raw mode terminal setup -- Encrypted I/O streaming -- Resize handling (`process.stdout.on('resize')`) -- Single command mode (`-c`) -- Tests: end-to-end shell session - -### Phase 5 — Production hardening -- Agent as systemd service (unit file template) -- Connection metrics (sessions opened, bytes transferred, duration) -- Max concurrent sessions per agent (configurable, default 5) -- Idle session timeout (configurable, default 30 minutes) -- Graceful shutdown (drain active sessions on SIGTERM) - ---- - -## 11. Dependencies - -| Dependency | Purpose | New? | -|---|---|---| -| `Bun.spawn({ terminal: })` | PTY allocation | No (Bun built-in since 1.3.5) | -| `@noble/curves` | ECDH key exchange | No (existing) | -| `@noble/ciphers` | ChaCha20-Poly1305 encryption | No (existing) | -| `@noble/hashes` | SHA-256, HKDF | No (existing) | -| `@authmesh/core` | Crypto primitives | No (existing) | -| `@authmesh/keystore` | Allow list, key storage | No (existing) | - -**Zero new dependencies.** Everything needed is already in the project or built into Bun. - ---- - -## 12. What This Is NOT - -- **Not a full SSH replacement** — no port forwarding, agent forwarding, or SFTP. Those can be added later. -- **Not a VPN** — amesh shell is for interactive terminal access, not network-level tunneling. -- **Not a bastion host** — each controller connects directly to the target (via relay). There's no central gateway. -- **Not competing with Tailscale/Teleport** — those are network-layer solutions. amesh operates at the application layer with zero infrastructure requirements. - -This is **SSH key management, simplified.** Same security model (asymmetric crypto, per-device keys, explicit authorization), but the pairing ceremony replaces manual key distribution, and revocation is instant and per-device. diff --git a/docs/security-audit-2026-04.md b/docs/security-audit-2026-04.md index 573f9bb..9c9a13f 100644 --- a/docs/security-audit-2026-04.md +++ b/docs/security-audit-2026-04.md @@ -1,5 +1,7 @@ # amesh Security Audit — 2026-04-05 +> **Note (v0.7.0):** The remote shell feature (agent daemon, shell cipher, frame protocol) was removed in v0.7.0. Findings C1, H3, M4, and L3 are no longer applicable — the code they targeted no longer exists. + **Scope:** Full external-pen-tester-style review of the v0.4.0 codebase at commit `d59be06`. Covered `packages/core`, `packages/keystore`, `packages/sdk`, `packages/relay`, and `packages/agent`/`packages/cli` shell + handshake + bootstrap flows. **Threat model:** Attacker with network access + compromised peer OR compromised/untrusted relay. This matches the protocol spec's stated threat model — the relay is explicitly untrusted (see `docs/architecture-decisions.md`). diff --git a/firebase.json b/firebase.json index f3032c3..fbe6de0 100644 --- a/firebase.json +++ b/firebase.json @@ -22,15 +22,6 @@ } ] }, - { - "source": "/install-agent", - "headers": [ - { - "key": "Content-Type", - "value": "text/plain; charset=utf-8" - } - ] - } ] } } diff --git a/landpage/src/lib/navigation.ts b/landpage/src/lib/navigation.ts index 8a4ae33..ec9c2f6 100644 --- a/landpage/src/lib/navigation.ts +++ b/landpage/src/lib/navigation.ts @@ -52,7 +52,6 @@ export const docSections: DocSection[] = [ items: [ { slug: 'key-storage', title: 'Key Storage', desc: 'Secure Enclave, TPM, encrypted file — tiered auto-detection' }, { slug: 'self-hosting', title: 'Self-Hosting', desc: 'Docker, Cloud Run, Fly.io, Kubernetes' }, - { slug: 'remote-shell', title: 'Remote Shell', desc: 'Agent setup, shell access, security model' }, ], }, { @@ -79,7 +78,6 @@ export const useCasePages: NavItem[] = [ { slug: 'webhooks', title: 'Webhooks', desc: 'Prove sender identity' }, { slug: 'cron-jobs', title: 'Cron Jobs', desc: 'Scheduled task identity' }, { slug: 'internal-tools', title: 'Internal Tools', desc: 'Per-developer audit trail' }, - { slug: 'remote-shell', title: 'Remote Shell', desc: 'SSH-like access with device identity' }, ]; export function getDocNav(currentSlug: string): { prev?: NavLink; next?: NavLink } { diff --git a/landpage/src/routes/blog/introducing-amesh-0-3/+page.svelte b/landpage/src/routes/blog/introducing-amesh-0-3/+page.svelte index ad4f115..47169cc 100644 --- a/landpage/src/routes/blog/introducing-amesh-0-3/+page.svelte +++ b/landpage/src/routes/blog/introducing-amesh-0-3/+page.svelte @@ -107,10 +107,10 @@ Identity created. Several quiet-but-important fixes landed across 0.3.x:

    -
  • macOS Keychain stale key accumulation (0.3.1). SecItemDelete only removes one item per call; multiple amesh init --force runs were leaving orphaned keys in the Keychain under the same tag, which caused selfSig verification failed on remote peers during pairing and shell handshakes. We now loop the delete until all matching items are cleared.
  • +
  • macOS Keychain stale key accumulation (0.3.1). SecItemDelete only removes one item per call; multiple amesh init --force runs were leaving orphaned keys in the Keychain under the same tag, which caused selfSig verification failed on remote peers during pairing. We now loop the delete until all matching items are cleared.
  • Passphrase stripped from memory (0.3.0). At all 6 call sites where a KeyStore is constructed from an identity, we now delete identity.passphrase after the keystore is built. The passphrase lives on disk, not in memory any longer than necessary.
  • Atomic write for identity.json (0.3.0). The SDK bootstrap path now uses tmp + rename instead of a direct write, so a crash during init can't leave you with a half-written identity file.
  • -
  • Device ID derivation consistency (0.3.3). invite and listen were deriving device IDs with raw base64url(pubkey) while init used SHA-256(pubkey) per the protocol spec. The relay could never match the controller's allow list entry to the agent's registration, silently breaking shell routing. All commands now use generateDeviceId(). Existing pairings need re-pairing — this is the one migration note of the series.
  • +
  • Device ID derivation consistency (0.3.3). invite and listen were deriving device IDs with raw base64url(pubkey) while init used SHA-256(pubkey) per the protocol spec. All commands now use generateDeviceId(). Existing pairings need re-pairing — this is the one migration note of the series.

Honest messaging

diff --git a/landpage/src/routes/blog/why-we-built-amesh/+page.svelte b/landpage/src/routes/blog/why-we-built-amesh/+page.svelte index e163017..1261232 100644 --- a/landpage/src/routes/blog/why-we-built-amesh/+page.svelte +++ b/landpage/src/routes/blog/why-we-built-amesh/+page.svelte @@ -163,7 +163,7 @@
  • It's not for humans. Users log in with OAuth, passkeys, or WebAuthn, not amesh.
  • It's not for ephemeral compute. Lambda, Cloud Functions, and short-lived containers don't have a stable device to bind to.
  • It's not post-quantum. P-256 is chosen because it's the most broadly supported in hardware in 2026; we'll add ML-DSA once hardware catches up, and the protocol already versions the signature algorithm.
  • -
  • It's not a full SSH replacement. The remote shell feature is an alternative to SSH keys, not to SSH itself — no port forwarding, no SFTP, no tunneling.
  • +
  • It's not a network tool. amesh authenticates API requests, not network connections — no port forwarding, no tunneling.
  • The security model in one sentence

    diff --git a/landpage/src/routes/docs/+page.svelte b/landpage/src/routes/docs/+page.svelte index b098e2b..6bb1728 100644 --- a/landpage/src/routes/docs/+page.svelte +++ b/landpage/src/routes/docs/+page.svelte @@ -1,5 +1,5 @@ - - - Remote Shell Guide — amesh - - - - - - {@html jsonLdScript(graph( - breadcrumbList([ - { name: 'Home', url: '/' }, - { name: 'Docs', url: '/docs' }, - { name: 'Remote Shell', url: '/docs/remote-shell' } - ]), - techArticle({ - title: 'Remote Shell Guide: Secure Shell Access with Device Identity', - description: 'Set up secure remote shell access with amesh. Agent daemon, shell client, permissions, and security model.', - url: '/docs/remote-shell', - section: 'Guides' - }) - ))} - - -
    - -
    - -

    Remote Shell Guide

    -

    SSH-like remote access using amesh device identity. No SSH keys, no authorized_keys, instant revocation.

    - -
    - - -
    -

    Install

    -

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

    - - -
    -
    - {#each installMethods as method, i} - - {/each} -
    - -
    -
    Both machines (controller + server)
    -
    - {installMethods[activeInstallMethod].command} - -
    -
    -
    -
    - - -
    -

    Setup

    -

    Two steps: pair with shell access, then start the agent.

    - -

    1. Pair devices with shell access

    -
    - # On the target (server) — pair and grant shell in one step -amesh listen --shell - -# On the controller (your laptop) -amesh invite 482916`} /> -
    -

    The --shell flag auto-grants shell access when pairing completes. Without it, you can grant later with amesh grant <device-id> --shell.

    - -

    2. Start the agent

    -
    - # On the target (server) — start the agent daemon -amesh agent start - -# Stop when done -amesh agent stop`} /> -
    -
    - - -
    -

    Platform Support

    -

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

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    PlatformInstall viaNotes
    macOS (arm64)Homebrew · npm · tarballApple Silicon; uses Secure Enclave when signed
    macOS (x64)Homebrew · npm · tarballIntel macs; falls back to Keychain
    Linux (x64)Homebrew · npm · tarball · .debMost cloud VMs; uses TPM 2.0 when available
    Linux (arm64)Homebrew · npm · tarballRaspberry Pi 4/5 on 64-bit Pi OS, Ampere, Graviton
    Linux (armv7, 32-bit)Bun wrapper onlyRaspberry Pi 3 and earlier — see note below
    -
    -

    - 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. -

    -
    - - -
    -

    Usage

    - -

    Interactive shell

    -
    - $ amesh shell prod-api - Connecting to prod-api (am_7f2e8a1b)... - Connected. Shell session started. - -user@prod-api:~$ whoami -user -user@prod-api:~$ exit - Session closed (exit code 0, duration 2m 14s).`} /> -
    - -

    Single command

    -
    - $ amesh shell prod-api -c "df -h" -Filesystem Size Used Avail Use% Mounted on -/dev/sda1 50G 12G 35G 26% /`} /> -
    -
    - - -
    -

    Security Model

    -
    -
    -
    End-to-end encrypted
    -
    ChaCha20-Poly1305 with per-session ephemeral ECDH keys. The relay forwards opaque blobs — it cannot read shell content.
    -
    -
    -
    Perfect forward secrecy
    -
    Each shell session generates new ephemeral P-256 keys. Compromising a session key does not affect past or future sessions.
    -
    -
    -
    Device-ID-bound session keys
    -
    Session keys are derived via HKDF with both device IDs baked in. A session key is only valid between the two intended parties.
    -
    -
    -
    Explicit shell permission
    -
    Pairing for API auth does not grant shell access. Shell requires amesh grant --shell.
    -
    -
    -
    No root by default
    -
    The agent refuses to run as root unless --allow-root is passed. The spawned shell inherits the agent's user permissions.
    -
    -
    -
    - - -
    -

    Environment Variables

    -
    - {#each [ - { name: 'AUTH_MESH_DIR', desc: 'Directory for identity and keys', def: '~/.amesh/' }, - { name: 'AUTH_MESH_PASSPHRASE', desc: 'Supply the encrypted-file backend passphrase at runtime — preferred for production so the secret never touches disk', def: 'optional' }, - { name: 'AMESH_PASSPHRASE_FILE', desc: 'Relocate the persisted passphrase file (default ~/.amesh/.passphrase, mode 0400)', def: 'optional' }, - { name: 'AMESH_RELAY_URL', desc: 'WebSocket relay URL', def: 'wss://relay.authmesh.dev/ws' }, - ] as env} -
    - {env.name} -
    {env.desc} Default: {env.def}
    -
    - {/each} -
    -
    - - -
    -

    Troubleshooting

    -
    -
    -
    "Shell access not granted for this device"
    -
    The controller is paired but doesn't have shell permission. Run amesh grant <device-id> --shell on the target.
    -
    -
    -
    "Handshake failed" / connection timeout
    -
    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 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"
    -
    The agent defaults to non-root. Use --allow-root if you understand the risk (grants root shells to all controllers).
    -
    -
    -
    - - - - -
    diff --git a/landpage/src/routes/docs/self-hosting/+page.svelte b/landpage/src/routes/docs/self-hosting/+page.svelte index 5a738c0..2e79abb 100644 --- a/landpage/src/routes/docs/self-hosting/+page.svelte +++ b/landpage/src/routes/docs/self-hosting/+page.svelte @@ -177,8 +177,8 @@ gcloud run deploy amesh-relay \\
    The relay forwards opaque ChaCha20-Poly1305 blobs. It cannot read the key exchange.
    -
    MITM protection (pairing and shell)
    -
    Pairing uses a 6-digit SAS the user confirms across both devices. The shell handshake binds its identity signature to the ECDH ephemeral transcript — a relay forwarding captured envelopes between two legs produces signatures that don't verify on the receiving leg.
    +
    MITM protection
    +
    Pairing uses a 6-digit SAS the user confirms across both devices. A relay cannot inject its own keys without the SAS mismatch being caught.
    Rate limiting (per client IP)
    diff --git a/landpage/src/routes/docs/troubleshooting/+page.svelte b/landpage/src/routes/docs/troubleshooting/+page.svelte index 92f03b0..3aecd9b 100644 --- a/landpage/src/routes/docs/troubleshooting/+page.svelte +++ b/landpage/src/routes/docs/troubleshooting/+page.svelte @@ -14,7 +14,6 @@ { id: 'signing', label: 'Signing & verification errors' }, { id: 'pairing', label: 'Pairing errors' }, { id: 'backend', label: 'Key storage backend errors' }, - { id: 'shell', label: 'Remote shell errors' }, { id: 'relay', label: 'Self-hosted relay errors' }, { id: 'diagnostics', label: 'Diagnostic commands' }, ]; @@ -27,7 +26,7 @@ Troubleshooting — amesh - + @@ -133,33 +132,6 @@
    -
    -

    Remote shell errors

    -
    -
    -
    "Shell access not granted for this device"
    -

    The controller is paired but doesn't have shell permission. The easiest fix: re-pair with amesh listen --shell which auto-grants shell access. Or grant it separately: amesh grant <device-id> --shell.

    -
    -
    -
    "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 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. -

    -
    -
    -
    "Handshake failed" / connection timeout
    -

    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"
    -

    The agent defaults to non-root for safety. If you genuinely need root shells (and understand the blast radius), start the agent with --allow-root.

    -
    -
    -
    -

    Self-hosted relay errors

    diff --git a/landpage/src/routes/use-cases/+page.svelte b/landpage/src/routes/use-cases/+page.svelte index 8d4f47e..3628bd9 100644 --- a/landpage/src/routes/use-cases/+page.svelte +++ b/landpage/src/routes/use-cases/+page.svelte @@ -1,5 +1,5 @@ diff --git a/landpage/src/routes/use-cases/remote-shell/+page.svelte b/landpage/src/routes/use-cases/remote-shell/+page.svelte deleted file mode 100644 index 32b2a10..0000000 --- a/landpage/src/routes/use-cases/remote-shell/+page.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - Remote Shell Without SSH Keys — amesh - - - - - - {@html jsonLdScript(graph( - breadcrumbList([ - { name: 'Home', url: '/' }, - { name: 'Use Cases', url: '/use-cases' }, - { name: 'Remote Shell', url: '/use-cases/remote-shell' } - ]), - techArticle({ - title: 'Remote Shell Without SSH Keys', - description: 'SSH-like remote access with device-bound identity. No SSH keys, no authorized_keys, instant per-device revocation.', - url: '/use-cases/remote-shell', - section: 'Use Cases' - }) - ))} - - -# Same binary on both machines -brew install ameshdev/tap/amesh -# or: npm install -g @authmesh/cli -# or: curl -fsSL https://authmesh.dev/install | sh` }, - { filename: 'Terminal (target)', code: `# On the server — start the agent daemon -$ amesh agent start - - amesh agent listening on relay.authmesh.dev - Device: am_7f2e8a1b (prod-api) - Authorized controllers: 2 - - Waiting for shell requests...` }, - { filename: 'Terminal (controller)', code: `# On your laptop — open a shell -$ amesh shell prod-api - - Connecting to prod-api (am_7f2e8a1b)... - Connected. Shell session started. - -user@prod-api:~$ whoami -user -user@prod-api:~$ exit - Session closed (exit code 0, duration 2m 14s).` }, - { filename: 'Pair + grant', code: `# On the server — pair and grant shell in one step -$ amesh listen --shell - -# On your laptop -$ amesh invite 482916 - "prod-api" added as target. - -# Revoke when someone leaves — instant, one command -$ amesh revoke am_3d9f1a2e - Removed. Access revoked immediately.` }, - ]} - changes={[ - { before: 'SSH keys are copyable files', after: 'Key is in the device — Keychain, TPM, or encrypted' }, - { before: 'authorized_keys is a plain text file', after: 'HMAC-sealed allow list with tamper detection' }, - { before: 'Revoke = edit every server', after: 'amesh revoke . Instant. One command.' }, - ]} -/> diff --git a/landpage/static/install-agent b/landpage/static/install-agent deleted file mode 100644 index 1d55c4f..0000000 --- a/landpage/static/install-agent +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh -# amesh-agent is now part of the main amesh binary. -# Redirecting to the unified install script. -# -# 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/landpage/static/sitemap.xml b/landpage/static/sitemap.xml index 9633b2b..2336a9d 100644 --- a/landpage/static/sitemap.xml +++ b/landpage/static/sitemap.xml @@ -90,14 +90,4 @@ 2026-04-02 0.7 - - https://authmesh.dev/use-cases/remote-shell - 2026-04-03 - 0.7 - - - https://authmesh.dev/docs/remote-shell - 2026-04-03 - 0.8 - diff --git a/packages/cli/README.md b/packages/cli/README.md index a1f2c95..9fc3b23 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @authmesh/cli -Unified CLI for [amesh](https://github.com/ameshdev/amesh) — device identity, remote shell, and file transfer. +CLI for [amesh](https://github.com/ameshdev/amesh) — device identity management and pairing. ## Install @@ -12,30 +12,24 @@ npm install -g @authmesh/cli ```bash amesh init --name "prod-api" # Create a device identity -amesh listen --shell # Start pairing + grant shell access +amesh listen # Start pairing (target side) 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 ``` ## Pairing flow On the target machine: ```bash -$ amesh listen --shell +$ amesh listen 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: diff --git a/packages/cli/package.json b/packages/cli/package.json index 6691e47..4450ec1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@authmesh/cli", "version": "0.6.0", - "description": "CLI for amesh — device identity, remote shell, agent daemon", + "description": "CLI for amesh — device identity management and pairing", "type": "module", "license": "MIT", "author": "Yair Etzion", diff --git a/packages/cli/src/__tests__/frame.test.ts b/packages/cli/src/__tests__/frame.test.ts deleted file mode 100644 index 192d283..0000000 --- a/packages/cli/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/cli/src/__tests__/message-reader-dispose.test.ts b/packages/cli/src/__tests__/message-reader-dispose.test.ts deleted file mode 100644 index a6cd5c3..0000000 --- a/packages/cli/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/cli/src/__tests__/shell-cipher.test.ts b/packages/cli/src/__tests__/shell-cipher.test.ts deleted file mode 100644 index 67880ee..0000000 --- a/packages/cli/src/__tests__/shell-cipher.test.ts +++ /dev/null @@ -1,139 +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. - const garbage = new Uint8Array(32); - 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/cli/src/__tests__/shell-handshake-sig.test.ts b/packages/cli/src/__tests__/shell-handshake-sig.test.ts deleted file mode 100644 index 516379f..0000000 --- a/packages/cli/src/__tests__/shell-handshake-sig.test.ts +++ /dev/null @@ -1,120 +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(); - const legitAgentEph = randomEphemeralPub(); - const attackerEph = randomEphemeralPub(); - - const timestamp = new Date().toISOString(); - - const msgForLegA = buildShellSigMessage({ - publicKey: controller.publicKeyBase64, - deviceId: controller.deviceId, - friendlyName: controller.friendlyName, - timestamp, - signerEphPub: legitControllerEph, - verifierEphPub: legitAgentEph, - }); - const sig = signMessage(controller.privateKey, msgForLegA); - - expect(verifyMessage(sig, msgForLegA, controller.publicKey)).toBe(true); - - const msgAsSeenByAgent = buildShellSigMessage({ - publicKey: controller.publicKeyBase64, - deviceId: controller.deviceId, - friendlyName: controller.friendlyName, - timestamp, - signerEphPub: attackerEph, - 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', () => { - 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); - - const oldFormatMsg = new TextEncoder().encode(id.publicKeyBase64 + id.friendlyName + timestamp); - expect(verifyMessage(sig, oldFormatMsg, id.publicKey)).toBe(false); - }); -}); diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts deleted file mode 100644 index f56985e..0000000 --- a/packages/cli/src/agent.ts +++ /dev/null @@ -1,376 +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 type { Identity } from './identity.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, - encodeDataFrame, - encodeExitFrame, - encodePongFrame, - parseFrame, - parseResize, -} from './frame.js'; - -interface AgentOptions { - relayUrl: string; - allowRoot: boolean; - idleTimeoutMinutes: number; -} - -function sanitizeForLog(str: string, maxLen = 200): string { - // Strip non-printable characters and truncate - return str.replace(/[^\x20-\x7E]/g, '').slice(0, maxLen); -} - -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(' Running as root grants root shells to all authorized controllers.'); - console.error(' Use --allow-root to override.'); - process.exit(1); - } - - 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); - - /** - * Represents an in-flight shell session. Owned by the WebSocket that opened - * it — on WS close (M4), the owning connect() scope tears it down so the - * 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 }; - }; - cipher: ShellCipher; - idleCheck: ReturnType; - messageHandler: (event: MessageEvent) => void; - } - - let sessionActive = false; - // Current active session, if any. Scoped to the outer closure so the - // connect()'s ws.close handler can tear it down. - let activeSession: ActiveSession | null = null; - - function teardownActiveSession(reason: string): void { - if (!activeSession) return; - console.log(`[amesh agent] Tearing down active session (${reason})`); - try { - activeSession.proc.kill(); - } catch { - /* already exited */ - } - clearInterval(activeSession.idleCheck); - try { - activeSession.cipher.close(); - } catch { - /* already closed */ - } - activeSession = null; - sessionActive = false; - } - - // 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; - const maxReconnectDelay = 30000; - - function connect(): void { - const ws = new WebSocket(opts.relayUrl); - - ws.addEventListener('open', () => { - reconnectDelay = 1000; - // Step 1: Send registration request — relay will issue a challenge - send(ws, { - type: 'agent', - deviceId: identity.deviceId, - publicKey: identity.publicKey, - }); - - // Heartbeat - const pingInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - send(ws, { type: 'ping' }); - } - }, 30_000); - - ws.addEventListener('close', () => { - clearInterval(pingInterval); - }); - }); - - ws.addEventListener('message', async (event: MessageEvent) => { - const raw = typeof event.data === 'string' ? event.data : String(event.data); - let msg; - try { - msg = JSON.parse(raw); - } catch { - return; - } - - // Step 2: Relay issues challenge — sign it to prove key ownership - if (msg.type === 'agent_challenge') { - const challenge = new TextEncoder().encode(msg.challenge as string); - const sig = await signFn(challenge); - send(ws, { - type: 'agent_challenge_response', - sig: Buffer.from(sig).toString('base64url'), - }); - return; - } - - if (msg.type === 'agent_registered') { - 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}`); - return; - } - - if (msg.type === 'pong') return; - - if (msg.type === 'peer_found') { - if (sessionActive) { - console.error('[amesh agent] Session already active, rejecting'); - return; - } - sessionActive = true; - handleShellRequest(ws, allowList, identity, signFn, opts.idleTimeoutMinutes) - .catch(() => {}) - .finally(() => { - sessionActive = false; - activeSession = null; - }); - return; - } - }); - - ws.addEventListener('close', () => { - // M4 — tear down any active shell session on disconnect. Previously the - // bash process kept running, `sessionActive` stayed true, and reconnect - // could never accept a new session until idle timeout fired. - if (activeSession) { - teardownActiveSession('ws_disconnect'); - } - console.log(`[amesh agent] Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`); - setTimeout(connect, reconnectDelay); - reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); - }); - - ws.addEventListener('error', () => { - // close event will fire after error, triggering reconnect - }); - } - - async function handleShellRequest( - ws: WebSocket, - al: AllowList, - id: Identity, - sign: (message: Uint8Array) => Promise, - idleTimeoutMin: number, - ): Promise { - const reader = createMessageReader(ws); - - try { - const result = await runAgentShellHandshake( - ws, - reader, - id.deviceId, - id.publicKey, - id.friendlyName, - sign, - al, - ); - - // M4 — drop the handshake reader NOW. Its message listener would - // otherwise accumulate every encrypted shell frame into an unread - // queue for the rest of the session (unbounded memory growth). - reader.dispose(); - - const startTime = Date.now(); - - console.log( - `[amesh agent] Shell opened by ${result.peerDeviceId} (${result.peerFriendlyName})`, - ); - - // Set up encrypted cipher + zero the handshake result copy (L3 fix) - const cipher = new ShellCipher(result.sessionKey, 'target'); - result.sessionKey.fill(0); - - // Spawn PTY - const cols = process.stdout.columns ?? 80; - const rows = process.stdout.rows ?? 24; - - const proc = Bun.spawn(['bash'], { - terminal: { - cols, - rows, - data(_terminal: unknown, data: Uint8Array) { - // PTY stdout → encrypt → send - const frame = encodeDataFrame(data); - const encrypted = cipher.encrypt(frame); - if (ws.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ - type: 'data', - payload: Buffer.from(encrypted).toString('base64'), - }), - ); - } - }, - }, - }); - - // Idle timeout (H1 fix) - let lastActivity = Date.now(); - const idleCheck = setInterval(() => { - if (Date.now() - lastActivity > idleTimeoutMin * 60_000) { - console.log(`[amesh agent] Idle timeout for ${result.peerDeviceId}`); - proc.kill(); - } - }, 30_000); - - // Receive encrypted frames from controller - const messageHandler = (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; - lastActivity = Date.now(); - - try { - const decrypted = cipher.decrypt(Buffer.from(msg.payload, 'base64')); - const { type, payload } = parseFrame(decrypted); - - switch (type) { - case FrameType.DATA: - proc.terminal?.write(payload); - break; - case FrameType.RESIZE: { - const { cols: c, rows: r } = parseResize(payload); - proc.terminal?.resize(c, r); - break; - } - case FrameType.PING: { - const pong = cipher.encrypt(encodePongFrame()); - ws.send( - JSON.stringify({ type: 'data', payload: Buffer.from(pong).toString('base64') }), - ); - break; - } - case FrameType.COMMAND: { - const cmd = new TextDecoder().decode(payload); - console.log( - `[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); - } - }; - ws.addEventListener('message', messageHandler); - - // Register with the outer scope so the ws.close handler (M4 teardown) - // can kill the proc, clear the timer, and close the cipher if the - // relay disconnects mid-session. - activeSession = { - proc: proc as ActiveSession['proc'], - cipher, - idleCheck, - messageHandler, - }; - - // Wait for process exit - const exitCode = await proc.exited; - clearInterval(idleCheck); - ws.removeEventListener('message', messageHandler); - - // Send exit frame - try { - const exitFrame = cipher.encrypt(encodeExitFrame(exitCode)); - if (ws.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ type: 'data', payload: Buffer.from(exitFrame).toString('base64') }), - ); - } - } catch { - /* cipher may be closed */ - } - - const duration = Math.round((Date.now() - startTime) / 1000); - console.log( - `[amesh agent] Shell closed for ${result.peerDeviceId} (exit=${exitCode}, duration=${duration}s)`, - ); - - cipher.close(); - activeSession = null; - // sessionActive reset by .finally() in caller - } catch (err) { - reader.dispose(); - console.error('[amesh agent] Shell handshake failed:', (err as Error).message); - // sessionActive reset by .finally() in caller - } - } - - connect(); - - // Keep process alive - await new Promise(() => {}); -} diff --git a/packages/cli/src/commands/agent/start.ts b/packages/cli/src/commands/agent/start.ts deleted file mode 100644 index fd18f0a..0000000 --- a/packages/cli/src/commands/agent/start.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Command, Flags } from '@oclif/core'; - -export default class AgentStart extends Command { - static override description = 'Start the amesh agent daemon (accepts remote shell connections)'; - - static override flags = { - relay: Flags.string({ - char: 'r', - description: 'Relay server URL', - default: 'wss://relay.authmesh.dev/ws', - env: 'AMESH_RELAY_URL', - }), - 'allow-root': Flags.boolean({ - description: 'Allow running as root (grants root shells to all controllers)', - default: false, - }), - 'idle-timeout': Flags.integer({ - description: 'Idle session timeout in minutes', - default: 30, - min: 1, - }), - }; - - async run(): Promise { - // Agent daemon requires Bun for PTY support (Bun.spawn({ terminal: })) - if (typeof globalThis.Bun === 'undefined') { - 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 start', - ); - } - - const { flags } = await this.parse(AgentStart); - - const { startAgent } = await import('../../agent.js'); - await startAgent({ - relayUrl: flags.relay, - allowRoot: flags['allow-root'], - idleTimeoutMinutes: flags['idle-timeout'], - }); - } -} diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts deleted file mode 100644 index 9dcfdf0..0000000 --- a/packages/cli/src/commands/agent/stop.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 2e5860c..0000000 --- a/packages/cli/src/commands/grant.ts +++ /dev/null @@ -1,52 +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.\n' + - ' 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.\n` + - 'Run `amesh list` to see paired devices.\n' + - "Note: grant runs on the target — you're granting a controller permission to access this device.", - ); - } - - await allowList.updatePermissions(args.deviceId, { shell: flags.shell }); - - this.log(''); - this.log(` Device: ${device.friendlyName} (${args.deviceId})`); - this.log(` Shell access: ${flags.shell ? 'granted' : 'revoked'}`); - this.log(''); - } -} diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index faf556d..3d177ee 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -16,10 +16,6 @@ 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 { @@ -128,11 +124,6 @@ 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(' 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 deleted file mode 100644 index 4c566ca..0000000 --- a/packages/cli/src/commands/reset.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/commands/shell.ts b/packages/cli/src/commands/shell.ts deleted file mode 100644 index ffa1f55..0000000 --- a/packages/cli/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/cli/src/frame.ts b/packages/cli/src/frame.ts deleted file mode 100644 index b963b22..0000000 --- a/packages/cli/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/cli/src/paths.ts b/packages/cli/src/paths.ts index 2bb36ca..aaac0dc 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -20,10 +20,6 @@ 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 b1ff4f8..ac173d2 100644 --- a/packages/cli/src/sea.ts +++ b/packages/cli/src/sea.ts @@ -15,17 +15,12 @@ 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 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 @@ -47,23 +42,15 @@ interface CommandMeta { } 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, - }, -}; +const nestedCommands: Record> = {}; /** * Create a minimal oclif root so Config.load() works in compiled binaries. @@ -145,7 +132,7 @@ async function main(): Promise { const oclifRoot = getOclifRoot(); - // Nested commands: `amesh agent start [flags]` + // Nested commands (e.g. `amesh [flags]`) const nested = nestedCommands[first]; if (nested) { const sub = args[1]; diff --git a/packages/cli/src/shell-cipher.ts b/packages/cli/src/shell-cipher.ts deleted file mode 100644 index 9b2019c..0000000 --- a/packages/cli/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/cli/src/shell-client.ts b/packages/cli/src/shell-client.ts deleted file mode 100644 index c93003c..0000000 --- a/packages/cli/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/cli/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts deleted file mode 100644 index b40e794..0000000 --- a/packages/cli/src/shell-handshake.ts +++ /dev/null @@ -1,344 +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) - */ -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/core/src/__tests__/ecdh.test.ts b/packages/core/src/__tests__/ecdh.test.ts index 66e14a2..f26f900 100644 --- a/packages/core/src/__tests__/ecdh.test.ts +++ b/packages/core/src/__tests__/ecdh.test.ts @@ -120,46 +120,6 @@ describe('deriveSessionKey', () => { }); }); -describe('deriveShellSessionKey', () => { - it('produces different keys than deriveSessionKey', async () => { - const { deriveShellSessionKey } = await import('../ecdh.js'); - const a = generateEphemeralKeyPair(); - const b = generateEphemeralKeyPair(); - const shared = computeSharedSecret(a.privateKey, b.publicKey); - - const pairingKey = deriveSessionKey(shared); - const shellKey = deriveShellSessionKey(shared, 'am_target', 'am_controller'); - - expect(pairingKey).not.toEqual(shellKey); - }); - - it('produces different keys for different device ID pairs', async () => { - const { deriveShellSessionKey } = await import('../ecdh.js'); - const a = generateEphemeralKeyPair(); - const b = generateEphemeralKeyPair(); - const shared = computeSharedSecret(a.privateKey, b.publicKey); - - const key1 = deriveShellSessionKey(shared, 'am_target1', 'am_controller1'); - const key2 = deriveShellSessionKey(shared, 'am_target2', 'am_controller1'); - - expect(key1).not.toEqual(key2); - }); - - it('both sides derive the same shell session key', async () => { - const { deriveShellSessionKey } = await import('../ecdh.js'); - const a = generateEphemeralKeyPair(); - const b = generateEphemeralKeyPair(); - - const sharedAB = computeSharedSecret(a.privateKey, b.publicKey); - const sharedBA = computeSharedSecret(b.privateKey, a.publicKey); - - const keyA = deriveShellSessionKey(sharedAB, 'am_target', 'am_ctrl'); - const keyB = deriveShellSessionKey(sharedBA, 'am_target', 'am_ctrl'); - - expect(keyA).toEqual(keyB); - }); -}); - describe('full ECDH handshake simulation', () => { it('target and controller derive matching session keys', () => { // Simulate the handshake from docs/protocol-spec.md Step 5-6 diff --git a/packages/core/src/ecdh.ts b/packages/core/src/ecdh.ts index 2bb4f0a..ba063fe 100644 --- a/packages/core/src/ecdh.ts +++ b/packages/core/src/ecdh.ts @@ -35,16 +35,3 @@ export function computeSharedSecret( export function deriveSessionKey(sharedSecret: Uint8Array): Uint8Array { return deriveKey(sharedSecret, HANDSHAKE_SALT, 'session-key', 32); } - -/** - * Derive a shell session key from ECDH shared secret, bound to both device IDs. - * Uses a separate HKDF domain ('amesh-shell-v1') to ensure cryptographic - * separation from pairing sessions. - */ -export function deriveShellSessionKey( - sharedSecret: Uint8Array, - targetDeviceId: string, - controllerDeviceId: string, -): Uint8Array { - return deriveKey(sharedSecret, 'amesh-shell-v1', `${targetDeviceId}:${controllerDeviceId}`, 32); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f84a5f3..cf8fcf9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,9 +4,4 @@ export { InMemoryNonceStore } from './nonce.js'; export type { NonceStore } from './nonce.js'; export { computeHmac, verifyHmac } from './hmac.js'; export { deriveKey } from './hkdf.js'; -export { - generateEphemeralKeyPair, - computeSharedSecret, - deriveSessionKey, - deriveShellSessionKey, -} from './ecdh.js'; +export { generateEphemeralKeyPair, computeSharedSecret, deriveSessionKey } from './ecdh.js'; diff --git a/packages/keystore/src/allow-list.ts b/packages/keystore/src/allow-list.ts index ec8c8b9..52f8724 100644 --- a/packages/keystore/src/allow-list.ts +++ b/packages/keystore/src/allow-list.ts @@ -2,10 +2,6 @@ import { computeHmac, verifyHmac, deriveKey } from '@authmesh/core'; import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; -export interface DevicePermissions { - shell?: boolean; -} - export interface AllowListDevice { deviceId: string; publicKey: string; // base64 compressed P-256 @@ -13,7 +9,6 @@ export interface AllowListDevice { addedAt: string; // ISO 8601 addedBy: 'handshake' | 'manual'; role: 'controller' | 'target'; - permissions?: DevicePermissions; } export interface AllowListData { @@ -224,24 +219,6 @@ export class AllowList { return data; } - /** - * Update permissions for a device. Reseals the allow list. - */ - async updatePermissions( - deviceId: string, - permissions: DevicePermissions, - ): Promise { - const data = await this.read(); - const device = data.devices.find((d) => d.deviceId === deviceId); - if (!device) { - throw new Error(`Device ${deviceId} not found in allow list`); - } - device.permissions = { ...device.permissions, ...permissions }; - data.updatedAt = new Date().toISOString(); - await this.writeSealed(data); - return data; - } - /** * Verify HMAC integrity using a specific canonicalization function. * Returns true on match, false on mismatch. Used by `read()` to try the diff --git a/packages/keystore/src/index.ts b/packages/keystore/src/index.ts index f5b80d9..7916176 100644 --- a/packages/keystore/src/index.ts +++ b/packages/keystore/src/index.ts @@ -1,5 +1,5 @@ export type { KeyStore } from './interface.js'; export { AllowList } from './allow-list.js'; -export type { AllowListData, AllowListDevice, DevicePermissions } from './allow-list.js'; +export type { AllowListData, AllowListDevice } from './allow-list.js'; export { detectAndCreate, createForBackend, BACKEND_LABELS, generatePassphrase } from './detect.js'; export type { StorageBackend, DetectionResult } from './detect.js'; diff --git a/packages/relay/src/agent-store.ts b/packages/relay/src/agent-store.ts deleted file mode 100644 index 9256a09..0000000 --- a/packages/relay/src/agent-store.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ServerWebSocket } from 'bun'; -import type { WebSocketData } from './server.js'; - -interface AgentEntry { - socket: ServerWebSocket; - publicKey: string; - registeredAt: number; - lastPing: number; -} - -/** - * Constant-time string comparison for the agent registry (L3). - * - * Public keys are not secret, but the relay's shell routing pipeline uses - * the `(deviceId, publicKey)` tuple as the only gate between an enumerating - * attacker and "this pair is currently registered" side-channel info. A - * timing-safe compare removes one axis of the oracle; the uniform response - * from handleShell (C3) removes the other. - */ -function constantTimeStringEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return diff === 0; -} - -/** - * Tracks connected agent daemons by device ID. - * Agents register with their public key; controllers must provide - * the matching public key to route a shell request. - */ -export class AgentStore { - private readonly agents = new Map(); - private readonly cleanupTimer: ReturnType; - private readonly heartbeatTimeoutMs: number; - - constructor(heartbeatTimeoutMs = 90_000) { - this.heartbeatTimeoutMs = heartbeatTimeoutMs; - this.cleanupTimer = setInterval(() => this.purgeStale(), 30_000); - this.cleanupTimer.unref(); - } - - register(deviceId: string, publicKey: string, socket: ServerWebSocket): boolean { - const existing = this.agents.get(deviceId); - if (existing) { - // Same public key = reconnect (allow), different = squatting attempt (reject) - if (!constantTimeStringEqual(existing.publicKey, publicKey)) return false; - // Close old connection if still open - if (existing.socket.readyState === WebSocket.OPEN) { - existing.socket.close(1000, 'replaced'); - } - } - this.agents.set(deviceId, { - socket, - publicKey, - registeredAt: Date.now(), - lastPing: Date.now(), - }); - return true; - } - - /** - * Look up an agent by device ID and verify the public key matches. - * Returns the agent's WebSocket if matched, undefined otherwise. - */ - matchAndGet( - deviceId: string, - expectedPublicKey: string, - ): ServerWebSocket | undefined { - const entry = this.agents.get(deviceId); - if (!entry) return undefined; - if (!constantTimeStringEqual(entry.publicKey, expectedPublicKey)) return undefined; - if (entry.socket.readyState !== WebSocket.OPEN) { - this.agents.delete(deviceId); - return undefined; - } - return entry.socket; - } - - recordPing(socket: ServerWebSocket): void { - for (const [, entry] of this.agents) { - if (entry.socket === socket) { - entry.lastPing = Date.now(); - return; - } - } - } - - removeBySocket(socket: ServerWebSocket): void { - for (const [deviceId, entry] of this.agents) { - if (entry.socket === socket) { - this.agents.delete(deviceId); - return; - } - } - } - - get size(): number { - return this.agents.size; - } - - private purgeStale(): void { - const now = Date.now(); - for (const [deviceId, entry] of this.agents) { - if ( - now - entry.lastPing > this.heartbeatTimeoutMs || - entry.socket.readyState !== WebSocket.OPEN - ) { - this.agents.delete(deviceId); - } - } - } - - destroy(): void { - clearInterval(this.cleanupTimer); - this.agents.clear(); - } -} diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index 3d90c03..00d7c0f 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -1,5 +1,4 @@ export { createRelayServer } from './server.js'; export type { WebSocketData } from './server.js'; export { SessionStore } from './session.js'; -export { AgentStore } from './agent-store.js'; export { RateLimiter } from './rate-limit.js'; diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 47ff9ff..b5a3ee7 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,8 +1,6 @@ import type { ServerWebSocket } from 'bun'; -import { verifyMessage } from '@authmesh/core'; import { SessionStore, SESSION_MAX_BYTES } from './session.js'; import { RateLimiter, OTCAttemptTracker } from './rate-limit.js'; -import { AgentStore } from './agent-store.js'; interface RelayMessage { type: @@ -10,10 +8,6 @@ interface RelayMessage { | 'connect' | 'data' | 'done' - | 'ping' - | 'agent' - | 'agent_challenge_response' - | 'shell' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' @@ -23,19 +17,12 @@ interface RelayMessage { jti?: string; token?: string; targetPubKey?: string; - deviceId?: string; - publicKey?: string; - timestamp?: string; - sig?: string; - targetDeviceId?: string; - targetPublicKey?: string; [key: string]: unknown; } export interface WebSocketData { otc?: string; btJti?: string; - agentDeviceId?: string; ip: string; /** Set when open() rejected the socket for exceeding MAX_CONNECTIONS. */ rejected?: boolean; @@ -135,9 +122,7 @@ export function createRelayServer(opts?: { return envVal === '1' || envVal === 'true' || envVal === 'yes'; })(); const sessions = new SessionStore(opts?.maxSessions); - const agentStore = new AgentStore(); const rateLimiter = 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 @@ -262,10 +247,14 @@ export function createRelayServer(opts?: { sessions.remove(otc); try { session.target.close(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } try { session.controller?.close(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } return; } @@ -397,105 +386,7 @@ export function createRelayServer(opts?: { bootstrapWatchers.delete(jti); } - // Pending agent challenges: ws → { deviceId, publicKey, challenge } - const pendingChallenges = new Map< - ServerWebSocket, - { deviceId: string; publicKey: string; challenge: string } - >(); - - // Shell: agent registration step 1 — issue challenge - function handleAgent(ws: ServerWebSocket, msg: RelayMessage) { - if (!msg.deviceId || !msg.publicKey) { - ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' })); - return; - } - // Generate a random challenge nonce - const challenge = crypto.randomUUID(); - pendingChallenges.set(ws, { deviceId: msg.deviceId, publicKey: msg.publicKey, challenge }); - ws.send(JSON.stringify({ type: 'agent_challenge', challenge })); - } - - // Shell: agent registration step 2 — verify challenge response - function handleAgentChallengeResponse(ws: ServerWebSocket, msg: RelayMessage) { - const pending = pendingChallenges.get(ws); - if (!pending) { - ws.send(JSON.stringify({ type: 'error', code: 'no_pending_challenge' })); - return; - } - pendingChallenges.delete(ws); - - if (!msg.sig) { - ws.send(JSON.stringify({ type: 'error', code: 'missing_signature' })); - return; - } - - // Verify the agent signed the challenge with the claimed private key - const publicKey = new Uint8Array(Buffer.from(pending.publicKey, 'base64')); - const message = new TextEncoder().encode(pending.challenge); - const signature = new Uint8Array(Buffer.from(msg.sig as string, 'base64url')); - - if (!verifyMessage(signature, message, publicKey)) { - ws.send(JSON.stringify({ type: 'error', code: 'invalid_signature' })); - return; - } - - // Signature valid — agent proves it holds the private key - const ok = agentStore.register(pending.deviceId, pending.publicKey, ws); - if (!ok) { - ws.send(JSON.stringify({ type: 'error', code: 'device_id_conflict' })); - return; - } - ws.data.agentDeviceId = pending.deviceId; - ws.send(JSON.stringify({ type: 'agent_registered' })); - } - - // Shell: controller requests shell to a target agent (C3 fix — uniform responses) - function handleShell(ws: ServerWebSocket, msg: RelayMessage) { - if (!msg.targetDeviceId || !msg.targetPublicKey) { - ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' })); - return; - } - if (!shellRateLimiter.check(ws.data.ip)) { - ws.send(JSON.stringify({ type: 'error', code: 'rate_limited' })); - return; - } - const agentWs = agentStore.matchAndGet(msg.targetDeviceId, msg.targetPublicKey); - if (!agentWs) { - // M6 fix — only count failures against rate limit - shellRateLimiter.recordFailure(ws.data.ip); - // Uniform response — don't reveal whether agent exists (C3 fix) - ws.send(JSON.stringify({ type: 'peer_found' })); - return; - } - // Create a pairing-like session for the shell (reuse existing data forwarding) - const shellOtc = `shell_${Date.now()}_${crypto.randomUUID()}`; - try { - sessions.create(shellOtc, agentWs, 600); // 10 min TTL for shell sessions - sessions.get(shellOtc)!.controller = ws; - ws.data.otc = shellOtc; - agentWs.data.otc = shellOtc; - agentWs.send(JSON.stringify({ type: 'peer_found' })); - ws.send(JSON.stringify({ type: 'peer_found' })); - } catch { - ws.send(JSON.stringify({ type: 'peer_found' })); - } - } - - // Shell: agent heartbeat - function handlePing(ws: ServerWebSocket) { - agentStore.recordPing(ws); - ws.send(JSON.stringify({ type: 'pong' })); - } - function cleanupSocket(ws: ServerWebSocket) { - // Clean up pending challenges - pendingChallenges.delete(ws); - - // Clean up agent registration - if (ws.data.agentDeviceId) { - agentStore.removeBySocket(ws); - } - // Clean up pairing sessions const otc = ws.data.otc; if (otc) { @@ -542,7 +433,6 @@ export function createRelayServer(opts?: { return Response.json({ status: 'ok', sessions: sessions.size, - agents: agentStore.size, }); } @@ -606,18 +496,6 @@ export function createRelayServer(opts?: { case 'bootstrap_reject': handleBootstrapResponse(ws, msg); break; - case 'agent': - handleAgent(ws, msg); - break; - case 'agent_challenge_response': - handleAgentChallengeResponse(ws, msg); - break; - case 'shell': - handleShell(ws, msg); - break; - case 'ping': - handlePing(ws); - break; default: ws.send(JSON.stringify({ type: 'error', code: 'unknown_type' })); } @@ -639,9 +517,7 @@ export function createRelayServer(opts?: { stop() { clearInterval(bootstrapCleanupTimer); sessions.destroy(); - agentStore.destroy(); rateLimiter.destroy(); - shellRateLimiter.destroy(); bootstrapWatchRateLimiter.destroy(); otcAttempts.destroy(); server?.stop(); diff --git a/packages/relay/src/session.ts b/packages/relay/src/session.ts index 23c49a5..b424a79 100644 --- a/packages/relay/src/session.ts +++ b/packages/relay/src/session.ts @@ -2,9 +2,8 @@ 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. */ + * Tight enough to prevent bulk data streaming through the relay. Self-hosted + * relays can override by setting a higher value. */ export const SESSION_MAX_BYTES = 5 * 1024 * 1024; export interface PairingSession { diff --git a/packaging/build-bun.mjs b/packaging/build-bun.mjs index 7680b83..9c210d0 100644 --- a/packaging/build-bun.mjs +++ b/packaging/build-bun.mjs @@ -5,7 +5,7 @@ * bun packaging/build-bun.mjs [--target bun-darwin-arm64] * * Compiles a standalone binary via `bun build --compile`: - * - packages/cli/src/sea.ts → dist/amesh (unified CLI + agent) + * - packages/cli/src/sea.ts → dist/amesh * * On macOS targets, also compiles the Swift Secure Enclave helper. * Default target: current platform. diff --git a/smoke-tests/lib/config.sh b/smoke-tests/lib/config.sh index c06a3f3..6a2f306 100755 --- a/smoke-tests/lib/config.sh +++ b/smoke-tests/lib/config.sh @@ -4,7 +4,7 @@ GITHUB_REPO="ameshdev/amesh" NPM_SCOPE="@authmesh" -NPM_PACKAGES=(core keystore sdk cli relay agent) +NPM_PACKAGES=(core keystore sdk cli relay) RELAY_PORT=3001 # Set by the runner at invocation time diff --git a/smoke-tests/tests/01-relay-image-boot.sh b/smoke-tests/tests/01-relay-image-boot.sh index 67fbd3d..4e4df11 100755 --- a/smoke-tests/tests/01-relay-image-boot.sh +++ b/smoke-tests/tests/01-relay-image-boot.sh @@ -10,6 +10,4 @@ response=$(curl -sf "$RELAY_HEALTH" || true) assert_not_empty "$response" "Health endpoint returned empty" assert_contains "$response" '"status":"ok"' "Health should contain status:ok" assert_contains "$response" '"sessions"' "Health should contain sessions field" -assert_contains "$response" '"agents"' "Health should contain agents field" - -pass "Relay /health returns {status:'ok', sessions, agents}" +pass "Relay /health returns {status:'ok', sessions}" diff --git a/smoke-tests/tests/08-binary-runs.sh b/smoke-tests/tests/08-binary-runs.sh index caed062..e195cca 100755 --- a/smoke-tests/tests/08-binary-runs.sh +++ b/smoke-tests/tests/08-binary-runs.sh @@ -36,7 +36,6 @@ assert_eq "$version_line" "amesh/${VERSION}" "--version mismatch" help_output=$(echo "$output" | sed -n '/^HELP_START$/,/^HELP_END$/p') assert_contains "$help_output" "init" "--help should list init command" -assert_contains "$help_output" "shell" "--help should list shell command" assert_contains "$help_output" "invite" "--help should list invite command" -pass "amesh/$VERSION — --version matches, --help lists 8 commands" +pass "amesh/$VERSION — --version matches, --help lists commands"