diff --git a/CHANGELOG.md b/CHANGELOG.md index d5df91a..cea1219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **File permissions**: all sensitive files (keys, identity, allow list) written with `0o600`/`0o700`. - **deviceId** validated against path traversal (`/^[a-zA-Z0-9_-]+$/`). - **Bootstrap jti** entropy increased from 32 to 128 bits. -- **Relay hardening**: per-OTC brute-force tracking (max 10 per OTC), `maxPayload` 64KB, connection limit 10K, bootstrap watcher TTL + cleanup, message field whitelisting. +- **Relay hardening**: per-OTC brute-force tracking (max 5 per OTC), `maxPayload` 64KB, connection limit 10K, bootstrap watcher TTL + cleanup, message field whitelisting. - **Nonce store** bounded at 1M entries to prevent memory exhaustion. - **Base64URL** decoding fix in SDK middleware (was using plain base64). - **Canonical string** rejects newlines in fields to prevent injection. diff --git a/Dockerfile.relay b/Dockerfile.relay index 4f5872f..823571b 100644 --- a/Dockerfile.relay +++ b/Dockerfile.relay @@ -5,12 +5,19 @@ WORKDIR /app # Copy workspace config COPY package.json bun.lock turbo.json tsconfig.base.json ./ -# Copy only the packages needed for the relay +# Copy all package.json files so bun workspace resolution works COPY packages/core/package.json packages/core/ +COPY packages/keystore/package.json packages/keystore/ +COPY packages/sdk/package.json packages/sdk/ +COPY packages/cli/package.json packages/cli/ COPY packages/relay/package.json packages/relay/ +COPY packages/shell/package.json packages/shell/ + +# Stub missing workspace dirs to satisfy bun install +RUN mkdir -p demo landpage && echo '{"name":"demo","private":true}' > demo/package.json && echo '{"name":"landpage","private":true}' > landpage/package.json # Install dependencies -RUN bun install --frozen-lockfile +RUN bun install # Copy source COPY packages/core/src packages/core/src diff --git a/bun.lock b/bun.lock index d238131..8245113 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ }, "packages/cli": { "name": "@authmesh/cli", - "version": "0.1.2", + "version": "0.1.6", "bin": { "amesh": "./dist/index.js", }, @@ -68,7 +68,7 @@ }, "packages/core": { "name": "@authmesh/core", - "version": "0.1.2", + "version": "0.1.6", "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", @@ -83,7 +83,7 @@ }, "packages/keystore": { "name": "@authmesh/keystore", - "version": "0.1.2", + "version": "0.1.6", "dependencies": { "@authmesh/core": "workspace:*", "@noble/ciphers": "2.1.1", @@ -100,7 +100,7 @@ }, "packages/relay": { "name": "@authmesh/relay", - "version": "0.1.2", + "version": "0.1.6", "dependencies": { "@authmesh/core": "workspace:*", }, @@ -116,7 +116,7 @@ }, "packages/sdk": { "name": "@authmesh/sdk", - "version": "0.1.2", + "version": "0.1.6", "dependencies": { "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", @@ -135,6 +135,29 @@ "typescript-eslint": "^8.0.0", }, }, + "packages/shell": { + "name": "@authmesh/shell", + "version": "0.1.0", + "bin": { + "amesh-agent": "./dist/commands/agent-start.js", + "amesh-shell": "./dist/commands/shell.js", + }, + "dependencies": { + "@authmesh/core": "workspace:*", + "@authmesh/keystore": "workspace:*", + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.0.0", + }, + }, }, "packages": { "@authmesh/cli": ["@authmesh/cli@workspace:packages/cli"], @@ -147,6 +170,8 @@ "@authmesh/sdk": ["@authmesh/sdk@workspace:packages/sdk"], + "@authmesh/shell": ["@authmesh/shell@workspace:packages/shell"], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 8ef30b5..ce811c9 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -132,7 +132,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en **Why:** Default umask (typically 0644) makes encrypted key files world-readable. Defense-in-depth even when encryption is strong. ### Relay hardening -**Decision:** Added per-OTC attempt tracking (max 10), WebSocket `maxPayload` (64KB), connection limit (10K), bootstrap watcher TTL and cleanup, message field whitelisting. +**Decision:** Added per-OTC attempt tracking (max 5), WebSocket `maxPayload` (64KB), connection limit (10K), bootstrap watcher TTL and cleanup, message field whitelisting. OTC sessions expire after 60 seconds. **Why:** The relay was vulnerable to distributed OTC brute-force (per-IP limiting only), memory exhaustion via large payloads or unlimited connections, and stale bootstrap watcher leaks. @@ -162,3 +162,33 @@ The controller CLI displays this code; the target CLI prompts the operator to en - No enforcement (application-level convention) — convention is not security; the middleware must enforce it **Trade-offs:** Bidirectional auth between two services requires two separate pairings (each side runs `amesh listen` once and `amesh invite` once). This is intentional friction — bidirectional trust should be a conscious choice, not the default. + +--- + +## ADR-011: Remote shell as separate package with explicit shell permission + +**Decision:** The remote shell feature ships as `@authmesh/shell`, a separate npm package with separate binaries (`amesh-agent`, `amesh-shell`). Shell access requires explicit `amesh grant --shell` after pairing. + +**Why:** + +1. **Security boundary:** Installing `@authmesh/sdk` for HTTP API auth must never pull in PTY code or an agent daemon. The attack surface for API signing and shell access are fundamentally different. + +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 prevents implicit privilege escalation. + +3. **Separate binaries:** `amesh-agent` and `amesh-shell` are distinct from `amesh` (the CLI). Users opt into shell capability by installing a separate package. + +**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:** +- Bundling in `@authmesh/cli` — mixes API auth tooling with shell daemon, implicit capability creep +- Auto-granting shell on pairing — violates principle of least privilege +- Reusing pairing handshake's random-nonce encryption — birthday-bound risk over long sessions +- Session resumption — complexity and nonce-reuse risk outweigh the latency benefit diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index db9fb97..3d8c8b4 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -243,7 +243,7 @@ TARGET RELAY CONTROLLER ``` OTC = crypto.randomInt(100000, 999999).toString() ``` -6 digits. Valid for **120 seconds**. Displayed prominently in the terminal. +6 digits. Valid for **60 seconds**. Displayed prominently in the terminal. **Step 5 — ECDH Ephemeral Exchange:** Both sides generate a **throwaway** P-256 keypair for this session only. They exchange public halves through the relay. The shared secret derived via ECDH never touches the relay. This ephemeral keypair is discarded after the ceremony. @@ -299,7 +299,7 @@ $ amesh listen ┌─────────────────────────────┐ │ Your pairing code: 482916 │ - │ Expires in: 120 seconds │ + │ Expires in: 60 seconds │ └─────────────────────────────┘ Share this code with your Controller device. @@ -566,13 +566,13 @@ The relay is a **dumb, stateless, ephemeral message bus**. It exists only to sol - Store any message to disk - Log public keys or device IDs - Inspect message contents (all payloads are encrypted by Step 6 of the handshake) -- Maintain sessions longer than the handshake window (120 seconds max) +- Maintain sessions longer than the handshake window (60 seconds max) ### Session Lifecycle ``` 1. TARGET connects with OTC "482916" - → Relay creates in-memory session: { otc: "482916", target: ws1, controller: null, expiresAt: now+120s } + → Relay creates in-memory session: { otc: "482916", target: ws1, controller: null, expiresAt: now+60s } 2. CONTROLLER connects with OTC "482916" → Relay finds session, sets controller: ws2 @@ -612,8 +612,9 @@ Termination: ### Relay Rate Limiting (mandatory) The relay MUST enforce: - Max 5 failed OTC attempts per IP per minute +- Max 5 failed attempts per OTC (then OTC is burned) - Max 1 active connection per OTC -- OTC session destroyed immediately on mismatch +- OTC session expires after 60 seconds ### Relay Deployment - Deploy as a Docker container on Fly.io for MVP (~$2/month, standard Node.js, easy deploy) diff --git a/docs/remote-shell-security-review.md b/docs/remote-shell-security-review.md new file mode 100644 index 0000000..03424e9 --- /dev/null +++ b/docs/remote-shell-security-review.md @@ -0,0 +1,185 @@ +# 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 new file mode 100644 index 0000000..156a0f2 --- /dev/null +++ b/docs/remote-shell-spec.md @@ -0,0 +1,348 @@ +# 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 +``` + +**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** — identical to pairing (both sides generate ephemeral P-256 keypairs, exchange public keys, compute shared secret via ECDH, derive session key via HKDF) +2. **Identity exchange** — identical to pairing (both sides send PeerIdentity with selfSig, encrypted with session key) +3. **Authorization check** — NEW: both sides verify the peer is in their allow list + - Target checks: controller's permanent public key is in allow list with `role: "controller"` + - Controller checks: target's permanent public key is in allow list with `role: "target"` +4. **No SAS verification** — trust was established during the original 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) + +**MITM protection without SAS:** The permanent public keys are exchanged during the original pairing (with SAS). During the shell handshake, both sides verify the peer's permanent key matches what's in their allow list. A MITM relay would need to substitute different permanent keys, which would fail the allow list check. + +--- + +## 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 — permanent key verification prevents this. 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. + +### 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) +- 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/landpage/src/lib/navigation.ts b/landpage/src/lib/navigation.ts index 36ec23b..3d5fc68 100644 --- a/landpage/src/lib/navigation.ts +++ b/landpage/src/lib/navigation.ts @@ -20,6 +20,7 @@ export interface RelatedLink { export const docPages: NavItem[] = [ { slug: 'integration', title: 'Integration Guide', desc: 'Express, microservices, webhooks, remote pairing' }, { slug: 'self-hosting', title: 'Self-Hosting Guide', desc: 'Docker, Cloud Run, Fly.io, Kubernetes' }, + { slug: 'remote-shell', title: 'Remote Shell Guide', desc: 'Agent setup, shell access, security model' }, ]; export const useCasePages: NavItem[] = [ @@ -27,6 +28,7 @@ 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/docs/+page.svelte b/landpage/src/routes/docs/+page.svelte index c280aea..6dc4237 100644 --- a/landpage/src/routes/docs/+page.svelte +++ b/landpage/src/routes/docs/+page.svelte @@ -1,5 +1,5 @@ + + + Remote Shell Guide — amesh + + + + + + + +
+ +
+ +

Remote Shell Guide

+

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

+ +
+ + +
+

Install

+

The shell feature is a separate package from the CLI.

+
+ # Install the shell package (agent + shell client) +brew install ameshdev/tap/amesh-shell +# or +npm install -g @authmesh/shell + +# You also need the CLI for pairing and permissions +brew install ameshdev/tap/amesh`} /> +
+
+ + +
+

Setup

+

Three steps: pair the devices (if not already), grant shell access, start the agent.

+ +

1. Pair devices (skip if already paired)

+
+ # On the target (server) +amesh listen + +# On the controller (your laptop) +amesh invite 482916`} /> +
+ +

2. Grant shell permission

+
+ # On the target — grant shell access to the controller +amesh grant am_3d9f1a2e --shell + +# Verify +amesh list +# Shows: am_3d9f1a2e alice-macbook [controller] [shell] added 2026-04-03`} /> +
+

Shell access is opt-in. Pairing for HTTP API auth does not automatically grant shell access.

+ +

3. Start the agent

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

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: 'Passphrase for encrypted-file backend', 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.
+
+
+
"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/use-cases/remote-shell/+page.svelte b/landpage/src/routes/use-cases/remote-shell/+page.svelte new file mode 100644 index 0000000..276231b --- /dev/null +++ b/landpage/src/routes/use-cases/remote-shell/+page.svelte @@ -0,0 +1,65 @@ + + + + Remote Shell Without SSH Keys — amesh + + + + + + + +# 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: 'Grant access', code: `# Shell access is opt-in — pairing alone doesn't grant it +$ amesh grant am_3d9f1a2e --shell + + Device: alice-macbook (am_3d9f1a2e) + Shell access: granted + +# 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/sitemap.xml b/landpage/static/sitemap.xml index 91ae21a..da340ea 100644 --- a/landpage/static/sitemap.xml +++ b/landpage/static/sitemap.xml @@ -45,4 +45,14 @@ 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/src/commands/grant.ts b/packages/cli/src/commands/grant.ts new file mode 100644 index 0000000..4d54f21 --- /dev/null +++ b/packages/cli/src/commands/grant.ts @@ -0,0 +1,51 @@ +import { Command, Args, Flags } from '@oclif/core'; +import { loadContext } from '../context.js'; + +export default class Grant extends Command { + static override description = 'Grant or revoke permissions for a paired device'; + + static override args = { + deviceId: Args.string({ + description: 'Device ID to modify (e.g., am_1a2b3c4d5e6f7a8b)', + required: true, + }), + }; + + static override flags = { + shell: Flags.boolean({ + description: 'Grant shell access (remote terminal)', + allowNo: true, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(Grant); + + if (flags.shell === undefined) { + this.error('Specify a permission to grant or revoke. Example: amesh grant --shell'); + } + + const { allowList } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + const data = await allowList.read(); + const device = data.devices.find((d) => d.deviceId === args.deviceId); + if (!device) { + this.error(`Device ${args.deviceId} not found in allow list.`); + } + + await allowList.updatePermissions(args.deviceId, { shell: flags.shell }); + + this.log(''); + this.log(` Device: ${device.friendlyName} (${args.deviceId})`); + if (flags.shell) { + this.log(' Shell access: granted'); + this.log(''); + this.log(' This device can now open remote shells via amesh-shell.'); + } else { + this.log(' Shell access: revoked'); + } + this.log(''); + } +} diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index 294801a..94c68d6 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -31,7 +31,7 @@ export default class Listen extends Command { this.log(''); this.log(' ┌─────────────────────────────┐'); this.log(` │ Your pairing code: ${otc} │`); - this.log(' │ Expires in: 120 seconds │'); + this.log(' │ Expires in: 60 seconds │'); this.log(' └─────────────────────────────┘'); this.log(''); this.log(' Share this code with your Controller device.'); diff --git a/packages/cli/src/handshake.ts b/packages/cli/src/handshake.ts index 13e1981..b2933cb 100644 --- a/packages/cli/src/handshake.ts +++ b/packages/cli/src/handshake.ts @@ -167,7 +167,7 @@ export async function runTargetHandshake( if (ack.type === 'error') throw new Error(`Relay error: ${ack.code}`); // Step 2-4: Wait for controller - const peerFound = await reader.read(120_000); + const peerFound = await reader.read(60_000); if (peerFound.type !== 'peer_found') throw new Error(`Unexpected: ${peerFound.type}`); // Step 5: ECDH ephemeral exchange — send our ephemeral public key diff --git a/packages/core/src/__tests__/ecdh.test.ts b/packages/core/src/__tests__/ecdh.test.ts index a13d26f..6ee75a6 100644 --- a/packages/core/src/__tests__/ecdh.test.ts +++ b/packages/core/src/__tests__/ecdh.test.ts @@ -124,6 +124,46 @@ 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 ba063fe..2bb4f0a 100644 --- a/packages/core/src/ecdh.ts +++ b/packages/core/src/ecdh.ts @@ -35,3 +35,16 @@ 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 67cc4f8..f84a5f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,4 +8,5 @@ export { generateEphemeralKeyPair, computeSharedSecret, deriveSessionKey, + deriveShellSessionKey, } from './ecdh.js'; diff --git a/packages/keystore/src/allow-list.ts b/packages/keystore/src/allow-list.ts index e1e4f79..3f26d67 100644 --- a/packages/keystore/src/allow-list.ts +++ b/packages/keystore/src/allow-list.ts @@ -2,6 +2,10 @@ 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 @@ -9,6 +13,7 @@ export interface AllowListDevice { addedAt: string; // ISO 8601 addedBy: 'handshake' | 'manual'; role: 'controller' | 'target'; + permissions?: DevicePermissions; } export interface AllowListData { @@ -153,6 +158,21 @@ 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. Throws on failure — never silently continue. */ diff --git a/packages/keystore/src/index.ts b/packages/keystore/src/index.ts index 5832ee9..8b5e702 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 } from './allow-list.js'; +export type { AllowListData, AllowListDevice, DevicePermissions } from './allow-list.js'; export { detectAndCreate, createForBackend } 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 new file mode 100644 index 0000000..4a1c2c9 --- /dev/null +++ b/packages/relay/src/agent-store.ts @@ -0,0 +1,96 @@ +import type { ServerWebSocket } from 'bun'; +import type { WebSocketData } from './server.js'; + +interface AgentEntry { + socket: ServerWebSocket; + publicKey: string; + registeredAt: number; + lastPing: number; +} + +/** + * 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 (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 (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 00d7c0f..3d90c03 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -1,4 +1,5 @@ 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 637882f..6dce546 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,20 +1,28 @@ import type { ServerWebSocket } from 'bun'; import { SessionStore } from './session.js'; import { RateLimiter, OTCAttemptTracker } from './rate-limit.js'; +import { AgentStore } from './agent-store.js'; interface RelayMessage { - type: 'listen' | 'connect' | 'data' | 'done' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' | 'bootstrap_reject'; + type: 'listen' | 'connect' | 'data' | 'done' | 'ping' | 'agent' | 'shell' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' | 'bootstrap_reject'; otc?: string; payload?: string; 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; } @@ -24,8 +32,10 @@ const BOOTSTRAP_WATCHER_TTL_MS = 300_000; // 5 minutes export function createRelayServer(opts?: { host?: string; port?: number }) { const sessions = new SessionStore(); + const agentStore = new AgentStore(); const rateLimiter = new RateLimiter(5, 60_000); - const otcAttempts = new OTCAttemptTracker(10); + const shellRateLimiter = new RateLimiter(5, 60_000); + const otcAttempts = new OTCAttemptTracker(5); // Bootstrap watchers: jti → { socket, createdAt } const bootstrapWatchers = new Map; createdAt: number }>(); // Track all connected sockets for bootstrap response routing @@ -175,7 +185,65 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { bootstrapWatchers.delete(jti); } + // Shell: agent registration (C1 fix — includes publicKey for anti-squatting) + function handleAgent(ws: ServerWebSocket, msg: RelayMessage) { + if (!msg.deviceId || !msg.publicKey) { + ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' })); + return; + } + const ok = agentStore.register(msg.deviceId, msg.publicKey, ws); + if (!ok) { + ws.send(JSON.stringify({ type: 'error', code: 'device_id_conflict' })); + return; + } + ws.data.agentDeviceId = msg.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()}_${Math.random().toString(36).slice(2, 8)}`; + 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 agent registration + if (ws.data.agentDeviceId) { + agentStore.removeBySocket(ws); + } + // Clean up pairing sessions const otc = ws.data.otc; if (otc) { @@ -214,7 +282,7 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { const url = new URL(req.url); if (url.pathname === '/health') { - return Response.json({ status: 'ok', sessions: sessions.size }); + return Response.json({ status: 'ok', sessions: sessions.size, agents: agentStore.size }); } if (url.pathname === '/ws') { @@ -271,6 +339,15 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { case 'bootstrap_reject': handleBootstrapResponse(ws, msg); break; + case 'agent': + handleAgent(ws, msg); + break; + case 'shell': + handleShell(ws, msg); + break; + case 'ping': + handlePing(ws); + break; default: ws.send(JSON.stringify({ type: 'error', code: 'unknown_type' })); } @@ -288,7 +365,9 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { stop() { clearInterval(bootstrapCleanupTimer); sessions.destroy(); + agentStore.destroy(); rateLimiter.destroy(); + shellRateLimiter.destroy(); otcAttempts.destroy(); server?.stop(); }, diff --git a/packages/relay/src/session.ts b/packages/relay/src/session.ts index 3db9e79..a4e55ae 100644 --- a/packages/relay/src/session.ts +++ b/packages/relay/src/session.ts @@ -11,7 +11,7 @@ export interface PairingSession { /** * In-memory session store for active pairing sessions. - * Sessions are ephemeral — max 120 seconds lifetime. + * Sessions are ephemeral — max 60 seconds lifetime for pairing. */ export class SessionStore { private sessions = new Map(); @@ -22,7 +22,7 @@ export class SessionStore { this.cleanupTimer = setInterval(() => this.purge(), 10_000); } - create(otc: string, target: ServerWebSocket, ttlSeconds = 120): PairingSession { + create(otc: string, target: ServerWebSocket, ttlSeconds = 60): PairingSession { if (this.sessions.has(otc)) { throw new Error('OTC already in use'); } diff --git a/packages/shell/README.md b/packages/shell/README.md new file mode 100644 index 0000000..79dd5cc --- /dev/null +++ b/packages/shell/README.md @@ -0,0 +1,63 @@ +# @authmesh/shell + +Secure remote shell for [amesh](https://github.com/ameshdev/amesh) --- SSH-like access using device-bound identity. No SSH keys, no authorized_keys, instant per-device revocation. + +## Install + +```bash +brew install ameshdev/tap/amesh-shell +# or +npm install -g @authmesh/shell +``` + +You also need `@authmesh/cli` for pairing and permissions: +```bash +brew install ameshdev/tap/amesh +``` + +## Usage + +### On the target (server) + +```bash +# Grant shell access to a controller (one-time) +amesh grant am_3d9f1a2e --shell + +# Start the agent daemon +amesh-agent start +``` + +### On the controller (your laptop) + +```bash +# Interactive shell +amesh-shell prod-api + +# Single command +amesh-shell prod-api -c "uptime" +``` + +## Security + +- End-to-end encrypted (ChaCha20-Poly1305, ephemeral ECDH per session) +- Device-ID-bound session keys (HKDF domain separation) +- Shell access is opt-in (`amesh grant --shell`), not automatic from pairing +- Agent refuses to run as root without `--allow-root` +- HMAC-sealed allow list with tamper detection +- One-way trust: controllers access targets, never the reverse + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `AUTH_MESH_DIR` | Override `~/.amesh/` directory | +| `AUTH_MESH_PASSPHRASE` | Passphrase for encrypted-file backend | +| `AMESH_RELAY_URL` | Override default relay URL | + +## Full documentation + +- [Remote Shell Guide](https://github.com/ameshdev/amesh/blob/main/docs/remote-shell-spec.md) + +## License + +[MIT](https://github.com/ameshdev/amesh/blob/main/LICENSE) diff --git a/packages/shell/package.json b/packages/shell/package.json new file mode 100644 index 0000000..b11b130 --- /dev/null +++ b/packages/shell/package.json @@ -0,0 +1,58 @@ +{ + "name": "@authmesh/shell", + "version": "0.1.0", + "description": "Secure remote shell for amesh — SSH-like access with device-bound identity", + "type": "module", + "license": "MIT", + "author": "Yair Etzion", + "repository": { + "type": "git", + "url": "https://github.com/ameshdev/amesh.git", + "directory": "packages/shell" + }, + "homepage": "https://github.com/ameshdev/amesh", + "keywords": [ + "authentication", + "remote-shell", + "ssh-alternative", + "device-identity", + "pty", + "encrypted-shell" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "amesh-agent": "./dist/commands/agent-start.js", + "amesh-shell": "./dist/commands/shell.js" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -b", + "test": "bun test src/__tests__/", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@authmesh/core": "workspace:*", + "@authmesh/keystore": "workspace:*", + "@noble/curves": "2.0.1", + "@noble/ciphers": "2.1.1", + "@noble/hashes": "2.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.0.0" + } +} diff --git a/packages/shell/src/__tests__/frame.test.ts b/packages/shell/src/__tests__/frame.test.ts new file mode 100644 index 0000000..192d283 --- /dev/null +++ b/packages/shell/src/__tests__/frame.test.ts @@ -0,0 +1,62 @@ +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/shell/src/__tests__/shell-cipher.test.ts b/packages/shell/src/__tests__/shell-cipher.test.ts new file mode 100644 index 0000000..5bfdea3 --- /dev/null +++ b/packages/shell/src/__tests__/shell-cipher.test.ts @@ -0,0 +1,104 @@ +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('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/shell/src/agent.ts b/packages/shell/src/agent.ts new file mode 100644 index 0000000..d49392e --- /dev/null +++ b/packages/shell/src/agent.ts @@ -0,0 +1,271 @@ +import { ShellCipher } from './shell-cipher.js'; +import { AllowList, createForBackend } from '@authmesh/keystore'; +import type { StorageBackend } from '@authmesh/keystore'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +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; +} + +interface Identity { + deviceId: string; + keyAlias?: string; + publicKey: string; + friendlyName: string; + storageBackend: string; +} + +function getAmeshDir(): string { + return process.env.AUTH_MESH_DIR ?? join(homedir(), '.amesh'); +} + +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 ameshDir = getAmeshDir(); + const identityContent = await readFile(join(ameshDir, 'identity.json'), 'utf-8'); + const identity = JSON.parse(identityContent) as Identity; + + const keyStore = await createForBackend( + identity.storageBackend as StorageBackend, + join(ameshDir, 'keys'), + process.env.AUTH_MESH_PASSPHRASE, + ); + + const keyAlias = identity.keyAlias ?? identity.deviceId; + const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); + const allowList = new AllowList(join(ameshDir, 'allow_list.json'), hmacKey, identity.deviceId); + + const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); + + let activeSessions = 0; + const maxSessions = 5; + const maxSessionsPerController = 1; + const controllerSessions = new Map(); + + 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; + // Register agent with relay (C1 fix — includes publicKey) + send(ws, { + type: 'agent', + deviceId: identity.deviceId, + publicKey: identity.publicKey, + timestamp: new Date().toISOString(), + }); + console.log('[amesh-agent] Registered with relay. Waiting for shell requests...'); + + // 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; } + + if (msg.type === 'agent_registered') { + const controllers = await allowList.countByRole('controller'); + console.log(`[amesh-agent] Authorized controllers: ${controllers}`); + return; + } + + if (msg.type === 'pong') return; + + if (msg.type === 'peer_found') { + if (activeSessions >= maxSessions) { + console.error('[amesh-agent] Max sessions reached, rejecting'); + return; + } + // H3 fix — increment BEFORE async handshake to prevent race condition + activeSessions++; + handleShellRequest(ws, allowList, identity, signFn, opts.idleTimeoutMinutes) + .catch(() => {}) + .finally(() => { /* decremented inside handleShellRequest */ }); + return; + } + }); + + ws.addEventListener('close', () => { + 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, + ); + + // Per-controller session limit (M2 fix) + const current = controllerSessions.get(result.peerDeviceId) ?? 0; + if (current >= maxSessionsPerController) { + console.error(`[amesh-agent] Max sessions for ${result.peerDeviceId}, rejecting`); + ws.close(); + return; + } + + // activeSessions already incremented before handshake (H3 fix) + controllerSessions.set(result.peerDeviceId, current + 1); + 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(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 + 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; + 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); + } + }); + + // Wait for process exit + const exitCode = await proc.exited; + clearInterval(idleCheck); + + // 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(); + activeSessions--; + controllerSessions.set(result.peerDeviceId, (controllerSessions.get(result.peerDeviceId) ?? 1) - 1); + + } catch (err) { + console.error('[amesh-agent] Shell handshake failed:', (err as Error).message); + activeSessions--; // H3 fix — release slot on failure + } + } + + connect(); + + // Keep process alive + await new Promise(() => {}); +} diff --git a/packages/shell/src/commands/agent-start.ts b/packages/shell/src/commands/agent-start.ts new file mode 100644 index 0000000..c7351d7 --- /dev/null +++ b/packages/shell/src/commands/agent-start.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun +import { startAgent } from '../agent.js'; + +const args = process.argv.slice(2); +const flags = { + relayUrl: getFlag(args, '--relay') ?? process.env.AMESH_RELAY_URL ?? 'wss://relay.authmesh.dev/ws', + allowRoot: args.includes('--allow-root'), + idleTimeoutMinutes: parseInt(getFlag(args, '--idle-timeout') ?? '30', 10), +}; + +if (args.includes('--help') || args.includes('-h')) { + console.log(` + amesh-agent start — Run the amesh shell agent daemon + + USAGE + amesh-agent start [flags] + + FLAGS + --relay Relay URL (default: wss://relay.authmesh.dev/ws) + --idle-timeout Idle session timeout in minutes (default: 30) + --allow-root Allow running as root (grants root shells) + -h, --help Show help + + ENVIRONMENT + AUTH_MESH_DIR Override ~/.amesh/ directory + AUTH_MESH_PASSPHRASE Passphrase for encrypted-file backend + AMESH_RELAY_URL Override default relay URL +`); + process.exit(0); +} + +startAgent({ + relayUrl: flags.relayUrl, + allowRoot: flags.allowRoot, + idleTimeoutMinutes: flags.idleTimeoutMinutes, +}); + +function getFlag(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + if (idx === -1 || idx + 1 >= args.length) return undefined; + return args[idx + 1]; +} diff --git a/packages/shell/src/commands/shell.ts b/packages/shell/src/commands/shell.ts new file mode 100644 index 0000000..5284028 --- /dev/null +++ b/packages/shell/src/commands/shell.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env bun +import { connectShell } from '../shell.js'; + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h') || args.length === 0) { + console.log(` + amesh-shell — Open a secure remote shell to a paired device + + USAGE + amesh-shell [flags] + + ARGUMENTS + device Device ID (am_...) or friendly name of the target + + FLAGS + -c Run a single command and exit + --relay Relay URL (default: wss://relay.authmesh.dev/ws) + -h, --help Show help + + EXAMPLES + amesh-shell prod-api # interactive shell + amesh-shell am_7f2e8a1b -c "uptime" # single command + + ENVIRONMENT + AUTH_MESH_DIR Override ~/.amesh/ directory + AUTH_MESH_PASSPHRASE Passphrase for encrypted-file backend + AMESH_RELAY_URL Override default relay URL +`); + process.exit(0); +} + +const target = args[0]; +const command = getFlag(args, '-c'); +const relayUrl = getFlag(args, '--relay') ?? process.env.AMESH_RELAY_URL ?? 'wss://relay.authmesh.dev/ws'; + +const exitCode = await connectShell({ target, relayUrl, command }); +process.exit(exitCode); + +function getFlag(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + if (idx === -1 || idx + 1 >= args.length) return undefined; + return args[idx + 1]; +} diff --git a/packages/shell/src/frame.ts b/packages/shell/src/frame.ts new file mode 100644 index 0000000..d73a064 --- /dev/null +++ b/packages/shell/src/frame.ts @@ -0,0 +1,77 @@ +/** + * 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; +} + +export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } { + if (frame.length < 1) throw new Error('Empty frame'); + 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/shell/src/index.ts b/packages/shell/src/index.ts new file mode 100644 index 0000000..ec89cfb --- /dev/null +++ b/packages/shell/src/index.ts @@ -0,0 +1,15 @@ +export { ShellCipher } from './shell-cipher.js'; +export { runAgentShellHandshake, runControllerShellHandshake } from './shell-handshake.js'; +export type { ShellHandshakeResult } from './shell-handshake.js'; +export { + FrameType, + encodeDataFrame, + encodeResizeFrame, + encodeExitFrame, + encodePingFrame, + encodePongFrame, + encodeCommandFrame, + parseFrame, + parseResize, + parseExit, +} from './frame.js'; diff --git a/packages/shell/src/shell-cipher.ts b/packages/shell/src/shell-cipher.ts new file mode 100644 index 0000000..6f9d286 --- /dev/null +++ b/packages/shell/src/shell-cipher.ts @@ -0,0 +1,119 @@ +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); + + // Verify nonce matches expected receive counter + const expected = this.nextRecvNonce(); + if (!constantTimeEqual(nonce, expected)) { + throw new Error('Nonce mismatch — possible replay or out-of-order frame'); + } + + const cipher = chacha20poly1305(this.sessionKey, nonce); + return cipher.decrypt(ciphertext); + } + + 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; + } + + private nextRecvNonce(): Uint8Array { + if (this.recvCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); + const nonce = new Uint8Array(this.recvNonceStart); + this.incrementCounter(nonce, this.recvCounter); + 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/shell/src/shell-handshake.ts b/packages/shell/src/shell-handshake.ts new file mode 100644 index 0000000..cb731cd --- /dev/null +++ b/packages/shell/src/shell-handshake.ts @@ -0,0 +1,240 @@ +import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { randomBytes } from '@noble/ciphers/utils.js'; +import { + generateEphemeralKeyPair, + computeSharedSecret, + deriveShellSessionKey, + verifyMessage, +} from '@authmesh/core'; +import type { AllowList } from '@authmesh/keystore'; + +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; + + ws.addEventListener('message', (event: MessageEvent) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data); + const msg = JSON.parse(raw); + if (waiter) { + const w = waiter; + waiter = null; + w.resolve(msg); + } else { + queue.push(msg); + } + }); + + return { + read(timeoutMs = 30_000): Promise> { + if (queue.length > 0) return Promise.resolve(queue.shift()!); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiter = null; + reject(new Error('Timeout waiting for message')); + }, timeoutMs); + waiter = { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }; + }); + }, + }; +} + +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); +} + +function verifySelfSig(peer: PeerIdentity): boolean { + const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64')); + const message = new TextEncoder().encode(peer.publicKey + peer.friendlyName + peer.timestamp); + const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64')); + return verifyMessage(sig, message, publicKey); +} + +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; + + if (!verifySelfSig(peerIdentity)) { + 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 + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + 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 + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + 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; + + if (!verifySelfSig(peerIdentity)) { + 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/shell/src/shell.ts b/packages/shell/src/shell.ts new file mode 100644 index 0000000..e5afe46 --- /dev/null +++ b/packages/shell/src/shell.ts @@ -0,0 +1,194 @@ +import { ShellCipher } from './shell-cipher.js'; +import { AllowList, createForBackend } from '@authmesh/keystore'; +import type { StorageBackend } from '@authmesh/keystore'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +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 +} + +interface Identity { + deviceId: string; + keyAlias?: string; + publicKey: string; + friendlyName: string; + storageBackend: string; +} + +function getAmeshDir(): string { + return process.env.AUTH_MESH_DIR ?? join(homedir(), '.amesh'); +} + +export async function connectShell(opts: ShellOptions): Promise { + const ameshDir = getAmeshDir(); + const identityContent = await readFile(join(ameshDir, 'identity.json'), 'utf-8'); + const identity = JSON.parse(identityContent) as Identity; + + const keyStore = await createForBackend( + identity.storageBackend as StorageBackend, + join(ameshDir, 'keys'), + process.env.AUTH_MESH_PASSPHRASE, + ); + + const keyAlias = identity.keyAlias ?? identity.deviceId; + const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); + const allowList = new AllowList(join(ameshDir, 'allow_list.json'), 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}`); + 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'); + ws.close(); + return 1; + } + + 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/shell/tsconfig.json b/packages/shell/tsconfig.json new file mode 100644 index 0000000..1d4a86a --- /dev/null +++ b/packages/shell/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/__tests__"], + "references": [{ "path": "../core" }, { "path": "../keystore" }] +}