From 32a3dc8f572635c57eb7f597d7f0133e463c5df4 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Fri, 3 Apr 2026 16:49:19 +0300 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20consolidate=20into=20single=20p?= =?UTF-8?q?ackage=20=E2=80=94=20amesh=20shell=20+=20amesh=20agent=20start?= =?UTF-8?q?=20in=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One package, one binary, one install: - amesh shell [-c cmd] — remote shell client (oclif command) - amesh agent start [--allow-root] [--idle-timeout] — daemon (oclif command) - All existing commands unchanged Removed @authmesh/shell package entirely. Shell cipher, handshake, frame, agent, and shell client code now lives in @authmesh/cli. 15 tests moved from shell package to CLI (shell-cipher: 8, frame: 7). Updated: ADR-011, landing page guide + use case, grant command text. All commands now use `amesh` prefix: `amesh shell`, `amesh agent start`. --- bun.lock | 37 +++-------- docs/architecture-decisions.md | 10 +-- .../src/routes/docs/remote-shell/+page.svelte | 20 +++--- .../use-cases/remote-shell/+page.svelte | 4 +- packages/cli/package.json | 4 +- .../src/__tests__/frame.test.ts | 0 .../src/__tests__/shell-cipher.test.ts | 0 packages/{shell => cli}/src/agent.ts | 0 packages/cli/src/commands/agent/start.ts | 33 ++++++++++ packages/cli/src/commands/grant.ts | 2 +- packages/cli/src/commands/shell.ts | 38 +++++++++++ packages/{shell => cli}/src/frame.ts | 0 packages/{shell => cli}/src/shell-cipher.ts | 0 .../src/shell.ts => cli/src/shell-client.ts} | 0 .../{shell => cli}/src/shell-handshake.ts | 0 packages/shell/README.md | 63 ------------------- packages/shell/package.json | 58 ----------------- packages/shell/src/commands/agent-start.ts | 42 ------------- packages/shell/src/commands/shell.ts | 44 ------------- packages/shell/src/index.ts | 15 ----- packages/shell/tsconfig.json | 10 --- 21 files changed, 97 insertions(+), 283 deletions(-) rename packages/{shell => cli}/src/__tests__/frame.test.ts (100%) rename packages/{shell => cli}/src/__tests__/shell-cipher.test.ts (100%) rename packages/{shell => cli}/src/agent.ts (100%) create mode 100644 packages/cli/src/commands/agent/start.ts create mode 100644 packages/cli/src/commands/shell.ts rename packages/{shell => cli}/src/frame.ts (100%) rename packages/{shell => cli}/src/shell-cipher.ts (100%) rename packages/{shell/src/shell.ts => cli/src/shell-client.ts} (100%) rename packages/{shell => cli}/src/shell-handshake.ts (100%) delete mode 100644 packages/shell/README.md delete mode 100644 packages/shell/package.json delete mode 100644 packages/shell/src/commands/agent-start.ts delete mode 100644 packages/shell/src/commands/shell.ts delete mode 100644 packages/shell/src/index.ts delete mode 100644 packages/shell/tsconfig.json diff --git a/bun.lock b/bun.lock index 8245113..1fa1814 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ }, "packages/cli": { "name": "@authmesh/cli", - "version": "0.1.6", + "version": "0.2.0", "bin": { "amesh": "./dist/index.js", }, @@ -55,11 +55,13 @@ "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@oclif/core": "^4.0.0", }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", "@types/node": "^22.0.0", "eslint": "^9.0.0", "typescript": "^5.7.0", @@ -68,7 +70,7 @@ }, "packages/core": { "name": "@authmesh/core", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", @@ -83,7 +85,7 @@ }, "packages/keystore": { "name": "@authmesh/keystore", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@authmesh/core": "workspace:*", "@noble/ciphers": "2.1.1", @@ -100,7 +102,7 @@ }, "packages/relay": { "name": "@authmesh/relay", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@authmesh/core": "workspace:*", }, @@ -116,7 +118,7 @@ }, "packages/sdk": { "name": "@authmesh/sdk", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", @@ -135,29 +137,6 @@ "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"], @@ -170,8 +149,6 @@ "@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 ce811c9..1e51ef5 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -165,17 +165,17 @@ The controller CLI displays this code; the target CLI prompts the operator to en --- -## ADR-011: Remote shell as separate package with explicit shell permission +## ADR-011: Remote shell in the CLI 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. +**Decision:** The remote shell feature is part of `@authmesh/cli` — one package, one binary. `amesh shell` connects to a remote target. `amesh agent start` runs the daemon. Shell access requires explicit `amesh grant --shell` after pairing. **Why:** -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. +1. **One install:** Developers install one thing (`@authmesh/cli`) and get everything — identity management, pairing, API auth, shell client, and agent daemon. -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. +2. **Explicit consent:** Pairing for API authentication (`amesh invite`) does not grant shell access. A `permissions.shell` flag in the allow list defaults to `false`. The target admin must explicitly run `amesh grant --shell`. This is the security boundary, not the package boundary. -3. **Separate binaries:** `amesh-agent` and `amesh-shell` are distinct from `amesh` (the CLI). Users opt into shell capability by installing a separate package. +3. **The daemon is opt-in by invocation:** `amesh agent start` must be explicitly run. It doesn't auto-start, doesn't install as a service, and refuses to run as root without `--allow-root`. **Security design choices:** diff --git a/landpage/src/routes/docs/remote-shell/+page.svelte b/landpage/src/routes/docs/remote-shell/+page.svelte index b2696c9..374e1d1 100644 --- a/landpage/src/routes/docs/remote-shell/+page.svelte +++ b/landpage/src/routes/docs/remote-shell/+page.svelte @@ -45,15 +45,11 @@

Install

-

The shell feature is a separate package from the CLI.

+

The shell client and agent daemon are included in the CLI. One install.

- # 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`} /> +npm install -g @authmesh/cli`} />
@@ -85,10 +81,10 @@ amesh list

3. Start the agent

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

Interactive shell

- $ amesh-shell prod-api + $ amesh shell prod-api Connecting to prod-api (am_7f2e8a1b)... Connected. Shell session started. @@ -110,7 +106,7 @@ user

Single command

- $ amesh-shell prod-api -c "df -h" + $ amesh shell prod-api -c "df -h" Filesystem Size Used Avail Use% Mounted on /dev/sda1 50G 12G 35G 26% /`} />
@@ -170,7 +166,7 @@ Filesystem Size Used Avail Use% Mounted on
"Handshake failed" / connection timeout
-
The agent is not running on the target. Start it with amesh-agent start.
+
The agent is not running on the target. Start it with amesh agent start.
"Refusing to run as root"
diff --git a/landpage/src/routes/use-cases/remote-shell/+page.svelte b/landpage/src/routes/use-cases/remote-shell/+page.svelte index 276231b..bbac230 100644 --- a/landpage/src/routes/use-cases/remote-shell/+page.svelte +++ b/landpage/src/routes/use-cases/remote-shell/+page.svelte @@ -30,7 +30,7 @@ ]} codeTabs={[ { filename: 'Terminal (target)', code: `# On the server — start the agent daemon -$ amesh-agent start +$ amesh agent start amesh agent listening on relay.authmesh.dev Device: am_7f2e8a1b (prod-api) @@ -38,7 +38,7 @@ Waiting for shell requests...` }, { filename: 'Terminal (controller)', code: `# On your laptop — open a shell -$ amesh-shell prod-api +$ amesh shell prod-api Connecting to prod-api (am_7f2e8a1b)... Connected. Shell session started. diff --git a/packages/cli/package.json b/packages/cli/package.json index 94ef79d..5f9aac3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,7 @@ ], "scripts": { "build": "tsc -b", - "test": "echo 'no tests'", + "test": "bun test src/__tests__/", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "clean": "rm -rf dist *.tsbuildinfo" @@ -43,11 +43,13 @@ "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@oclif/core": "^4.0.0" }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", "@types/node": "^22.0.0", "eslint": "^9.0.0", "typescript": "^5.7.0", diff --git a/packages/shell/src/__tests__/frame.test.ts b/packages/cli/src/__tests__/frame.test.ts similarity index 100% rename from packages/shell/src/__tests__/frame.test.ts rename to packages/cli/src/__tests__/frame.test.ts diff --git a/packages/shell/src/__tests__/shell-cipher.test.ts b/packages/cli/src/__tests__/shell-cipher.test.ts similarity index 100% rename from packages/shell/src/__tests__/shell-cipher.test.ts rename to packages/cli/src/__tests__/shell-cipher.test.ts diff --git a/packages/shell/src/agent.ts b/packages/cli/src/agent.ts similarity index 100% rename from packages/shell/src/agent.ts rename to packages/cli/src/agent.ts diff --git a/packages/cli/src/commands/agent/start.ts b/packages/cli/src/commands/agent/start.ts new file mode 100644 index 0000000..9593a35 --- /dev/null +++ b/packages/cli/src/commands/agent/start.ts @@ -0,0 +1,33 @@ +import { Command, Flags } from '@oclif/core'; + +export default class AgentStart extends Command { + static override description = 'Start the amesh agent daemon (accepts remote shell connections)'; + + static override flags = { + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: 'wss://relay.authmesh.dev/ws', + env: 'AMESH_RELAY_URL', + }), + 'allow-root': Flags.boolean({ + description: 'Allow running as root (grants root shells to all controllers)', + default: false, + }), + 'idle-timeout': Flags.integer({ + description: 'Idle session timeout in minutes', + default: 30, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(AgentStart); + + const { startAgent } = await import('../../agent.js'); + await startAgent({ + relayUrl: flags.relay, + allowRoot: flags['allow-root'], + idleTimeoutMinutes: flags['idle-timeout'], + }); + } +} diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts index 4d54f21..488c8e5 100644 --- a/packages/cli/src/commands/grant.ts +++ b/packages/cli/src/commands/grant.ts @@ -42,7 +42,7 @@ export default class Grant extends Command { if (flags.shell) { this.log(' Shell access: granted'); this.log(''); - this.log(' This device can now open remote shells via amesh-shell.'); + this.log(' This device can now open remote shells via `amesh shell`.'); } else { this.log(' Shell access: revoked'); } diff --git a/packages/cli/src/commands/shell.ts b/packages/cli/src/commands/shell.ts new file mode 100644 index 0000000..ffa1f55 --- /dev/null +++ b/packages/cli/src/commands/shell.ts @@ -0,0 +1,38 @@ +import { Command, Args, Flags } from '@oclif/core'; + +export default class Shell extends Command { + static override description = 'Open a remote shell to a paired device'; + + static override args = { + device: Args.string({ + description: 'Device ID (am_...) or friendly name of the target', + required: true, + }), + }; + + static override flags = { + command: Flags.string({ + char: 'c', + description: 'Run a single command and exit', + }), + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: 'wss://relay.authmesh.dev/ws', + env: 'AMESH_RELAY_URL', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(Shell); + + const { connectShell } = await import('../shell-client.js'); + const exitCode = await connectShell({ + target: args.device, + relayUrl: flags.relay, + command: flags.command, + }); + + this.exit(exitCode); + } +} diff --git a/packages/shell/src/frame.ts b/packages/cli/src/frame.ts similarity index 100% rename from packages/shell/src/frame.ts rename to packages/cli/src/frame.ts diff --git a/packages/shell/src/shell-cipher.ts b/packages/cli/src/shell-cipher.ts similarity index 100% rename from packages/shell/src/shell-cipher.ts rename to packages/cli/src/shell-cipher.ts diff --git a/packages/shell/src/shell.ts b/packages/cli/src/shell-client.ts similarity index 100% rename from packages/shell/src/shell.ts rename to packages/cli/src/shell-client.ts diff --git a/packages/shell/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts similarity index 100% rename from packages/shell/src/shell-handshake.ts rename to packages/cli/src/shell-handshake.ts diff --git a/packages/shell/README.md b/packages/shell/README.md deleted file mode 100644 index 79dd5cc..0000000 --- a/packages/shell/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# @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 deleted file mode 100644 index 41ec6bd..0000000 --- a/packages/shell/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@authmesh/shell", - "version": "0.2.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/commands/agent-start.ts b/packages/shell/src/commands/agent-start.ts deleted file mode 100644 index c7351d7..0000000 --- a/packages/shell/src/commands/agent-start.ts +++ /dev/null @@ -1,42 +0,0 @@ -#!/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 deleted file mode 100644 index 5284028..0000000 --- a/packages/shell/src/commands/shell.ts +++ /dev/null @@ -1,44 +0,0 @@ -#!/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/index.ts b/packages/shell/src/index.ts deleted file mode 100644 index ec89cfb..0000000 --- a/packages/shell/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/tsconfig.json b/packages/shell/tsconfig.json deleted file mode 100644 index 1d4a86a..0000000 --- a/packages/shell/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/__tests__"], - "references": [{ "path": "../core" }, { "path": "../keystore" }] -} From 2c906bff0aceb272ca3c0e416946aa1c24dba931 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Fri, 3 Apr 2026 17:00:09 +0300 Subject: [PATCH 2/4] security + docs: fix review findings, update all documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - B2: PTY output now wrapped in JSON {type:'data',payload:...} (was raw base64 — broken) - C2: Bootstrap response whitelist fields (prevent JSON injection) - M2: Frame parser rejects unknown frame types - M3: Shell session OTC uses crypto.randomUUID() (was Math.random) - L3: Idle timeout min bound (min: 1 minute) Doc fixes: - shell-client.ts error message: amesh-agent → amesh agent start - CLAUDE.md: CLI description includes shell/agent/grant - README.md: CLI package table includes shell/agent/grant - CLI README: added shell, agent start, grant to commands list - ADR-011: removed self-contradicting rejected alternative - Removed ghost packages/agent/ directory --- README.md | 2 +- docs/architecture-decisions.md | 2 +- packages/cli/README.md | 3 +++ packages/cli/src/agent.ts | 2 +- packages/cli/src/commands/agent/start.ts | 1 + packages/cli/src/frame.ts | 6 ++++++ packages/cli/src/shell-client.ts | 2 +- packages/relay/src/server.ts | 6 ++++-- 8 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e7cea87..a95f05e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ app.use(amesh.verify()); | Package | Description | |---------|-------------| | [`@authmesh/sdk`](./packages/sdk) | Signing fetch client + Express verification middleware | -| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision` | +| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell`, `agent` | | [`@authmesh/core`](./packages/core) | Crypto primitives: sign, verify, canonical string, nonce, HMAC, HKDF, ECDH | | [`@authmesh/keystore`](./packages/keystore) | Key storage drivers: Secure Enclave, macOS Keychain, TPM 2.0, encrypted file | | [`@authmesh/relay`](./packages/relay) | WebSocket relay for device pairing handshakes | diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 1e51ef5..0b5b9ec 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -188,7 +188,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en - **Per-controller session limits** — prevents DoS by authorized-but-misbehaving peers **Rejected alternatives:** -- Bundling in `@authmesh/cli` — mixes API auth tooling with shell daemon, implicit capability creep +- Separate `@authmesh/agent` package — adds install confusion without meaningful security benefit; the permission gate (`amesh grant --shell`) is the real security boundary, not the package boundary - 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/packages/cli/README.md b/packages/cli/README.md index 756b62c..b2798b9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,6 +17,9 @@ amesh invite # Join pairing (controller side) amesh list # Show trusted devices amesh revoke # Remove a trusted device amesh provision # Generate bootstrap tokens +amesh grant --shell # Grant shell access to a controller +amesh shell # Open remote shell to a target +amesh agent start # Start the agent daemon (target side) ``` ## Pairing flow diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index d49392e..e9d8dff 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -185,7 +185,7 @@ export async function startAgent(opts: AgentOptions): Promise { const frame = encodeDataFrame(data); const encrypted = cipher.encrypt(frame); if (ws.readyState === WebSocket.OPEN) { - ws.send(Buffer.from(encrypted).toString('base64')); + ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(encrypted).toString('base64') })); } }, }, diff --git a/packages/cli/src/commands/agent/start.ts b/packages/cli/src/commands/agent/start.ts index 9593a35..29d6827 100644 --- a/packages/cli/src/commands/agent/start.ts +++ b/packages/cli/src/commands/agent/start.ts @@ -17,6 +17,7 @@ export default class AgentStart extends Command { 'idle-timeout': Flags.integer({ description: 'Idle session timeout in minutes', default: 30, + min: 1, }), }; diff --git a/packages/cli/src/frame.ts b/packages/cli/src/frame.ts index d73a064..8da47d5 100644 --- a/packages/cli/src/frame.ts +++ b/packages/cli/src/frame.ts @@ -56,8 +56,14 @@ export function encodeCommandFrame(command: string): Uint8Array { return frame; } +const VALID_FRAME_TYPES = new Set([ + FrameType.DATA, FrameType.RESIZE, FrameType.EXIT, + FrameType.PING, FrameType.PONG, FrameType.COMMAND, +]); + export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } { if (frame.length < 1) throw new Error('Empty frame'); + if (!VALID_FRAME_TYPES.has(frame[0])) throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`); return { type: frame[0] as FrameTypeValue, payload: frame.subarray(1), diff --git a/packages/cli/src/shell-client.ts b/packages/cli/src/shell-client.ts index e5afe46..4400e55 100644 --- a/packages/cli/src/shell-client.ts +++ b/packages/cli/src/shell-client.ts @@ -94,7 +94,7 @@ export async function connectShell(opts: ShellOptions): Promise { ); } 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'); + console.error('Is the agent running on the target? Start it with: amesh agent start'); ws.close(); return 1; } diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 6dce546..d8fe801 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -172,13 +172,15 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { } // Bootstrap: controller responds (ack or reject) — forward to target + // Whitelist forwarded fields to prevent injection of arbitrary JSON (C2 fix) function handleBootstrapResponse(ws: ServerWebSocket, msg: RelayMessage) { const jti = msg.jti; if (!jti) return; + const safe = { type: msg.type, jti: msg.jti, controllerPubKey: msg.publicKey }; // Find target socket by jti for (const client of connectedSockets) { if (client.data.btJti === jti && client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(msg)); + client.send(JSON.stringify(safe)); } } // Clean up watcher @@ -219,7 +221,7 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { 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)}`; + const shellOtc = `shell_${Date.now()}_${crypto.randomUUID()}`; try { sessions.create(shellOtc, agentWs, 600); // 10 min TTL for shell sessions sessions.get(shellOtc)!.controller = ws; From 84ff9400a85e04ab5eb5cbaffb5b1e580bf064c4 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Fri, 3 Apr 2026 17:59:31 +0300 Subject: [PATCH 3/4] security: relay challenge-response for agent auth, enforce single session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 fix — Agent registration now requires proof of key ownership: 1. Agent sends { type: 'agent', deviceId, publicKey } 2. Relay responds with { type: 'agent_challenge', challenge: randomUUID } 3. Agent signs challenge with private key, sends { type: 'agent_challenge_response', sig } 4. Relay verifies ECDSA-P256 signature against claimed publicKey 5. Only then registers the agent An attacker who knows the publicKey cannot forge the signature. Relay imports verifyMessage from @authmesh/core for verification. H1 fix — Single session enforcement: - Replaced maxSessions=5 + per-controller tracking with simple boolean flag - sessionActive set before handshake, cleared in .finally() - No race condition, no counter leaks, no session cross-contamination Also: fixed Dockerfile.relay for consolidated workspace (removed shell ref) --- Dockerfile.relay | 17 ++++++++++-- packages/cli/src/agent.ts | 52 +++++++++++++++++------------------- packages/relay/src/server.ts | 49 ++++++++++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 33 deletions(-) diff --git a/Dockerfile.relay b/Dockerfile.relay index 823571b..fd1d2c8 100644 --- a/Dockerfile.relay +++ b/Dockerfile.relay @@ -11,7 +11,7 @@ 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/ +# packages/shell was consolidated into packages/cli # 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 @@ -33,12 +33,25 @@ FROM oven/bun:1.3-slim WORKDIR /app -# Only copy the built dist files — relay has zero external runtime deps +# Copy workspace config + package.jsons for workspace resolution +COPY --from=build /app/package.json /app/bun.lock ./ COPY --from=build /app/packages/core/package.json packages/core/ COPY --from=build /app/packages/core/dist packages/core/dist COPY --from=build /app/packages/relay/package.json packages/relay/ COPY --from=build /app/packages/relay/dist packages/relay/dist +# Stub other workspace packages so bun can resolve the workspace +RUN mkdir -p packages/keystore packages/sdk packages/cli && \ + echo '{"name":"@authmesh/keystore","private":true}' > packages/keystore/package.json && \ + echo '{"name":"@authmesh/sdk","private":true}' > packages/sdk/package.json && \ + echo '{"name":"@authmesh/cli","private":true}' > packages/cli/package.json && \ + mkdir -p demo landpage && \ + echo '{"name":"demo","private":true}' > demo/package.json && \ + echo '{"name":"landpage","private":true}' > landpage/package.json + +# Install to create workspace links +RUN bun install + EXPOSE 3001 ENV PORT=3001 diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index e9d8dff..41bf435 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -62,10 +62,7 @@ export async function startAgent(opts: AgentOptions): Promise { const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); - let activeSessions = 0; - const maxSessions = 5; - const maxSessionsPerController = 1; - const controllerSessions = new Map(); + let sessionActive = false; console.log(`[amesh-agent] Device: ${identity.deviceId} (${identity.friendlyName})`); console.log(`[amesh-agent] Connecting to relay: ${opts.relayUrl}`); @@ -79,14 +76,12 @@ export async function startAgent(opts: AgentOptions): Promise { ws.addEventListener('open', () => { reconnectDelay = 1000; - // Register agent with relay (C1 fix — includes publicKey) + // Step 1: Send registration request — relay will issue a challenge 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(() => { @@ -105,24 +100,38 @@ export async function startAgent(opts: AgentOptions): Promise { let msg; try { msg = JSON.parse(raw); } catch { return; } + // Step 2: Relay issues challenge — sign it to prove key ownership + if (msg.type === 'agent_challenge') { + const challenge = new TextEncoder().encode(msg.challenge as string); + const sig = await signFn(challenge); + send(ws, { + type: 'agent_challenge_response', + sig: Buffer.from(sig).toString('base64url'), + }); + return; + } + if (msg.type === 'agent_registered') { - const controllers = await allowList.countByRole('controller'); - console.log(`[amesh-agent] Authorized controllers: ${controllers}`); + console.log('[amesh-agent] Registered with relay (identity verified).'); + const data = await allowList.read(); + const shellControllers = data.devices.filter( + (d) => d.role === 'controller' && d.permissions?.shell, + ).length; + console.log(`[amesh-agent] Authorized controllers with shell access: ${shellControllers}`); return; } if (msg.type === 'pong') return; if (msg.type === 'peer_found') { - if (activeSessions >= maxSessions) { - console.error('[amesh-agent] Max sessions reached, rejecting'); + if (sessionActive) { + console.error('[amesh-agent] Session already active, rejecting'); return; } - // H3 fix — increment BEFORE async handshake to prevent race condition - activeSessions++; + sessionActive = true; handleShellRequest(ws, allowList, identity, signFn, opts.idleTimeoutMinutes) .catch(() => {}) - .finally(() => { /* decremented inside handleShellRequest */ }); + .finally(() => { sessionActive = false; }); return; } }); @@ -154,16 +163,6 @@ export async function startAgent(opts: AgentOptions): Promise { 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})`); @@ -255,12 +254,11 @@ export async function startAgent(opts: AgentOptions): Promise { 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); + // sessionActive reset by .finally() in caller } catch (err) { console.error('[amesh-agent] Shell handshake failed:', (err as Error).message); - activeSessions--; // H3 fix — release slot on failure + // sessionActive reset by .finally() in caller } } diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index d8fe801..5a69962 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,10 +1,11 @@ import type { ServerWebSocket } from 'bun'; +import { verifyMessage } from '@authmesh/core'; 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' | 'ping' | 'agent' | 'shell' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' | 'bootstrap_reject'; + type: 'listen' | 'connect' | 'data' | 'done' | 'ping' | 'agent' | 'agent_challenge_response' | 'shell' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' | 'bootstrap_reject'; otc?: string; payload?: string; jti?: string; @@ -187,18 +188,52 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { bootstrapWatchers.delete(jti); } - // Shell: agent registration (C1 fix — includes publicKey for anti-squatting) + // Pending agent challenges: ws → { deviceId, publicKey, challenge } + const pendingChallenges = new Map, { deviceId: string; publicKey: string; challenge: string }>(); + + // Shell: agent registration step 1 — issue challenge function handleAgent(ws: ServerWebSocket, msg: RelayMessage) { if (!msg.deviceId || !msg.publicKey) { ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' })); return; } - const ok = agentStore.register(msg.deviceId, msg.publicKey, ws); + // Generate a random challenge nonce + const challenge = crypto.randomUUID(); + pendingChallenges.set(ws, { deviceId: msg.deviceId, publicKey: msg.publicKey, challenge }); + ws.send(JSON.stringify({ type: 'agent_challenge', challenge })); + } + + // Shell: agent registration step 2 — verify challenge response + function handleAgentChallengeResponse(ws: ServerWebSocket, msg: RelayMessage) { + const pending = pendingChallenges.get(ws); + if (!pending) { + ws.send(JSON.stringify({ type: 'error', code: 'no_pending_challenge' })); + return; + } + pendingChallenges.delete(ws); + + if (!msg.sig) { + ws.send(JSON.stringify({ type: 'error', code: 'missing_signature' })); + return; + } + + // Verify the agent signed the challenge with the claimed private key + const publicKey = new Uint8Array(Buffer.from(pending.publicKey, 'base64')); + const message = new TextEncoder().encode(pending.challenge); + const signature = new Uint8Array(Buffer.from(msg.sig as string, 'base64url')); + + if (!verifyMessage(signature, message, publicKey)) { + ws.send(JSON.stringify({ type: 'error', code: 'invalid_signature' })); + return; + } + + // Signature valid — agent proves it holds the private key + const ok = agentStore.register(pending.deviceId, pending.publicKey, ws); if (!ok) { ws.send(JSON.stringify({ type: 'error', code: 'device_id_conflict' })); return; } - ws.data.agentDeviceId = msg.deviceId; + ws.data.agentDeviceId = pending.deviceId; ws.send(JSON.stringify({ type: 'agent_registered' })); } @@ -241,6 +276,9 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { } function cleanupSocket(ws: ServerWebSocket) { + // Clean up pending challenges + pendingChallenges.delete(ws); + // Clean up agent registration if (ws.data.agentDeviceId) { agentStore.removeBySocket(ws); @@ -344,6 +382,9 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { case 'agent': handleAgent(ws, msg); break; + case 'agent_challenge_response': + handleAgentChallengeResponse(ws, msg); + break; case 'shell': handleShell(ws, msg); break; From 3bcff2634ac20bed962b4abe344ff12d5aa7d189 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Fri, 3 Apr 2026 18:11:10 +0300 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20split=20into=20two=20binaries?= =?UTF-8?q?=20=E2=80=94=20amesh=20(CLI)=20+=20amesh-agent=20(server)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate packages, two separate binaries: - @authmesh/cli (amesh): init, listen, invite, list, revoke, grant, provision, shell - @authmesh/agent (amesh-agent): superset — all CLI commands + agent daemon CLI has no daemon code. Agent is a full copy of CLI + agent start command. ~400 lines of shared protocol code duplicated (shell-cipher, handshake, frame). Updated: README packages table, remote shell guide install section, agent README. --- README.md | 3 +- bun.lock | 25 ++ .../src/routes/docs/remote-shell/+page.svelte | 10 +- packages/agent/README.md | 42 +++ packages/agent/package.json | 59 ++++ packages/agent/src/__tests__/frame.test.ts | 62 ++++ .../agent/src/__tests__/shell-cipher.test.ts | 104 ++++++ packages/{cli => agent}/src/agent.ts | 0 packages/agent/src/bootstrap-token.ts | 121 +++++++ .../src/commands/agent/start.ts | 0 packages/agent/src/commands/grant.ts | 51 +++ packages/agent/src/commands/init.ts | 127 ++++++++ packages/agent/src/commands/invite.ts | 83 +++++ packages/agent/src/commands/list.ts | 45 +++ packages/agent/src/commands/listen.ts | 126 +++++++ packages/agent/src/commands/provision.ts | 88 +++++ packages/agent/src/commands/revoke.ts | 61 ++++ packages/agent/src/commands/shell.ts | 38 +++ packages/agent/src/context.ts | 31 ++ packages/agent/src/frame.ts | 83 +++++ packages/agent/src/handshake.ts | 307 ++++++++++++++++++ packages/agent/src/identity.ts | 44 +++ packages/agent/src/index.ts | 4 + packages/agent/src/paths.ts | 20 ++ packages/agent/src/sea.ts | 133 ++++++++ packages/agent/src/shell-cipher.ts | 119 +++++++ packages/agent/src/shell-client.ts | 194 +++++++++++ packages/agent/src/shell-handshake.ts | 240 ++++++++++++++ packages/agent/tsconfig.json | 10 + 29 files changed, 2225 insertions(+), 5 deletions(-) create mode 100644 packages/agent/README.md create mode 100644 packages/agent/package.json create mode 100644 packages/agent/src/__tests__/frame.test.ts create mode 100644 packages/agent/src/__tests__/shell-cipher.test.ts rename packages/{cli => agent}/src/agent.ts (100%) create mode 100644 packages/agent/src/bootstrap-token.ts rename packages/{cli => agent}/src/commands/agent/start.ts (100%) create mode 100644 packages/agent/src/commands/grant.ts create mode 100644 packages/agent/src/commands/init.ts create mode 100644 packages/agent/src/commands/invite.ts create mode 100644 packages/agent/src/commands/list.ts create mode 100644 packages/agent/src/commands/listen.ts create mode 100644 packages/agent/src/commands/provision.ts create mode 100644 packages/agent/src/commands/revoke.ts create mode 100644 packages/agent/src/commands/shell.ts create mode 100644 packages/agent/src/context.ts create mode 100644 packages/agent/src/frame.ts create mode 100644 packages/agent/src/handshake.ts create mode 100644 packages/agent/src/identity.ts create mode 100644 packages/agent/src/index.ts create mode 100644 packages/agent/src/paths.ts create mode 100644 packages/agent/src/sea.ts create mode 100644 packages/agent/src/shell-cipher.ts create mode 100644 packages/agent/src/shell-client.ts create mode 100644 packages/agent/src/shell-handshake.ts create mode 100644 packages/agent/tsconfig.json diff --git a/README.md b/README.md index a95f05e..09ef8ca 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ app.use(amesh.verify()); | Package | Description | |---------|-------------| | [`@authmesh/sdk`](./packages/sdk) | Signing fetch client + Express verification middleware | -| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell`, `agent` | +| [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision`, `grant`, `shell` | +| [`@authmesh/agent`](./packages/agent) | Agent daemon + full CLI: all CLI commands + `agent start` | | [`@authmesh/core`](./packages/core) | Crypto primitives: sign, verify, canonical string, nonce, HMAC, HKDF, ECDH | | [`@authmesh/keystore`](./packages/keystore) | Key storage drivers: Secure Enclave, macOS Keychain, TPM 2.0, encrypted file | | [`@authmesh/relay`](./packages/relay) | WebSocket relay for device pairing handshakes | diff --git a/bun.lock b/bun.lock index 1fa1814..62b1c4b 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,29 @@ "vite": "^7.3.1", }, }, + "packages/agent": { + "name": "@authmesh/agent", + "version": "0.2.0", + "bin": { + "amesh-agent": "./dist/index.js", + }, + "dependencies": { + "@authmesh/core": "workspace:*", + "@authmesh/keystore": "workspace:*", + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@oclif/core": "^4.0.0", + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.0.0", + }, + }, "packages/cli": { "name": "@authmesh/cli", "version": "0.2.0", @@ -139,6 +162,8 @@ }, }, "packages": { + "@authmesh/agent": ["@authmesh/agent@workspace:packages/agent"], + "@authmesh/cli": ["@authmesh/cli@workspace:packages/cli"], "@authmesh/core": ["@authmesh/core@workspace:packages/core"], diff --git a/landpage/src/routes/docs/remote-shell/+page.svelte b/landpage/src/routes/docs/remote-shell/+page.svelte index 374e1d1..8a70531 100644 --- a/landpage/src/routes/docs/remote-shell/+page.svelte +++ b/landpage/src/routes/docs/remote-shell/+page.svelte @@ -45,11 +45,13 @@

Install

-

The shell client and agent daemon are included in the CLI. One install.

+

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

- # or -npm install -g @authmesh/cli`} /> + # On your laptop (controller) +brew install ameshdev/tap/amesh + +# On the server (target) — includes all CLI commands + daemon +brew install ameshdev/tap/amesh-agent`} />
diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 0000000..d648aeb --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,42 @@ +# @authmesh/agent + +Agent daemon for [amesh](https://github.com/ameshdev/amesh) remote shell --- secure remote access using device-bound identity. Includes all CLI commands plus the agent daemon. One install on the server. + +## Install + +```bash +brew install ameshdev/tap/amesh-agent +# or +npm install -g @authmesh/agent +``` + +## Setup + +```bash +amesh-agent init --name "prod-api" +amesh-agent listen +# Controller runs: amesh invite + +amesh-agent grant am_3d9f1a2e --shell +amesh-agent agent start +``` + +## Commands + +All CLI commands plus the agent daemon: + +```bash +amesh-agent init --name "prod-api" # Create device identity +amesh-agent listen # Start pairing (target side) +amesh-agent invite # Join pairing +amesh-agent list # Show paired devices +amesh-agent revoke # Remove a device +amesh-agent grant --shell # Grant shell access +amesh-agent provision # Generate bootstrap tokens +amesh-agent shell # Open remote shell +amesh-agent agent start # Start the agent daemon +``` + +## License + +[MIT](https://github.com/ameshdev/amesh/blob/main/LICENSE) diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 0000000..2bcf2a4 --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,59 @@ +{ + "name": "@authmesh/agent", + "version": "0.2.0", + "description": "amesh agent daemon + CLI — remote shell target with device-bound identity", + "type": "module", + "license": "MIT", + "author": "Yair Etzion", + "repository": { + "type": "git", + "url": "https://github.com/ameshdev/amesh.git", + "directory": "packages/agent" + }, + "homepage": "https://github.com/ameshdev/amesh", + "keywords": [ + "authentication", + "agent", + "remote-shell", + "ssh-alternative", + "device-identity", + "pty", + "daemon" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "amesh-agent": "./dist/index.js" + }, + "oclif": { + "commands": "./dist/commands", + "bin": "amesh-agent" + }, + "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/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@oclif/core": "^4.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/bun": "^1.3.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.0.0" + } +} diff --git a/packages/agent/src/__tests__/frame.test.ts b/packages/agent/src/__tests__/frame.test.ts new file mode 100644 index 0000000..192d283 --- /dev/null +++ b/packages/agent/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/agent/src/__tests__/shell-cipher.test.ts b/packages/agent/src/__tests__/shell-cipher.test.ts new file mode 100644 index 0000000..5bfdea3 --- /dev/null +++ b/packages/agent/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/cli/src/agent.ts b/packages/agent/src/agent.ts similarity index 100% rename from packages/cli/src/agent.ts rename to packages/agent/src/agent.ts diff --git a/packages/agent/src/bootstrap-token.ts b/packages/agent/src/bootstrap-token.ts new file mode 100644 index 0000000..a419d3c --- /dev/null +++ b/packages/agent/src/bootstrap-token.ts @@ -0,0 +1,121 @@ +import { verifyMessage } from '@authmesh/core'; +import { randomBytes } from '@noble/ciphers/utils.js'; +import type { KeyStore } from '@authmesh/keystore'; + +export interface BootstrapTokenHeader { + typ: 'amesh-bootstrap'; + ver: '1'; + alg: 'ES256'; +} + +export interface BootstrapTokenPayload { + iss: string; // controller device ID + pub: string; // controller public key (base64, compressed P-256) + iat: number; // issued at (unix seconds) + exp: number; // expiry (unix seconds) + jti: string; // unique token ID + name: string; // friendly name for the target + relay: string; // relay URL + scope: 'peer:add'; + single_use: true; +} + +const MAX_TTL = 86400; // 24 hours +const PREFIX = 'amesh-bt-v1'; + +function b64url(data: string): string { + return Buffer.from(data).toString('base64url'); +} + +function b64urlDecode(encoded: string): string { + return Buffer.from(encoded, 'base64url').toString(); +} + +/** + * Generate a bootstrap token signed by the controller's key. + */ +export async function generateBootstrapToken(opts: { + issuerDeviceId: string; + keyAlias?: string; + name: string; + ttlSeconds: number; + relay: string; + keyStore: KeyStore; +}): Promise<{ token: string; payload: BootstrapTokenPayload }> { + if (opts.ttlSeconds > MAX_TTL) { + throw new Error('ttl cannot exceed 24 hours'); + } + + const now = Math.floor(Date.now() / 1000); + const jti = `bt_${Buffer.from(randomBytes(16)).toString('hex')}`; + const keyAlias = opts.keyAlias ?? opts.issuerDeviceId; + const publicKey = await opts.keyStore.getPublicKey(keyAlias); + + const header: BootstrapTokenHeader = { typ: 'amesh-bootstrap', ver: '1', alg: 'ES256' }; + const payload: BootstrapTokenPayload = { + iss: opts.issuerDeviceId, + pub: Buffer.from(publicKey).toString('base64'), + iat: now, + exp: now + opts.ttlSeconds, + jti, + name: opts.name, + relay: opts.relay, + scope: 'peer:add', + single_use: true, + }; + + const headerB64 = b64url(JSON.stringify(header)); + const payloadB64 = b64url(JSON.stringify(payload)); + const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sig = await opts.keyStore.sign(keyAlias, sigInput); + + const token = `${PREFIX}.${headerB64}.${payloadB64}.${Buffer.from(sig).toString('base64url')}`; + return { token, payload }; +} + +/** + * Decode a bootstrap token without verifying the signature. + */ +export function decodeBootstrapToken(token: string): { + header: BootstrapTokenHeader; + payload: BootstrapTokenPayload; + signatureInput: string; + signature: Uint8Array; +} { + if (!token.startsWith(`${PREFIX}.`)) { + throw new Error('invalid_token_format'); + } + + const parts = token.slice(PREFIX.length + 1).split('.'); + if (parts.length !== 3) throw new Error('invalid_token_format'); + + const [headerB64, payloadB64, sigB64] = parts; + const header = JSON.parse(b64urlDecode(headerB64)) as BootstrapTokenHeader; + const payload = JSON.parse(b64urlDecode(payloadB64)) as BootstrapTokenPayload; + const signature = new Uint8Array(Buffer.from(sigB64, 'base64url')); + + if (header.typ !== 'amesh-bootstrap') throw new Error('invalid_token_type'); + if (header.ver !== '1') throw new Error('unsupported_token_version'); + + return { header, payload, signatureInput: `${headerB64}.${payloadB64}`, signature }; +} + +/** + * Validate a bootstrap token: check expiry and verify signature. + */ +export function validateBootstrapToken( + token: string, + controllerPublicKey: Uint8Array, +): BootstrapTokenPayload { + const { payload, signatureInput, signature } = decodeBootstrapToken(token); + + const now = Math.floor(Date.now() / 1000); + if (payload.exp <= now) throw new Error('token_expired'); + + const message = new TextEncoder().encode(signatureInput); + if (!verifyMessage(signature, message, controllerPublicKey)) { + throw new Error('invalid_signature'); + } + + return payload; +} diff --git a/packages/cli/src/commands/agent/start.ts b/packages/agent/src/commands/agent/start.ts similarity index 100% rename from packages/cli/src/commands/agent/start.ts rename to packages/agent/src/commands/agent/start.ts diff --git a/packages/agent/src/commands/grant.ts b/packages/agent/src/commands/grant.ts new file mode 100644 index 0000000..488c8e5 --- /dev/null +++ b/packages/agent/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/agent/src/commands/init.ts b/packages/agent/src/commands/init.ts new file mode 100644 index 0000000..0c8038c --- /dev/null +++ b/packages/agent/src/commands/init.ts @@ -0,0 +1,127 @@ +import { Command, Flags } from '@oclif/core'; +import { createForBackend, detectAndCreate } from '@authmesh/keystore'; +import type { StorageBackend } from '@authmesh/keystore'; +import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; +import { getIdentityPath, getKeysDir } from '../paths.js'; +import { rename } from 'node:fs/promises'; +import { join } from 'node:path'; + +const deviceIdPlaceholder = 'am_init'; + +export default class Init extends Command { + static override description = 'Create a cryptographic identity for this device'; + + static override flags = { + name: Flags.string({ + char: 'n', + description: 'Friendly name for this device', + required: true, + }), + backend: Flags.string({ + char: 'b', + description: 'Force a specific storage backend', + options: ['secure-enclave', 'keychain', 'tpm2', 'encrypted-file'], + }), + passphrase: Flags.string({ + char: 'p', + description: 'Passphrase for encrypted-file backend (or set AUTH_MESH_PASSPHRASE)', + env: 'AUTH_MESH_PASSPHRASE', + }), + force: Flags.boolean({ + description: 'Overwrite existing identity', + default: false, + }), + 'max-controllers': Flags.integer({ + description: 'Maximum number of controllers allowed (default: 1)', + default: 1, + min: 1, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Init); + + const identityPath = getIdentityPath(); + if (!flags.force && (await identityExists(identityPath))) { + this.error('Identity already exists. Use --force to overwrite.'); + } + + // Validate passphrase requirement for encrypted-file backend + if (flags.backend === 'encrypted-file' && !flags.passphrase) { + this.error( + 'Encrypted-file backend requires a passphrase.\n' + + ' Use --passphrase or set AUTH_MESH_PASSPHRASE.', + ); + } + + this.log(''); + this.log('Generating P-256 keypair...'); + + const keysDir = getKeysDir(); + let backend: StorageBackend; + let keyStore; + + if (flags.backend) { + backend = flags.backend as StorageBackend; + keyStore = await createForBackend(backend, keysDir, flags.passphrase); + } else { + const result = await detectAndCreate(keysDir, flags.passphrase); + backend = result.backend; + keyStore = result.keyStore; + if (result.warning) { + this.warn(result.warning); + } + } + + // Generate key and derive device ID from public key + const { publicKey } = await keyStore.generateAndStore(deviceIdPlaceholder); + const deviceId = generateDeviceId(publicKey); + + let keyAlias: string; + + if (backend === 'encrypted-file') { + // Encrypted-file driver stores keys as files — rename to real device ID + const oldPath = join(keysDir, `${deviceIdPlaceholder}.key.json`); + const newPath = join(keysDir, `${deviceId}.key.json`); + await rename(oldPath, newPath); + keyAlias = deviceId; + } else { + // Hardware keystores can't rename keys — key stays stored under deviceIdPlaceholder. + // context.ts maps deviceId → internal key name via identity.keyAlias. + keyAlias = deviceIdPlaceholder; + } + + const identity = { + version: '2.0.0' as const, + deviceId, + keyAlias, + publicKey: Buffer.from(publicKey).toString('base64'), + friendlyName: flags.name, + createdAt: new Date().toISOString(), + storageBackend: backend, + ...(flags['max-controllers'] > 1 ? { maxControllers: flags['max-controllers'] } : {}), + }; + + await saveIdentity(identityPath, identity); + + // Remove stale allow list — it was sealed with the old key and can't be verified + const { getAllowListPath } = await import('../paths.js'); + const { unlink } = await import('node:fs/promises'); + await unlink(getAllowListPath()).catch(() => {}); + + this.log('Identity created.'); + this.log(''); + this.log(` Device ID : ${deviceId}`); + this.log(` Public Key: ${identity.publicKey.slice(0, 20)}...`); + this.log(` Backend : ${backend}`); + if (backend === 'encrypted-file') { + this.log(''); + this.warn( + 'Using file-based key storage. Keys are protected by filesystem permissions and a passphrase, not hardware.\n' + + ' For hardware-backed storage, use macOS or a Linux host with TPM 2.0.', + ); + } + this.log(''); + this.log('Run `amesh listen` on this machine, then `amesh invite` from your laptop.'); + } +} diff --git a/packages/agent/src/commands/invite.ts b/packages/agent/src/commands/invite.ts new file mode 100644 index 0000000..53b618d --- /dev/null +++ b/packages/agent/src/commands/invite.ts @@ -0,0 +1,83 @@ +import { Command, Args, Flags } from '@oclif/core'; +import { loadContext } from '../context.js'; +import { runControllerHandshake } from '../handshake.js'; +const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; + +export default class Invite extends Command { + static override description = 'Pair with a target device using its pairing code'; + + static override args = { + code: Args.string({ + description: '6-digit pairing code from the target device', + required: true, + }), + }; + + static override flags = { + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: DEFAULT_RELAY, + env: 'AMESH_RELAY_URL', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(Invite); + + if (!/^\d{6}$/.test(args.code)) { + this.error('Pairing code must be exactly 6 digits.'); + } + + const { identity, keyStore, allowList, keyAlias } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + this.log(''); + this.log(` Connecting to relay with code ${args.code}...`); + + const signFn = async (message: Uint8Array) => { + return keyStore.sign(keyAlias, message); + }; + + let result; + try { + result = await runControllerHandshake( + flags.relay, + args.code, + identity.publicKey, + identity.friendlyName, + signFn, + ); + } catch (err) { + this.error(`Handshake failed: ${(err as Error).message}`); + } + + this.log(' Peer found.'); + this.log(' Ephemeral P-256 ECDH tunnel established.'); + this.log(' Keys exchanged and verified.'); + this.log(''); + this.log(' ┌──────────────────────────────────┐'); + this.log(` │ Verification code: ${result.sas} │`); + this.log(' │ Enter this code on the Target │'); + this.log(' │ device to complete pairing. │'); + this.log(' └──────────────────────────────────┘'); + this.log(''); + + await allowList.addDevice({ + deviceId: `am_${Buffer.from(result.peerPublicKey).toString('base64url').slice(0, 16)}`, + publicKey: Buffer.from(result.peerPublicKey).toString('base64'), + friendlyName: result.peerFriendlyName, + addedAt: new Date().toISOString(), + addedBy: 'handshake', + role: 'target', + }); + + this.log(''); + this.log(` "${result.peerFriendlyName}" added as target.`); + this.log(''); + this.log(' Pairing complete. The relay connection is closed.'); + this.log(''); + } + +} diff --git a/packages/agent/src/commands/list.ts b/packages/agent/src/commands/list.ts new file mode 100644 index 0000000..ee2c39f --- /dev/null +++ b/packages/agent/src/commands/list.ts @@ -0,0 +1,45 @@ +import { Command } from '@oclif/core'; +import { loadContext } from '../context.js'; + +export default class List extends Command { + static override description = 'Show trusted devices in the allow list'; + + async run(): Promise { + await this.parse(List); + const { identity, allowList } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + let data; + try { + data = await allowList.read(); + } catch (err: unknown) { + if ((err as Error).message.includes('integrity check failed')) { + this.error( + 'CRITICAL: Allow list integrity check failed — possible tampering.\n' + + 'The file may have been modified outside of amesh.', + ); + } + throw err; + } + + this.log(''); + if (data.devices.length === 0) { + this.log(' No trusted devices yet.'); + this.log(' Run `amesh listen` to start pairing.'); + } else { + this.log(` Trusted Devices (${data.devices.length})`); + this.log(' ' + '─'.repeat(55)); + for (const device of data.devices) { + const date = device.addedAt.split('T')[0]; + const roleTag = device.role === 'controller' ? '[controller]' : '[target]'; + this.log(` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`); + } + this.log(' ' + '─'.repeat(55)); + } + + this.log(''); + this.log(` Your identity: ${identity.deviceId} (${identity.friendlyName})`); + this.log(''); + } +} diff --git a/packages/agent/src/commands/listen.ts b/packages/agent/src/commands/listen.ts new file mode 100644 index 0000000..94c68d6 --- /dev/null +++ b/packages/agent/src/commands/listen.ts @@ -0,0 +1,126 @@ +import { Command, Flags } from '@oclif/core'; +import { loadContext } from '../context.js'; +import { generateOTC, runTargetHandshake, verifySAS } from '../handshake.js'; +import { createInterface } from 'node:readline'; + +const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; + +export default class Listen extends Command { + static override description = 'Wait for a pairing request from a controller device'; + + static override flags = { + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: DEFAULT_RELAY, + env: 'AMESH_RELAY_URL', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Listen); + + const { identity, keyStore, allowList, keyAlias } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + const otc = generateOTC(); + + this.log(''); + this.log(' Connecting to relay...'); + this.log(''); + this.log(' ┌─────────────────────────────┐'); + this.log(` │ Your pairing code: ${otc} │`); + this.log(' │ Expires in: 60 seconds │'); + this.log(' └─────────────────────────────┘'); + this.log(''); + this.log(' Share this code with your Controller device.'); + this.log(''); + + const signFn = async (message: Uint8Array) => { + return keyStore.sign(keyAlias, message); + }; + + let result; + try { + result = await runTargetHandshake( + flags.relay, + otc, + identity.publicKey, + identity.friendlyName, + signFn, + ); + } catch (err) { + this.error(`Handshake failed: ${(err as Error).message}`); + } + + this.log(' Controller connected.'); + this.log(' Ephemeral P-256 ECDH tunnel established.'); + this.log(' Keys exchanged and verified.'); + this.log(''); + this.log(' ┌──────────────────────────────────┐'); + this.log(' │ Enter the 6-digit code shown │'); + this.log(" │ on the Controller's screen. │"); + this.log(' └──────────────────────────────────┘'); + this.log(''); + + const entered = await this.prompt(' Verification code: '); + if (!verifySAS(entered.trim(), result.sas)) { + this.log(''); + this.log(' Code mismatch — possible MITM. Pairing aborted.'); + return; + } + + const newDevice = { + deviceId: `am_${Buffer.from(result.peerPublicKey).toString('base64url').slice(0, 16)}`, + publicKey: Buffer.from(result.peerPublicKey).toString('base64'), + friendlyName: result.peerFriendlyName, + addedAt: new Date().toISOString(), + addedBy: 'handshake' as const, + role: 'controller' as const, + }; + + // Enforce maxControllers limit (default: 1) + const maxControllers = (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; + const currentControllers = await allowList.countByRole('controller'); + + if (currentControllers >= maxControllers) { + this.log(` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`); + const replace = await this.confirm(' Replace existing controller(s)? (Y/n): '); + if (!replace) { + this.log(''); + this.log(' Pairing cancelled. No changes made.'); + return; + } + await allowList.replaceByRole('controller', newDevice); + } else { + await allowList.addDevice(newDevice); + } + + this.log(''); + this.log(` "${result.peerFriendlyName}" added as controller.`); + this.log(''); + this.log(' You can now use amesh signing. The relay connection is closed.'); + this.log(''); + } + + private prompt(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, (answer) => { + rl.close(); + resolve(answer); + }); + }); + } + + private confirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() !== 'n'); + }); + }); + } +} diff --git a/packages/agent/src/commands/provision.ts b/packages/agent/src/commands/provision.ts new file mode 100644 index 0000000..648d8be --- /dev/null +++ b/packages/agent/src/commands/provision.ts @@ -0,0 +1,88 @@ +import { Command, Flags } from '@oclif/core'; +import { loadContext } from '../context.js'; +import { generateBootstrapToken } from '../bootstrap-token.js'; + +const DEFAULT_RELAY = 'wss://relay.authmesh.dev/ws'; + +function parseTTL(ttl: string): number { + const match = ttl.match(/^(\d+)(m|h)$/); + if (!match) throw new Error('Invalid TTL format. Use e.g. 30m, 1h, 24h'); + const [, num, unit] = match; + return parseInt(num) * (unit === 'h' ? 3600 : 60); +} + +export default class Provision extends Command { + static override description = 'Generate a bootstrap token for automated device pairing'; + + static override flags = { + name: Flags.string({ + char: 'n', + description: 'Friendly name for the device being provisioned', + required: true, + }), + ttl: Flags.string({ + char: 't', + description: 'Token validity period (e.g. 30m, 1h, 24h)', + default: '1h', + }), + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: DEFAULT_RELAY, + env: 'AMESH_RELAY_URL', + }), + output: Flags.string({ + char: 'o', + description: 'Output format', + options: ['text', 'json'], + default: 'text', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Provision); + + const { identity, keyStore, keyAlias } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + const ttlSeconds = parseTTL(flags.ttl); + + const { token, payload } = await generateBootstrapToken({ + issuerDeviceId: identity.deviceId, + keyAlias, + name: flags.name, + ttlSeconds, + relay: flags.relay, + keyStore, + }); + + if (flags.output === 'json') { + this.log( + JSON.stringify({ + token, + jti: payload.jti, + name: payload.name, + issuedAt: new Date(payload.iat * 1000).toISOString(), + expiresAt: new Date(payload.exp * 1000).toISOString(), + relay: payload.relay, + }), + ); + return; + } + + this.log(''); + this.log(' Bootstrap token generated.'); + this.log(''); + this.log(` Token (valid for ${flags.ttl}, single use):`); + this.log(''); + this.log(` ${token}`); + this.log(''); + this.log(' Usage:'); + this.log(' Set this as an environment variable on the target:'); + this.log(` AMESH_BOOTSTRAP_TOKEN=${token}`); + this.log(''); + this.log(' On first boot, the target will pair automatically.'); + this.log(''); + } +} diff --git a/packages/agent/src/commands/revoke.ts b/packages/agent/src/commands/revoke.ts new file mode 100644 index 0000000..85278a5 --- /dev/null +++ b/packages/agent/src/commands/revoke.ts @@ -0,0 +1,61 @@ +import { Command, Args } from '@oclif/core'; +import { loadContext } from '../context.js'; +import { createInterface } from 'node:readline'; + +export default class Revoke extends Command { + static override description = 'Remove a device from the allow list'; + + static override args = { + deviceId: Args.string({ + description: 'Device ID to revoke (e.g., am_1a2b3c4d5e6f7a8b)', + required: true, + }), + }; + + async run(): Promise { + const { args } = await this.parse(Revoke); + + const { allowList } = await loadContext().catch(() => { + this.error('No identity found. Run `amesh init` first.'); + }); + + const data = await allowList.read(); + const device = data.devices.find((d) => d.deviceId === args.deviceId); + if (!device) { + this.error(`Device ${args.deviceId} not found in allow list.`); + } + + this.log(''); + this.log(` Device: ${device.friendlyName}`); + this.log(` Added: ${device.addedAt.split('T')[0]}`); + this.log(''); + + const confirmed = await this.confirm( + ' Are you sure? This device will lose access immediately. (y/N): ', + ); + if (!confirmed) { + this.log(' Cancelled.'); + return; + } + + await allowList.removeDevice(args.deviceId); + + this.log(''); + this.log(` ${args.deviceId} removed from allow list.`); + this.log(' Allow list resealed.'); + this.log(''); + this.log(' Revocation is effective immediately on this machine.'); + this.log(' If this device authenticates to other machines, revoke it there too.'); + this.log(''); + } + + private confirm(prompt: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y'); + }); + }); + } +} diff --git a/packages/agent/src/commands/shell.ts b/packages/agent/src/commands/shell.ts new file mode 100644 index 0000000..ffa1f55 --- /dev/null +++ b/packages/agent/src/commands/shell.ts @@ -0,0 +1,38 @@ +import { Command, Args, Flags } from '@oclif/core'; + +export default class Shell extends Command { + static override description = 'Open a remote shell to a paired device'; + + static override args = { + device: Args.string({ + description: 'Device ID (am_...) or friendly name of the target', + required: true, + }), + }; + + static override flags = { + command: Flags.string({ + char: 'c', + description: 'Run a single command and exit', + }), + relay: Flags.string({ + char: 'r', + description: 'Relay server URL', + default: 'wss://relay.authmesh.dev/ws', + env: 'AMESH_RELAY_URL', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(Shell); + + const { connectShell } = await import('../shell-client.js'); + const exitCode = await connectShell({ + target: args.device, + relayUrl: flags.relay, + command: flags.command, + }); + + this.exit(exitCode); + } +} diff --git a/packages/agent/src/context.ts b/packages/agent/src/context.ts new file mode 100644 index 0000000..d722220 --- /dev/null +++ b/packages/agent/src/context.ts @@ -0,0 +1,31 @@ +import { createForBackend, AllowList } from '@authmesh/keystore'; +import type { KeyStore, StorageBackend } from '@authmesh/keystore'; +import { loadIdentity } from './identity.js'; +import type { Identity } from './identity.js'; +import { getIdentityPath, getAllowListPath, getKeysDir } from './paths.js'; + +export interface AmeshContext { + identity: Identity; + keyStore: KeyStore; + allowList: AllowList; + /** The internal key name in the keystore (may differ from deviceId for keychain/TPM) */ + keyAlias: string; +} + +export async function loadContext(): Promise { + const identity = await loadIdentity(getIdentityPath()); + + const keyStore = await createForBackend( + identity.storageBackend as StorageBackend, + getKeysDir(), + process.env.AUTH_MESH_PASSPHRASE, + ); + + // keyAlias: the name used in the keystore. Defaults to deviceId for backwards compat. + const keyAlias = (identity as Identity & { keyAlias?: string }).keyAlias ?? identity.deviceId; + + const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); + const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); + + return { identity, keyStore, allowList, keyAlias }; +} diff --git a/packages/agent/src/frame.ts b/packages/agent/src/frame.ts new file mode 100644 index 0000000..8da47d5 --- /dev/null +++ b/packages/agent/src/frame.ts @@ -0,0 +1,83 @@ +/** + * Shell frame protocol — binary frames over the encrypted tunnel. + * + * Each frame is: type_byte (1B) || payload (variable) + * The entire frame is then encrypted with ShellCipher before transmission. + */ + +export const FrameType = { + DATA: 0x01, // Raw terminal bytes (stdin/stdout) + RESIZE: 0x02, // Terminal resize: { cols: u16, rows: u16 } (4 bytes BE) + EXIT: 0x03, // Process exit: { code: i32 } (4 bytes BE) + PING: 0x04, // Keepalive ping (empty payload) + PONG: 0x05, // Keepalive pong (empty payload) + COMMAND: 0x06, // Single command for -c mode (UTF-8 string) +} as const; + +export type FrameTypeValue = (typeof FrameType)[keyof typeof FrameType]; + +export function encodeDataFrame(data: Uint8Array): Uint8Array { + const frame = new Uint8Array(1 + data.length); + frame[0] = FrameType.DATA; + frame.set(data, 1); + return frame; +} + +export function encodeResizeFrame(cols: number, rows: number): Uint8Array { + const frame = new Uint8Array(5); + frame[0] = FrameType.RESIZE; + const view = new DataView(frame.buffer); + view.setUint16(1, cols, false); + view.setUint16(3, rows, false); + return frame; +} + +export function encodeExitFrame(code: number): Uint8Array { + const frame = new Uint8Array(5); + frame[0] = FrameType.EXIT; + const view = new DataView(frame.buffer); + view.setInt32(1, code, false); + return frame; +} + +export function encodePingFrame(): Uint8Array { + return new Uint8Array([FrameType.PING]); +} + +export function encodePongFrame(): Uint8Array { + return new Uint8Array([FrameType.PONG]); +} + +export function encodeCommandFrame(command: string): Uint8Array { + const encoded = new TextEncoder().encode(command); + const frame = new Uint8Array(1 + encoded.length); + frame[0] = FrameType.COMMAND; + frame.set(encoded, 1); + return frame; +} + +const VALID_FRAME_TYPES = new Set([ + FrameType.DATA, FrameType.RESIZE, FrameType.EXIT, + FrameType.PING, FrameType.PONG, FrameType.COMMAND, +]); + +export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } { + if (frame.length < 1) throw new Error('Empty frame'); + if (!VALID_FRAME_TYPES.has(frame[0])) throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`); + return { + type: frame[0] as FrameTypeValue, + payload: frame.subarray(1), + }; +} + +export function parseResize(payload: Uint8Array): { cols: number; rows: number } { + if (payload.byteLength < 4) throw new Error('RESIZE frame too short'); + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + return { cols: view.getUint16(0, false), rows: view.getUint16(2, false) }; +} + +export function parseExit(payload: Uint8Array): { code: number } { + if (payload.byteLength < 4) throw new Error('EXIT frame too short'); + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + return { code: view.getInt32(0, false) }; +} diff --git a/packages/agent/src/handshake.ts b/packages/agent/src/handshake.ts new file mode 100644 index 0000000..b2933cb --- /dev/null +++ b/packages/agent/src/handshake.ts @@ -0,0 +1,307 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { randomBytes } from '@noble/ciphers/utils.js'; +import { + generateEphemeralKeyPair, + computeSharedSecret, + deriveSessionKey, + verifyMessage, +} from '@authmesh/core'; + +interface PeerIdentity { + publicKey: string; // base64 + friendlyName: string; + timestamp: string; + selfSig: string; // base64 +} + +/** + * Send a JSON message over WebSocket. + */ +function send(ws: WebSocket, msg: object): void { + ws.send(JSON.stringify(msg)); +} + +/** + * Create a buffered message reader for a WebSocket. + * Messages that arrive before read() is called are queued. + * Uses the standard WebSocket API (works in Bun and browsers). + */ +function createMessageReader(ws: WebSocket) { + const queue: Record[] = []; + let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + + ws.addEventListener('message', (event: MessageEvent) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data); + const msg = JSON.parse(raw); + if (waiter) { + const w = waiter; + waiter = null; + w.resolve(msg); + } else { + queue.push(msg); + } + }); + + return { + read(timeoutMs = 30_000): Promise> { + if (queue.length > 0) { + return Promise.resolve(queue.shift()!); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiter = null; + reject(new Error('Timeout waiting for message')); + }, timeoutMs); + waiter = { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }; + }); + }, + }; +} + +/** + * Encrypt a message with ChaCha20-Poly1305. + */ +function encrypt(sessionKey: Uint8Array, plaintext: Uint8Array): string { + const nonce = randomBytes(12); + const cipher = chacha20poly1305(sessionKey, nonce); + const ciphertext = cipher.encrypt(plaintext); + // Prepend nonce to ciphertext + const combined = new Uint8Array(12 + ciphertext.length); + combined.set(nonce, 0); + combined.set(ciphertext, 12); + return Buffer.from(combined).toString('base64'); +} + +/** + * Decrypt a message with ChaCha20-Poly1305. + */ +function decrypt(sessionKey: Uint8Array, encoded: string): Uint8Array { + const combined = Buffer.from(encoded, 'base64'); + const nonce = combined.subarray(0, 12); + const ciphertext = combined.subarray(12); + const cipher = chacha20poly1305(sessionKey, nonce); + return cipher.decrypt(ciphertext); +} + +/** + * Compute SAS (Short Authentication String) for MITM detection. + * SAS = truncate(SHA-256(targetPub || controllerPub || sharedSecret), 6 digits) + */ +export function computeSAS( + targetPubKey: Uint8Array, + controllerPubKey: Uint8Array, + sharedSecret: Uint8Array, +): string { + const combined = new Uint8Array(targetPubKey.length + controllerPubKey.length + sharedSecret.length); + combined.set(targetPubKey, 0); + combined.set(controllerPubKey, targetPubKey.length); + combined.set(sharedSecret, targetPubKey.length + controllerPubKey.length); + const hash = sha256(combined); + const num = ((hash[0] << 16) | (hash[1] << 8) | hash[2]) % 1_000_000; + return num.toString().padStart(6, '0'); +} + +/** + * Generate a 6-digit OTC. + */ +export function generateOTC(): string { + const bytes = randomBytes(4); + const num = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; + return ((num % 900_000) + 100_000).toString(); +} + +/** + * Verify selfSig from a peer. + */ +function verifySelfSig(peer: PeerIdentity): boolean { + const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64')); + const message = new TextEncoder().encode(peer.publicKey + peer.friendlyName + peer.timestamp); + const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64')); + return verifyMessage(sig, message, publicKey); +} + +/** + * Constant-time comparison for SAS codes. + * Prevents timing side-channels during code entry verification. + */ +export function verifySAS(entered: string, computed: string): boolean { + if (entered.length !== computed.length) return false; + let diff = 0; + for (let i = 0; i < entered.length; i++) { + diff |= entered.charCodeAt(i) ^ computed.charCodeAt(i); + } + return diff === 0; +} + +export interface HandshakeResult { + peerPublicKey: Uint8Array; + peerFriendlyName: string; + sas: string; +} + +/** + * Run the TARGET side of the handshake (Step 1-11 from spec). + */ +export async function runTargetHandshake( + relayUrl: string, + otc: string, + myPublicKeyBase64: string, + myFriendlyName: string, + signFn: (message: Uint8Array) => Promise, +): Promise { + const ws = new WebSocket(relayUrl); + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(e)); + }); + const reader = createMessageReader(ws); + + try { + // Step 1: Connect with OTC + send(ws, { type: 'listen', otc }); + const ack = await reader.read(); + if (ack.type === 'error') throw new Error(`Relay error: ${ack.code}`); + + // Step 2-4: Wait for controller + const peerFound = await reader.read(60_000); + if (peerFound.type !== 'peer_found') throw new Error(`Unexpected: ${peerFound.type}`); + + // Step 5: ECDH ephemeral exchange — send our ephemeral public key + const ephemeral = generateEphemeralKeyPair(); + send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); + + // Receive controller's ephemeral public key + const peerEphMsg = await reader.read(); + const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); + + // Step 6: Derive session key + const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); + const sessionKey = deriveSessionKey(sharedSecret); + + // Step 7: Receive controller's permanent identity (encrypted) + const encPeerIdentity = await reader.read(); + const peerIdentity = JSON.parse( + new TextDecoder().decode(decrypt(sessionKey, encPeerIdentity.payload as string)), + ) as PeerIdentity; + + if (!verifySelfSig(peerIdentity)) { + throw new Error('selfSig verification failed — peer identity is invalid'); + } + + // Step 8: Send our permanent identity (encrypted) + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + + const myIdentity: PeerIdentity = { + publicKey: myPublicKeyBase64, + friendlyName: myFriendlyName, + timestamp, + selfSig: Buffer.from(selfSig).toString('base64'), + }; + + const encMyIdentity = encrypt(sessionKey, new TextEncoder().encode(JSON.stringify(myIdentity))); + send(ws, { type: 'data', payload: encMyIdentity }); + + // Step 9: Compute SAS + const myPub = new Uint8Array(Buffer.from(myPublicKeyBase64, 'base64')); + const peerPub = new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')); + const sas = computeSAS(myPub, peerPub, sharedSecret); + + // Step 10: Done + send(ws, { type: 'done' }); + + return { + peerPublicKey: peerPub, + peerFriendlyName: peerIdentity.friendlyName, + sas, + }; + } finally { + ws.close(); + } +} + +/** + * Run the CONTROLLER side of the handshake. + */ +export async function runControllerHandshake( + relayUrl: string, + otc: string, + myPublicKeyBase64: string, + myFriendlyName: string, + signFn: (message: Uint8Array) => Promise, +): Promise { + const ws = new WebSocket(relayUrl); + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(e)); + }); + const reader = createMessageReader(ws); + + try { + // Step 3: Connect with OTC + send(ws, { type: 'connect', otc }); + const peerFound = await reader.read(); + if (peerFound.type === 'error') throw new Error(`Relay error: ${peerFound.code}`); + if (peerFound.type !== 'peer_found') throw new Error(`Unexpected: ${peerFound.type}`); + + // Step 5: Receive target's ephemeral public key + const peerEphMsg = await reader.read(); + const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); + + // Send our ephemeral public key + const ephemeral = generateEphemeralKeyPair(); + send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); + + // Step 6: Derive session key + const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); + const sessionKey = deriveSessionKey(sharedSecret); + + // Step 7: Send our permanent identity (encrypted) + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + + const myIdentity: PeerIdentity = { + publicKey: myPublicKeyBase64, + friendlyName: myFriendlyName, + timestamp, + selfSig: Buffer.from(selfSig).toString('base64'), + }; + + const encMyIdentity = encrypt(sessionKey, new TextEncoder().encode(JSON.stringify(myIdentity))); + send(ws, { type: 'data', payload: encMyIdentity }); + + // Step 8: Receive target's permanent identity (encrypted) + const encPeerIdentity = await reader.read(); + const peerIdentity = JSON.parse( + new TextDecoder().decode(decrypt(sessionKey, encPeerIdentity.payload as string)), + ) as PeerIdentity; + + if (!verifySelfSig(peerIdentity)) { + throw new Error('selfSig verification failed — peer identity is invalid'); + } + + // Step 9: Compute SAS + const peerPub = new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')); + const myPub = new Uint8Array(Buffer.from(myPublicKeyBase64, 'base64')); + const sas = computeSAS(peerPub, myPub, sharedSecret); + + // Step 10: Done + send(ws, { type: 'done' }); + + return { + peerPublicKey: peerPub, + peerFriendlyName: peerIdentity.friendlyName, + sas, + }; + } finally { + ws.close(); + } +} diff --git a/packages/agent/src/identity.ts b/packages/agent/src/identity.ts new file mode 100644 index 0000000..e8d9ead --- /dev/null +++ b/packages/agent/src/identity.ts @@ -0,0 +1,44 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export interface Identity { + version: '2.0.0'; + deviceId: string; + publicKey: string; // base64 + friendlyName: string; + createdAt: string; // ISO 8601 + storageBackend: string; + maxControllers?: number; // default 1 — max controllers allowed on this target +} + +/** + * Generate a device ID from a compressed P-256 public key. + * deviceId = "am_" + Base64URL(SHA-256(compressedPublicKey)).slice(0, 16) + */ +export function generateDeviceId(publicKey: Uint8Array): string { + const hash = sha256(publicKey); + const b64url = Buffer.from(hash).toString('base64url'); + return `am_${b64url.slice(0, 16)}`; +} + +export async function loadIdentity(path: string): Promise { + const content = await readFile(path, 'utf-8'); + return JSON.parse(content) as Identity; +} + +export async function saveIdentity(path: string, identity: Identity): Promise { + const tmpPath = `${path}.tmp`; + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(tmpPath, JSON.stringify(identity, null, 2), { encoding: 'utf-8', mode: 0o600 }); + await rename(tmpPath, path); +} + +export async function identityExists(path: string): Promise { + try { + await readFile(path); + return true; + } catch { + return false; + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 0000000..886f324 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core'; + +await execute({ dir: import.meta.url }); diff --git a/packages/agent/src/paths.ts b/packages/agent/src/paths.ts new file mode 100644 index 0000000..1735378 --- /dev/null +++ b/packages/agent/src/paths.ts @@ -0,0 +1,20 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const AUTH_MESH_DIR = join(homedir(), '.amesh'); + +export function getAuthMeshDir(): string { + return process.env.AUTH_MESH_DIR ?? AUTH_MESH_DIR; +} + +export function getIdentityPath(): string { + return join(getAuthMeshDir(), 'identity.json'); +} + +export function getAllowListPath(): string { + return join(getAuthMeshDir(), 'allow_list.json'); +} + +export function getKeysDir(): string { + return join(getAuthMeshDir(), 'keys'); +} diff --git a/packages/agent/src/sea.ts b/packages/agent/src/sea.ts new file mode 100644 index 0000000..62d042e --- /dev/null +++ b/packages/agent/src/sea.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +/** + * SEA (Single Executable Application) entry point. + * + * Statically imports all commands to bypass oclif's filesystem-based + * command discovery, which doesn't work inside a single-file bundle. + * Help is handled here; flag parsing delegates to oclif's Command.run(). + * + * oclif requires a valid package.json root to initialize. Since compiled + * binaries don't have a filesystem, we create a minimal one at startup. + */ + +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import Init from './commands/init.js'; +import Invite from './commands/invite.js'; +import List from './commands/list.js'; +import Listen from './commands/listen.js'; +import Provision from './commands/provision.js'; +import Revoke from './commands/revoke.js'; + +declare const __VERSION__: string; +const VERSION = __VERSION__; // replaced at build time by esbuild/bun + +interface CommandMeta { + run(argv?: string[], opts?: string): Promise; + description?: string; + flags?: Record; + args?: Record; +} + +const commands: Record = { + init: Init, + invite: Invite, + list: List, + listen: Listen, + provision: Provision, + revoke: Revoke, +}; + +/** + * Create a minimal oclif root so Config.load() works in compiled binaries. + * Without this, oclif tries to find package.json at the build-time path. + */ +function getOclifRoot(): string { + const root = join(tmpdir(), 'amesh-oclif'); + const pjsonPath = join(root, 'package.json'); + if (!existsSync(pjsonPath)) { + mkdirSync(root, { recursive: true }); + writeFileSync(pjsonPath, JSON.stringify({ + name: '@authmesh/cli', + version: VERSION, + oclif: { bin: 'amesh' }, + })); + } + return root; +} + +function showHelp(): void { + console.log(`amesh v${VERSION} — Device-bound M2M authentication\n`); + console.log('Usage: amesh [flags]\n'); + console.log('Commands:'); + for (const [name, cmd] of Object.entries(commands)) { + console.log(` ${name.padEnd(14)}${cmd.description ?? ''}`); + } + console.log('\nRun "amesh --help" for details on a specific command.'); +} + +function showCommandHelp(name: string, cmd: CommandMeta): void { + console.log(`amesh ${name} — ${cmd.description ?? ''}\n`); + console.log(`Usage: amesh ${name} [flags]\n`); + + if (cmd.args && Object.keys(cmd.args).length > 0) { + console.log('Arguments:'); + for (const [argName, argDef] of Object.entries(cmd.args)) { + const req = argDef.required ? ' (required)' : ''; + console.log(` ${argName.padEnd(18)}${argDef.description ?? ''}${req}`); + } + console.log(''); + } + + if (cmd.flags && Object.keys(cmd.flags).length > 0) { + console.log('Flags:'); + for (const [flagName, flagDef] of Object.entries(cmd.flags)) { + const short = flagDef.char ? `-${flagDef.char}, ` : ' '; + const req = flagDef.required ? ' (required)' : ''; + const def = flagDef.default !== undefined ? ` [default: ${flagDef.default}]` : ''; + const opts = flagDef.options ? ` [${flagDef.options.join('|')}]` : ''; + console.log(` ${short}--${flagName.padEnd(16)}${flagDef.description ?? ''}${opts}${req}${def}`); + } + console.log(''); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const cmdName = args[0]; + + if (!cmdName || cmdName === '--help' || cmdName === '-h' || cmdName === 'help') { + showHelp(); + process.exit(0); + } + + if (cmdName === '--version' || cmdName === '-V') { + console.log(`amesh/${VERSION}`); + process.exit(0); + } + + const Cmd = commands[cmdName]; + if (!Cmd) { + console.error(`Unknown command: ${cmdName}`); + console.error('Run "amesh --help" to see available commands.'); + process.exit(1); + } + + const rest = args.slice(1); + if (rest.includes('--help') || rest.includes('-h')) { + showCommandHelp(cmdName, Cmd); + process.exit(0); + } + + const oclifRoot = getOclifRoot(); + await Cmd.run(rest, oclifRoot); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); +}); diff --git a/packages/agent/src/shell-cipher.ts b/packages/agent/src/shell-cipher.ts new file mode 100644 index 0000000..6f9d286 --- /dev/null +++ b/packages/agent/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/agent/src/shell-client.ts b/packages/agent/src/shell-client.ts new file mode 100644 index 0000000..4400e55 --- /dev/null +++ b/packages/agent/src/shell-client.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/agent/src/shell-handshake.ts b/packages/agent/src/shell-handshake.ts new file mode 100644 index 0000000..cb731cd --- /dev/null +++ b/packages/agent/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/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 0000000..1d4a86a --- /dev/null +++ b/packages/agent/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" }] +}