diff --git a/bun.lock b/bun.lock index 8245113..e39ef1e 100644 --- a/bun.lock +++ b/bun.lock @@ -45,9 +45,34 @@ "vite": "^7.3.1", }, }, + "packages/agent": { + "name": "@authmesh/agent", + "version": "0.2.0", + "bin": { + "amesh-agent": "./dist/commands/agent-start.js", + "amesh": "./dist/cli-proxy.js", + }, + "dependencies": { + "@authmesh/cli": "workspace:*", + "@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.1.6", + "version": "0.2.0", "bin": { "amesh": "./dist/index.js", }, @@ -68,7 +93,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 +108,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 +125,7 @@ }, "packages/relay": { "name": "@authmesh/relay", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@authmesh/core": "workspace:*", }, @@ -116,7 +141,7 @@ }, "packages/sdk": { "name": "@authmesh/sdk", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", @@ -135,31 +160,10 @@ "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/agent": ["@authmesh/agent@workspace:packages/agent"], + "@authmesh/cli": ["@authmesh/cli@workspace:packages/cli"], "@authmesh/core": ["@authmesh/core@workspace:packages/core"], @@ -170,8 +174,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..04440f4 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -167,7 +167,7 @@ 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 -**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 shell client (`amesh shell`) is a subcommand in `@authmesh/cli`. The agent daemon (`amesh-agent`) ships as `@authmesh/agent`, a separate package for the target server. Shell access requires explicit `amesh grant --shell` after pairing. **Why:** @@ -175,7 +175,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en 2. **Explicit consent:** Pairing for API authentication (`amesh invite`) does not grant shell access. A `permissions.shell` flag in the allow list defaults to `false`. The target admin must explicitly run `amesh grant --shell`. This prevents implicit privilege escalation. -3. **Separate binaries:** `amesh-agent` and `amesh-shell` are distinct from `amesh` (the CLI). Users opt into shell capability by installing a separate package. +3. **Controller in CLI, agent separate:** `amesh shell` is a subcommand of the existing CLI. Only the server needs `@authmesh/agent` — the developer's laptop never has daemon code. **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..e95dde8 100644 --- a/landpage/src/routes/docs/remote-shell/+page.svelte +++ b/landpage/src/routes/docs/remote-shell/+page.svelte @@ -45,15 +45,15 @@

Install

-

The shell feature is a separate package from the CLI.

+

The CLI includes the shell client. The server needs the agent package.

- # Install the shell package (agent + shell client) -brew install ameshdev/tap/amesh-shell -# or -npm install -g @authmesh/shell + # On your laptop (controller) — CLI includes amesh shell +brew install ameshdev/tap/amesh -# You also need the CLI for pairing and permissions -brew install ameshdev/tap/amesh`} /> +# On the server (target) — agent daemon +brew install ameshdev/tap/amesh-agent +# or +npm install -g @authmesh/agent`} />
@@ -98,7 +98,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 +110,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% /`} />
diff --git a/landpage/src/routes/use-cases/remote-shell/+page.svelte b/landpage/src/routes/use-cases/remote-shell/+page.svelte index 276231b..62a7eff 100644 --- a/landpage/src/routes/use-cases/remote-shell/+page.svelte +++ b/landpage/src/routes/use-cases/remote-shell/+page.svelte @@ -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/agent/README.md b/packages/agent/README.md new file mode 100644 index 0000000..c502322 --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,57 @@ +# @authmesh/agent + +Agent daemon for [amesh](https://github.com/ameshdev/amesh) remote shell --- secure remote access using device-bound identity. Install on the server (target) to accept shell connections from paired controllers. + +**Includes the full amesh CLI** — one install on the server gives you everything (init, pair, grant, agent). + +## Install + +```bash +brew install ameshdev/tap/amesh-agent +# or +npm install -g @authmesh/agent +``` + +## Setup + +```bash +# Create identity + pair with controller (one-time) +amesh init --name "prod-api" +amesh listen +# Controller runs: amesh invite + +# Grant shell access to the controller +amesh grant am_3d9f1a2e --shell + +# Start the agent daemon +amesh-agent start +``` + +## Usage + +```bash +amesh-agent start # foreground +amesh-agent start --idle-timeout 60 # custom timeout (minutes) +amesh-agent start --relay wss://... # custom relay +amesh-agent start --allow-root # run as root (danger) +``` + +## Security + +- Shell access requires explicit `amesh grant --shell` (not automatic from pairing) +- End-to-end encrypted (ChaCha20-Poly1305, ephemeral ECDH per session) +- Refuses to run as root without `--allow-root` +- Per-controller session limits +- HMAC-sealed allow list with tamper detection + +## 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 | + +## License + +[MIT](https://github.com/ameshdev/amesh/blob/main/LICENSE) diff --git a/packages/shell/package.json b/packages/agent/package.json similarity index 82% rename from packages/shell/package.json rename to packages/agent/package.json index 41ec6bd..33c689e 100644 --- a/packages/shell/package.json +++ b/packages/agent/package.json @@ -1,14 +1,14 @@ { - "name": "@authmesh/shell", + "name": "@authmesh/agent", "version": "0.2.0", - "description": "Secure remote shell for amesh — SSH-like access with device-bound identity", + "description": "amesh agent daemon — 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/shell" + "directory": "packages/agent" }, "homepage": "https://github.com/ameshdev/amesh", "keywords": [ @@ -24,7 +24,7 @@ }, "bin": { "amesh-agent": "./dist/commands/agent-start.js", - "amesh-shell": "./dist/commands/shell.js" + "amesh": "./dist/cli-proxy.js" }, "exports": { ".": { @@ -41,8 +41,10 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { + "@authmesh/cli": "workspace:*", "@authmesh/core": "workspace:*", "@authmesh/keystore": "workspace:*", + "@oclif/core": "^4.0.0", "@noble/curves": "2.0.1", "@noble/ciphers": "2.1.1", "@noble/hashes": "2.0.1" diff --git a/packages/shell/src/__tests__/frame.test.ts b/packages/agent/src/__tests__/frame.test.ts similarity index 100% rename from packages/shell/src/__tests__/frame.test.ts rename to packages/agent/src/__tests__/frame.test.ts diff --git a/packages/shell/src/__tests__/shell-cipher.test.ts b/packages/agent/src/__tests__/shell-cipher.test.ts similarity index 100% rename from packages/shell/src/__tests__/shell-cipher.test.ts rename to packages/agent/src/__tests__/shell-cipher.test.ts diff --git a/packages/shell/src/agent.ts b/packages/agent/src/agent.ts similarity index 95% rename from packages/shell/src/agent.ts rename to packages/agent/src/agent.ts index d49392e..e32a74d 100644 --- a/packages/shell/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -67,7 +67,16 @@ export async function startAgent(opts: AgentOptions): Promise { const maxSessionsPerController = 1; const controllerSessions = new Map(); + const shellControllers = (await allowList.read()).devices.filter( + (d) => d.role === 'controller' && d.permissions?.shell, + ).length; + + console.log(''); + console.warn('[amesh-agent] WARNING: This device is now accepting remote shell connections from authorized controllers.'); console.log(`[amesh-agent] Device: ${identity.deviceId} (${identity.friendlyName})`); + console.log(`[amesh-agent] Authorized controllers with shell access: ${shellControllers}`); + console.log(`[amesh-agent] Run \`amesh list\` to see who has access.`); + console.log(''); console.log(`[amesh-agent] Connecting to relay: ${opts.relayUrl}`); // Connect to relay with reconnect diff --git a/packages/agent/src/cli-proxy.ts b/packages/agent/src/cli-proxy.ts new file mode 100644 index 0000000..1eddea5 --- /dev/null +++ b/packages/agent/src/cli-proxy.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env bun +/** + * Re-exports the amesh CLI so that `npm install -g @authmesh/agent` provides the `amesh` command. + * The agent package is a superset of the CLI. + */ +import { execute } from '@oclif/core'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Resolve to @authmesh/cli's dist directory for oclif command discovery +const cliPkg = import.meta.resolve('@authmesh/cli/package.json'); +const cliDir = dirname(fileURLToPath(cliPkg)); + +await execute({ dir: join(cliDir, 'dist') }); diff --git a/packages/shell/src/commands/agent-start.ts b/packages/agent/src/commands/agent-start.ts similarity index 100% rename from packages/shell/src/commands/agent-start.ts rename to packages/agent/src/commands/agent-start.ts diff --git a/packages/shell/src/frame.ts b/packages/agent/src/frame.ts similarity index 100% rename from packages/shell/src/frame.ts rename to packages/agent/src/frame.ts diff --git a/packages/shell/src/index.ts b/packages/agent/src/index.ts similarity index 100% rename from packages/shell/src/index.ts rename to packages/agent/src/index.ts diff --git a/packages/shell/src/shell-cipher.ts b/packages/agent/src/shell-cipher.ts similarity index 100% rename from packages/shell/src/shell-cipher.ts rename to packages/agent/src/shell-cipher.ts diff --git a/packages/shell/src/shell-handshake.ts b/packages/agent/src/shell-handshake.ts similarity index 100% rename from packages/shell/src/shell-handshake.ts rename to packages/agent/src/shell-handshake.ts diff --git a/packages/shell/tsconfig.json b/packages/agent/tsconfig.json similarity index 100% rename from packages/shell/tsconfig.json rename to packages/agent/tsconfig.json diff --git a/packages/cli/package.json b/packages/cli/package.json index 94ef79d..79c7be5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,8 @@ "bin": "amesh" }, "files": [ - "dist" + "dist", + "package.json" ], "scripts": { "build": "tsc -b", 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/cli/src/frame.ts b/packages/cli/src/frame.ts new file mode 100644 index 0000000..d73a064 --- /dev/null +++ b/packages/cli/src/frame.ts @@ -0,0 +1,77 @@ +/** + * Shell frame protocol — binary frames over the encrypted tunnel. + * + * Each frame is: type_byte (1B) || payload (variable) + * The entire frame is then encrypted with ShellCipher before transmission. + */ + +export const FrameType = { + DATA: 0x01, // Raw terminal bytes (stdin/stdout) + RESIZE: 0x02, // Terminal resize: { cols: u16, rows: u16 } (4 bytes BE) + EXIT: 0x03, // Process exit: { code: i32 } (4 bytes BE) + PING: 0x04, // Keepalive ping (empty payload) + PONG: 0x05, // Keepalive pong (empty payload) + COMMAND: 0x06, // Single command for -c mode (UTF-8 string) +} as const; + +export type FrameTypeValue = (typeof FrameType)[keyof typeof FrameType]; + +export function encodeDataFrame(data: Uint8Array): Uint8Array { + const frame = new Uint8Array(1 + data.length); + frame[0] = FrameType.DATA; + frame.set(data, 1); + return frame; +} + +export function encodeResizeFrame(cols: number, rows: number): Uint8Array { + const frame = new Uint8Array(5); + frame[0] = FrameType.RESIZE; + const view = new DataView(frame.buffer); + view.setUint16(1, cols, false); + view.setUint16(3, rows, false); + return frame; +} + +export function encodeExitFrame(code: number): Uint8Array { + const frame = new Uint8Array(5); + frame[0] = FrameType.EXIT; + const view = new DataView(frame.buffer); + view.setInt32(1, code, false); + return frame; +} + +export function encodePingFrame(): Uint8Array { + return new Uint8Array([FrameType.PING]); +} + +export function encodePongFrame(): Uint8Array { + return new Uint8Array([FrameType.PONG]); +} + +export function encodeCommandFrame(command: string): Uint8Array { + const encoded = new TextEncoder().encode(command); + const frame = new Uint8Array(1 + encoded.length); + frame[0] = FrameType.COMMAND; + frame.set(encoded, 1); + return frame; +} + +export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } { + if (frame.length < 1) throw new Error('Empty frame'); + return { + type: frame[0] as FrameTypeValue, + payload: frame.subarray(1), + }; +} + +export function parseResize(payload: Uint8Array): { cols: number; rows: number } { + if (payload.byteLength < 4) throw new Error('RESIZE frame too short'); + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + return { cols: view.getUint16(0, false), rows: view.getUint16(2, false) }; +} + +export function parseExit(payload: Uint8Array): { code: number } { + if (payload.byteLength < 4) throw new Error('EXIT frame too short'); + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + return { code: view.getInt32(0, false) }; +} diff --git a/packages/cli/src/shell-cipher.ts b/packages/cli/src/shell-cipher.ts new file mode 100644 index 0000000..6f9d286 --- /dev/null +++ b/packages/cli/src/shell-cipher.ts @@ -0,0 +1,119 @@ +import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; + +const NONCE_LEN = 12; + +/** + * Encrypted shell session cipher using ChaCha20-Poly1305 with incrementing nonces. + * + * Each side maintains its own send counter: + * - Controller starts at 0x00...00 + * - Target starts at 0x80...00 (high bit set) + * + * This ensures the two sides never produce the same nonce, and provides + * ordering guarantees. Nonce reuse with ChaCha20-Poly1305 is catastrophic + * (XOR of ciphertexts leaks plaintext), so this design eliminates it. + * + * MUST NOT be confused with the random-nonce encrypt()/decrypt() in handshake.ts. + * That code is for one-shot pairing messages. This is for long-lived shell sessions. + */ +export class ShellCipher { + private readonly sessionKey: Uint8Array; + private readonly sendNonce: Uint8Array; + private readonly recvNonceStart: Uint8Array; + private sendCounter: bigint; + private recvCounter: bigint; + private closed = false; + + /** + * @param sessionKey - 32-byte key from deriveShellSessionKey() + * @param role - 'controller' starts send nonce at 0x00, 'target' starts at 0x80 + */ + constructor(sessionKey: Uint8Array, role: 'controller' | 'target') { + if (sessionKey.length !== 32) throw new Error('Session key must be 32 bytes'); + this.sessionKey = new Uint8Array(sessionKey); + this.sendNonce = new Uint8Array(NONCE_LEN); + this.recvNonceStart = new Uint8Array(NONCE_LEN); + + if (role === 'controller') { + // Controller sends with nonces starting at 0x00..., receives 0x80... + this.recvNonceStart[0] = 0x80; + } else { + // Target sends with nonces starting at 0x80..., receives 0x00... + this.sendNonce[0] = 0x80; + } + + this.sendCounter = 0n; + this.recvCounter = 0n; + } + + encrypt(plaintext: Uint8Array): Uint8Array { + if (this.closed) throw new Error('Cipher is closed'); + const nonce = this.nextSendNonce(); + const cipher = chacha20poly1305(this.sessionKey, nonce); + const ciphertext = cipher.encrypt(plaintext); + // Prepend nonce so receiver can verify ordering + const out = new Uint8Array(NONCE_LEN + ciphertext.length); + out.set(nonce, 0); + out.set(ciphertext, NONCE_LEN); + return out; + } + + decrypt(data: Uint8Array): Uint8Array { + if (this.closed) throw new Error('Cipher is closed'); + if (data.length < NONCE_LEN + 16) throw new Error('Ciphertext too short'); // 16 = Poly1305 tag + const nonce = data.subarray(0, NONCE_LEN); + const ciphertext = data.subarray(NONCE_LEN); + + // Verify nonce matches expected receive counter + const expected = this.nextRecvNonce(); + if (!constantTimeEqual(nonce, expected)) { + throw new Error('Nonce mismatch — possible replay or out-of-order frame'); + } + + const cipher = chacha20poly1305(this.sessionKey, nonce); + return cipher.decrypt(ciphertext); + } + + close(): void { + this.closed = true; + this.sessionKey.fill(0); + this.sendNonce.fill(0); + this.recvNonceStart.fill(0); + } + + private static readonly MAX_COUNTER = (2n ** 64n) - 1n; + + private nextSendNonce(): Uint8Array { + if (this.sendCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); + const nonce = new Uint8Array(this.sendNonce); + this.incrementCounter(nonce, this.sendCounter); + this.sendCounter++; + return nonce; + } + + private nextRecvNonce(): Uint8Array { + if (this.recvCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); + const nonce = new Uint8Array(this.recvNonceStart); + this.incrementCounter(nonce, this.recvCounter); + this.recvCounter++; + return nonce; + } + + /** + * Write counter into nonce bytes 4-11 (big-endian), preserving the role prefix in bytes 0-3. + */ + private incrementCounter(nonce: Uint8Array, counter: bigint): void { + const view = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength); + // Write 64-bit counter into bytes 4-11 + view.setBigUint64(4, counter, false); // big-endian + } +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} diff --git a/packages/shell/src/shell.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/cli/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts new file mode 100644 index 0000000..cb731cd --- /dev/null +++ b/packages/cli/src/shell-handshake.ts @@ -0,0 +1,240 @@ +import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { randomBytes } from '@noble/ciphers/utils.js'; +import { + generateEphemeralKeyPair, + computeSharedSecret, + deriveShellSessionKey, + verifyMessage, +} from '@authmesh/core'; +import type { AllowList } from '@authmesh/keystore'; + +interface PeerIdentity { + publicKey: string; // base64 + deviceId: string; + friendlyName: string; + timestamp: string; + selfSig: string; // base64 +} + +export interface ShellHandshakeResult { + sessionKey: Uint8Array; + peerDeviceId: string; + peerFriendlyName: string; + peerPublicKey: Uint8Array; +} + +function send(ws: WebSocket, msg: object): void { + ws.send(JSON.stringify(msg)); +} + +function createMessageReader(ws: WebSocket) { + const queue: Record[] = []; + let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + + ws.addEventListener('message', (event: MessageEvent) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data); + const msg = JSON.parse(raw); + if (waiter) { + const w = waiter; + waiter = null; + w.resolve(msg); + } else { + queue.push(msg); + } + }); + + return { + read(timeoutMs = 30_000): Promise> { + if (queue.length > 0) return Promise.resolve(queue.shift()!); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiter = null; + reject(new Error('Timeout waiting for message')); + }, timeoutMs); + waiter = { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }; + }); + }, + }; +} + +function encrypt(sessionKey: Uint8Array, plaintext: Uint8Array): string { + const nonce = randomBytes(12); + const cipher = chacha20poly1305(sessionKey, nonce); + const ciphertext = cipher.encrypt(plaintext); + const combined = new Uint8Array(12 + ciphertext.length); + combined.set(nonce, 0); + combined.set(ciphertext, 12); + return Buffer.from(combined).toString('base64'); +} + +function decrypt(sessionKey: Uint8Array, encoded: string): Uint8Array { + const combined = Buffer.from(encoded, 'base64'); + const nonce = combined.subarray(0, 12); + const ciphertext = combined.subarray(12); + const cipher = chacha20poly1305(sessionKey, nonce); + return cipher.decrypt(ciphertext); +} + +function verifySelfSig(peer: PeerIdentity): boolean { + const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64')); + const message = new TextEncoder().encode(peer.publicKey + peer.friendlyName + peer.timestamp); + const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64')); + return verifyMessage(sig, message, publicKey); +} + +const MAX_TIMESTAMP_SKEW_MS = 60_000; // 60 seconds + +function validateTimestamp(timestamp: string): void { + const ts = new Date(timestamp).getTime(); + if (isNaN(ts)) throw new Error('Invalid timestamp in peer identity'); + if (Math.abs(Date.now() - ts) > MAX_TIMESTAMP_SKEW_MS) { + throw new Error('Peer identity timestamp out of range'); + } +} + +/** + * Run the TARGET (agent) side of the shell handshake. + * No OTC, no SAS — trust is pre-established via allow list. + * Returns the session key for encrypted shell I/O. + */ +export async function runAgentShellHandshake( + ws: WebSocket, + reader: ReturnType, + myDeviceId: string, + myPublicKeyBase64: string, + myFriendlyName: string, + signFn: (message: Uint8Array) => Promise, + allowList: AllowList, +): Promise { + // Step 1: ECDH ephemeral exchange + const ephemeral = generateEphemeralKeyPair(); + send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); + + const peerEphMsg = await reader.read(); + const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); + + // Step 2: Derive session key (BOUND to device IDs — separate domain from pairing) + const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); + + // Step 3: Receive controller identity (encrypted with temp key for initial exchange) + const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp'); + const encPeerIdentity = await reader.read(); + const peerIdentity = JSON.parse( + new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)), + ) as PeerIdentity; + + if (!verifySelfSig(peerIdentity)) { + throw new Error('selfSig verification failed'); + } + validateTimestamp(peerIdentity.timestamp); // H1 fix + + // Step 4: Authorization — check allow list + const device = await allowList.findByPublicKey(peerIdentity.publicKey); + if (!device) throw new Error('Device not in allow list'); + if (device.role !== 'controller') throw new Error('Device is not a controller'); + if (!device.permissions?.shell) throw new Error('Shell access not granted for this device'); + + // Step 5: Send our identity + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + const myIdentity: PeerIdentity = { + publicKey: myPublicKeyBase64, + deviceId: myDeviceId, + friendlyName: myFriendlyName, + timestamp, + selfSig: Buffer.from(selfSig).toString('base64'), + }; + send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + + // Step 6: Derive final session key bound to actual device IDs + const sessionKey = deriveShellSessionKey(sharedSecret, myDeviceId, peerIdentity.deviceId); + + // H2 fix — zero key material + ephemeral.privateKey.fill(0); + sharedSecret.fill(0); + tempKey.fill(0); + + return { + sessionKey, + peerDeviceId: peerIdentity.deviceId, + peerFriendlyName: peerIdentity.friendlyName, + peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')), + }; +} + +/** + * Run the CONTROLLER side of the shell handshake. + */ +export async function runControllerShellHandshake( + ws: WebSocket, + reader: ReturnType, + myDeviceId: string, + myPublicKeyBase64: string, + myFriendlyName: string, + signFn: (message: Uint8Array) => Promise, + allowList: AllowList, +): Promise { + // Step 1: Receive agent ephemeral key + const peerEphMsg = await reader.read(); + const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64')); + + // Send our ephemeral key + const ephemeral = generateEphemeralKeyPair(); + send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') }); + + // Step 2: Derive shared secret + const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub); + const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp'); + + // Step 3: Send our identity + const timestamp = new Date().toISOString(); + const selfSig = await signFn( + new TextEncoder().encode(myPublicKeyBase64 + myFriendlyName + timestamp), + ); + const myIdentity: PeerIdentity = { + publicKey: myPublicKeyBase64, + deviceId: myDeviceId, + friendlyName: myFriendlyName, + timestamp, + selfSig: Buffer.from(selfSig).toString('base64'), + }; + send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + + // Step 4: Receive agent identity + const encPeerIdentity = await reader.read(); + const peerIdentity = JSON.parse( + new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)), + ) as PeerIdentity; + + if (!verifySelfSig(peerIdentity)) { + throw new Error('selfSig verification failed'); + } + validateTimestamp(peerIdentity.timestamp); // H1 fix + + // Step 5: Verify agent is in our allow list + const device = await allowList.findByPublicKey(peerIdentity.publicKey); + if (!device) throw new Error('Device not in allow list'); + if (device.role !== 'target') throw new Error('Device is not a target'); + + // Step 6: Derive final session key bound to actual device IDs + const sessionKey = deriveShellSessionKey(sharedSecret, peerIdentity.deviceId, myDeviceId); + + // H2 fix — zero key material + ephemeral.privateKey.fill(0); + sharedSecret.fill(0); + tempKey.fill(0); + + return { + sessionKey, + peerDeviceId: peerIdentity.deviceId, + peerFriendlyName: peerIdentity.friendlyName, + peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')), + }; +} + +export { createMessageReader, send }; diff --git a/packages/shell/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/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]; -}