From 37ffe169054740dc69566c35b2c6b1182a1f0998 Mon Sep 17 00:00:00 2001
From: YairEtzion
SecItemDelete only removes one item per call; multiple amesh init --force runs were leaving orphaned keys in the Keychain under the same tag, which caused selfSig verification failed on remote peers during pairing and shell handshakes. We now loop the delete until all matching items are cleared.SecItemDelete only removes one item per call; multiple amesh init --force runs were leaving orphaned keys in the Keychain under the same tag, which caused selfSig verification failed on remote peers during pairing. We now loop the delete until all matching items are cleared.delete identity.passphrase after the keystore is built. The passphrase lives on disk, not in memory any longer than necessary.invite and listen were deriving device IDs with raw base64url(pubkey) while init used SHA-256(pubkey) per the protocol spec. The relay could never match the controller's allow list entry to the agent's registration, silently breaking shell routing. All commands now use generateDeviceId(). Existing pairings need re-pairing — this is the one migration note of the series.invite and listen were deriving device IDs with raw base64url(pubkey) while init used SHA-256(pubkey) per the protocol spec. All commands now use generateDeviceId(). Existing pairings need re-pairing — this is the one migration note of the series.SSH-like remote access using amesh device identity. No SSH keys, no authorized_keys, instant revocation.
-One binary: amesh for both controller (your laptop) and server. Run amesh agent start on the server to enable remote access.
{installMethods[activeInstallMethod].command}
-
- Two steps: pair with shell access, then start the agent.
- -The --shell flag auto-grants shell access when pairing completes. Without it, you can grant later with amesh grant <device-id> --shell.
The amesh daemon ships as a prebuilt binary on all supported platforms — no runtime install needed.
| Platform | -Install via | -Notes | -
|---|---|---|
| macOS (arm64) | -Homebrew · npm · tarball | -Apple Silicon; uses Secure Enclave when signed | -
| macOS (x64) | -Homebrew · npm · tarball | -Intel macs; falls back to Keychain | -
| Linux (x64) | -Homebrew · npm · tarball · .deb | -Most cloud VMs; uses TPM 2.0 when available | -
| Linux (arm64) | -Homebrew · npm · tarball | -Raspberry Pi 4/5 on 64-bit Pi OS, Ampere, Graviton | -
| Linux (armv7, 32-bit) | -Bun wrapper only | -Raspberry Pi 3 and earlier — see note below | -
- Linux armv7 (Raspberry Pi 3 and earlier): Bun does not ship for 32-bit ARM. If you must run the agent on these devices, install Bun manually (if a third-party build is available for your arch) and run as bun $(which amesh) agent start. Everything else (Pi 4/5 on 64-bit Pi OS, all modern ARM servers) is supported out of the box.
-
amesh grant --shell.--allow-root is passed. The spawned shell inherits the agent's user permissions.{env.name}
- amesh grant <device-id> --shell on the target.amesh agent start and verify the relay is reachable from both sides.bun $(which amesh) agent start. On supported architectures (macOS arm64/x64, Linux x64/arm64) this error should not appear — if it does, see the Troubleshooting page for the full diagnostic flow.--allow-root if you understand the risk (grants root shells to all controllers)."Shell access not granted for this device"The controller is paired but doesn't have shell permission. The easiest fix: re-pair with amesh listen --shell which auto-grants shell access. Or grant it separately: amesh grant <device-id> --shell.
"The agent daemon requires Bun runtime for PTY support" (unsupported architectures only)
- You should never see this on macOS (arm64/x64) or Linux (x64/arm64) — the npm postinstall downloads a prebuilt binary that bundles Bun, and amesh agent start runs directly. If you do see it on a supported platform, the postinstall probably couldn't reach GitHub releases — check the install log for download errors and re-run npm rebuild @authmesh/cli with network access.
-
- On unsupported architectures (Raspberry Pi 3 and earlier, armv7 32-bit Pi OS), the postinstall falls back to the JS entry and the agent needs Bun for PTY. Bun does not ship for armv7, so you'd need a third-party Bun build. Most users should move to Pi 4/5 on 64-bit Pi OS or a different ARM host. -
-"Handshake failed" / connection timeoutThe agent is not running on the target. Start it with amesh agent start, and verify the relay is reachable from both sides (port 443 or whatever your self-hosted relay uses).
"Refusing to run as root"The agent defaults to non-root for safety. If you genuinely need root shells (and understand the blast radius), start the agent with --allow-root.
# Join pairing (controller side)
amesh list # Show trusted devices
amesh revoke # Remove a trusted device
amesh provision # Generate bootstrap tokens
-amesh grant --shell # Grant shell access to a controller
-amesh shell # Open remote shell to a target
-amesh agent start # Start the agent daemon (target side)
-amesh agent stop # Stop the agent daemon
-amesh reset # Clear stale sessions
```
## Pairing flow
On the target machine:
```bash
-$ amesh listen --shell
+$ amesh listen
Pairing code: 482916
Controller connected.
Enter the 6-digit code shown on the Controller.
Verification code: 847291
"Dev Laptop" added as controller.
- Shell access: granted
```
On the controller:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 6691e47..4450ec1 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@authmesh/cli",
"version": "0.6.0",
- "description": "CLI for amesh — device identity, remote shell, agent daemon",
+ "description": "CLI for amesh — device identity management and pairing",
"type": "module",
"license": "MIT",
"author": "Yair Etzion",
diff --git a/packages/cli/src/__tests__/frame.test.ts b/packages/cli/src/__tests__/frame.test.ts
deleted file mode 100644
index 192d283..0000000
--- a/packages/cli/src/__tests__/frame.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { describe, it, expect } from 'bun:test';
-import {
- FrameType,
- encodeDataFrame,
- encodeResizeFrame,
- encodeExitFrame,
- encodePingFrame,
- encodePongFrame,
- encodeCommandFrame,
- parseFrame,
- parseResize,
- parseExit,
-} from '../frame.js';
-
-describe('frame protocol', () => {
- it('encodes and parses data frame', () => {
- const data = new TextEncoder().encode('hello');
- const frame = encodeDataFrame(data);
- const parsed = parseFrame(frame);
- expect(parsed.type).toBe(FrameType.DATA);
- expect(new TextDecoder().decode(parsed.payload)).toBe('hello');
- });
-
- it('encodes and parses resize frame', () => {
- const frame = encodeResizeFrame(120, 40);
- const parsed = parseFrame(frame);
- expect(parsed.type).toBe(FrameType.RESIZE);
- const { cols, rows } = parseResize(parsed.payload);
- expect(cols).toBe(120);
- expect(rows).toBe(40);
- });
-
- it('encodes and parses exit frame', () => {
- const frame = encodeExitFrame(42);
- const parsed = parseFrame(frame);
- expect(parsed.type).toBe(FrameType.EXIT);
- const { code } = parseExit(parsed.payload);
- expect(code).toBe(42);
- });
-
- it('handles negative exit codes', () => {
- const frame = encodeExitFrame(-1);
- const { code } = parseExit(parseFrame(frame).payload);
- expect(code).toBe(-1);
- });
-
- it('encodes and parses ping/pong frames', () => {
- expect(parseFrame(encodePingFrame()).type).toBe(FrameType.PING);
- expect(parseFrame(encodePongFrame()).type).toBe(FrameType.PONG);
- });
-
- it('encodes and parses command frame', () => {
- const frame = encodeCommandFrame('uptime');
- const parsed = parseFrame(frame);
- expect(parsed.type).toBe(FrameType.COMMAND);
- expect(new TextDecoder().decode(parsed.payload)).toBe('uptime');
- });
-
- it('rejects empty frame', () => {
- expect(() => parseFrame(new Uint8Array(0))).toThrow('Empty frame');
- });
-});
diff --git a/packages/cli/src/__tests__/message-reader-dispose.test.ts b/packages/cli/src/__tests__/message-reader-dispose.test.ts
deleted file mode 100644
index a6cd5c3..0000000
--- a/packages/cli/src/__tests__/message-reader-dispose.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, it, expect } from 'bun:test';
-// We import createMessageReader via the re-export in shell-handshake.ts — it
-// isn't exported by name, so we use the module's internal binding.
-import { createMessageReader } from '../shell-handshake.js';
-
-/**
- * Regression test for M4 — the handshake message reader installed a
- * `message` listener on the WebSocket that was never removed after the
- * handshake completed. On a long-lived shell session the reader's internal
- * queue kept growing on every encrypted frame (unbounded memory growth).
- *
- * The fix exposes a `dispose()` method that removes the listener and drains
- * any pending waiter.
- */
-describe('createMessageReader dispose (M4)', () => {
- function makeFakeWs() {
- type Handler = (ev: MessageEvent) => void;
- const listeners = new Map>();
- const ws = {
- addEventListener(type: string, handler: Handler) {
- if (!listeners.has(type)) listeners.set(type, new Set());
- listeners.get(type)!.add(handler);
- },
- removeEventListener(type: string, handler: Handler) {
- listeners.get(type)?.delete(handler);
- },
- // Helpers for tests
- dispatchMessage(data: string) {
- const event = { data } as MessageEvent;
- for (const handler of listeners.get('message') ?? []) {
- handler(event);
- }
- },
- listenerCount(type: string): number {
- return listeners.get(type)?.size ?? 0;
- },
- };
- return ws as unknown as WebSocket & {
- dispatchMessage: (data: string) => void;
- listenerCount: (type: string) => number;
- };
- }
-
- it('dispose() removes the message listener', () => {
- const ws = makeFakeWs();
- const reader = createMessageReader(ws);
- expect(ws.listenerCount('message')).toBe(1);
- reader.dispose();
- expect(ws.listenerCount('message')).toBe(0);
- });
-
- it('dispose() is idempotent', () => {
- const ws = makeFakeWs();
- const reader = createMessageReader(ws);
- reader.dispose();
- reader.dispose();
- expect(ws.listenerCount('message')).toBe(0);
- });
-
- it('messages received after dispose() do not grow the internal queue', () => {
- const ws = makeFakeWs();
- const reader = createMessageReader(ws);
- reader.dispose();
- // Dispatch 1000 messages; nothing should accumulate since the listener
- // has been removed.
- for (let i = 0; i < 1000; i++) {
- ws.dispatchMessage(JSON.stringify({ type: 'data', seq: i }));
- }
- // read() after dispose should reject with reader_disposed if there's a
- // pending waiter; with no waiter it just sits on the empty queue.
- // We verify by calling read with a short timeout — no message available
- // means it should time out (not resolve with a leaked queued message).
- // Short-circuit: the queue was drained on dispose.
- // We can't easily assert on private queue.length, but if the listener
- // is gone, dispatched messages cannot reach it.
- expect(ws.listenerCount('message')).toBe(0);
- });
-
- it('pending read() rejects with reader_disposed on dispose', async () => {
- const ws = makeFakeWs();
- const reader = createMessageReader(ws);
- const readPromise = reader.read(5000);
- reader.dispose();
- await expect(readPromise).rejects.toThrow('reader_disposed');
- });
-
- it('read() still works for messages enqueued before dispose', async () => {
- const ws = makeFakeWs();
- const reader = createMessageReader(ws);
- ws.dispatchMessage(JSON.stringify({ type: 'pre-dispose' }));
- // The message is in the queue; read() should return it immediately.
- const msg = await reader.read(100);
- expect(msg.type).toBe('pre-dispose');
- reader.dispose();
- });
-});
diff --git a/packages/cli/src/__tests__/shell-cipher.test.ts b/packages/cli/src/__tests__/shell-cipher.test.ts
deleted file mode 100644
index 67880ee..0000000
--- a/packages/cli/src/__tests__/shell-cipher.test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { describe, it, expect } from 'bun:test';
-import { ShellCipher } from '../shell-cipher.js';
-import { randomBytes } from '@noble/ciphers/utils.js';
-
-const sessionKey = randomBytes(32);
-
-describe('ShellCipher', () => {
- it('encrypts and decrypts a message (controller → target)', () => {
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- const plaintext = new TextEncoder().encode('hello world');
- const encrypted = controller.encrypt(plaintext);
- const decrypted = target.decrypt(encrypted);
-
- expect(new TextDecoder().decode(decrypted)).toBe('hello world');
-
- controller.close();
- target.close();
- });
-
- it('encrypts and decrypts a message (target → controller)', () => {
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- const plaintext = new TextEncoder().encode('response data');
- const encrypted = target.encrypt(plaintext);
- const decrypted = controller.decrypt(encrypted);
-
- expect(new TextDecoder().decode(decrypted)).toBe('response data');
-
- controller.close();
- target.close();
- });
-
- it('handles multiple messages in sequence', () => {
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- for (let i = 0; i < 100; i++) {
- const msg = new TextEncoder().encode(`message ${i}`);
- const encrypted = controller.encrypt(msg);
- const decrypted = target.decrypt(encrypted);
- expect(new TextDecoder().decode(decrypted)).toBe(`message ${i}`);
- }
-
- controller.close();
- target.close();
- });
-
- it('rejects out-of-order nonces', () => {
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- const msg1 = controller.encrypt(new TextEncoder().encode('first'));
- controller.encrypt(new TextEncoder().encode('second')); // advance counter
-
- // Consume first, then replay it — should fail
- target.decrypt(msg1);
- expect(() => target.decrypt(msg1)).toThrow('Nonce mismatch');
-
- controller.close();
- target.close();
- });
-
- it('rejects decryption with wrong key', () => {
- const key2 = randomBytes(32);
- const controller = new ShellCipher(sessionKey, 'controller');
- const wrongTarget = new ShellCipher(key2, 'target');
-
- const encrypted = controller.encrypt(new TextEncoder().encode('secret'));
- expect(() => wrongTarget.decrypt(encrypted)).toThrow();
-
- controller.close();
- wrongTarget.close();
- });
-
- it('refuses operations after close', () => {
- const cipher = new ShellCipher(sessionKey, 'controller');
- cipher.close();
-
- expect(() => cipher.encrypt(new Uint8Array(1))).toThrow('Cipher is closed');
- });
-
- it('rejects session key of wrong length', () => {
- expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow(
- 'Session key must be 32 bytes',
- );
- });
-
- it('survives an injected garbage frame without desyncing the session', () => {
- // Adversarial test for H3: a relay forwarding a junk frame must not
- // permanently break the session. The receiver must still decrypt the
- // next legitimate frame after dropping the bad one.
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- const legitFrame = controller.encrypt(new TextEncoder().encode('hello'));
-
- // Forge a frame with a plausible-shaped nonce but garbage contents.
- const garbage = new Uint8Array(32);
- garbage[0] = 0xff;
- expect(() => target.decrypt(garbage)).toThrow('Nonce mismatch');
-
- // The legitimate frame must still decrypt — the counter must not have
- // advanced on the failed attempt above.
- const decrypted = target.decrypt(legitFrame);
- expect(new TextDecoder().decode(decrypted)).toBe('hello');
-
- // And a second legitimate frame after a Poly1305-failing forgery must also
- // still work (counter only advances on successful authentication).
- const next = controller.encrypt(new TextEncoder().encode('world'));
- const tamperedNonceMatch = new Uint8Array(next.length);
- tamperedNonceMatch.set(next);
- // Flip a ciphertext byte so Poly1305 rejects it; nonce matches expected.
- tamperedNonceMatch[tamperedNonceMatch.length - 1] ^= 0x01;
- expect(() => target.decrypt(tamperedNonceMatch)).toThrow();
- expect(new TextDecoder().decode(target.decrypt(next))).toBe('world');
-
- controller.close();
- target.close();
- });
-
- it('controller and target nonces do not overlap', () => {
- const controller = new ShellCipher(sessionKey, 'controller');
- const target = new ShellCipher(sessionKey, 'target');
-
- // Both encrypt — the nonces should be different (high bit split)
- const enc1 = controller.encrypt(new TextEncoder().encode('a'));
- const enc2 = target.encrypt(new TextEncoder().encode('b'));
-
- // First byte of nonce: controller=0x00, target=0x80
- expect(enc1[0]).toBe(0x00);
- expect(enc2[0]).toBe(0x80);
-
- controller.close();
- target.close();
- });
-});
diff --git a/packages/cli/src/__tests__/shell-handshake-sig.test.ts b/packages/cli/src/__tests__/shell-handshake-sig.test.ts
deleted file mode 100644
index 516379f..0000000
--- a/packages/cli/src/__tests__/shell-handshake-sig.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { describe, it, expect } from 'bun:test';
-import { p256 } from '@noble/curves/nist.js';
-import { signMessage, verifyMessage } from '@authmesh/core';
-import { buildShellSigMessage } from '../shell-handshake.js';
-
-/**
- * Regression test for C1 — shell handshake MITM via unbound selfSig.
- *
- * A valid selfSig must verify ONLY against the ECDH transcript that the peer
- * actually saw. A relay-MITM that substitutes its own ephemeral keys on each
- * leg must not be able to replay a captured selfSig from one leg to the other.
- */
-describe('shell handshake signature binding (C1)', () => {
- function makeIdentity(friendlyName: string, deviceId: string) {
- const privateKey = p256.utils.randomSecretKey();
- const publicKey = p256.getPublicKey(privateKey, true);
- return {
- privateKey,
- publicKey,
- publicKeyBase64: Buffer.from(publicKey).toString('base64'),
- friendlyName,
- deviceId,
- };
- }
-
- function randomEphemeralPub(): Uint8Array {
- return p256.getPublicKey(p256.utils.randomSecretKey(), true);
- }
-
- it('a signature bound to ephemeral pair (A,B) does not verify against pair (A,C)', () => {
- const controller = makeIdentity('ctrl', 'am_ctrl1234567890');
-
- const legitControllerEph = randomEphemeralPub();
- const legitAgentEph = randomEphemeralPub();
- const attackerEph = randomEphemeralPub();
-
- const timestamp = new Date().toISOString();
-
- const msgForLegA = buildShellSigMessage({
- publicKey: controller.publicKeyBase64,
- deviceId: controller.deviceId,
- friendlyName: controller.friendlyName,
- timestamp,
- signerEphPub: legitControllerEph,
- verifierEphPub: legitAgentEph,
- });
- const sig = signMessage(controller.privateKey, msgForLegA);
-
- expect(verifyMessage(sig, msgForLegA, controller.publicKey)).toBe(true);
-
- const msgAsSeenByAgent = buildShellSigMessage({
- publicKey: controller.publicKeyBase64,
- deviceId: controller.deviceId,
- friendlyName: controller.friendlyName,
- timestamp,
- signerEphPub: attackerEph,
- verifierEphPub: legitAgentEph,
- });
- expect(verifyMessage(sig, msgAsSeenByAgent, controller.publicKey)).toBe(false);
- });
-
- it('flipping deviceId, friendlyName, or timestamp invalidates the signature', () => {
- const id = makeIdentity('alice', 'am_alice1234567890');
- const signerEph = randomEphemeralPub();
- const verifierEph = randomEphemeralPub();
- const timestamp = new Date().toISOString();
-
- const base = {
- publicKey: id.publicKeyBase64,
- deviceId: id.deviceId,
- friendlyName: id.friendlyName,
- timestamp,
- signerEphPub: signerEph,
- verifierEphPub: verifierEph,
- };
-
- const sig = signMessage(id.privateKey, buildShellSigMessage(base));
- expect(verifyMessage(sig, buildShellSigMessage(base), id.publicKey)).toBe(true);
-
- expect(
- verifyMessage(
- sig,
- buildShellSigMessage({ ...base, deviceId: 'am_mallory1234' }),
- id.publicKey,
- ),
- ).toBe(false);
-
- expect(
- verifyMessage(sig, buildShellSigMessage({ ...base, friendlyName: 'mallory' }), id.publicKey),
- ).toBe(false);
-
- expect(
- verifyMessage(
- sig,
- buildShellSigMessage({ ...base, timestamp: new Date(Date.now() + 1000).toISOString() }),
- id.publicKey,
- ),
- ).toBe(false);
- });
-
- it('domain separator prevents cross-protocol signature reuse', () => {
- const id = makeIdentity('bob', 'am_bob9999999999');
- const signerEph = randomEphemeralPub();
- const verifierEph = randomEphemeralPub();
- const timestamp = new Date().toISOString();
-
- const shellMsg = buildShellSigMessage({
- publicKey: id.publicKeyBase64,
- deviceId: id.deviceId,
- friendlyName: id.friendlyName,
- timestamp,
- signerEphPub: signerEph,
- verifierEphPub: verifierEph,
- });
- const sig = signMessage(id.privateKey, shellMsg);
-
- const oldFormatMsg = new TextEncoder().encode(id.publicKeyBase64 + id.friendlyName + timestamp);
- expect(verifyMessage(sig, oldFormatMsg, id.publicKey)).toBe(false);
- });
-});
diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts
deleted file mode 100644
index f56985e..0000000
--- a/packages/cli/src/agent.ts
+++ /dev/null
@@ -1,376 +0,0 @@
-import { ShellCipher } from './shell-cipher.js';
-import { AllowList, createForBackend } from '@authmesh/keystore';
-import type { StorageBackend } from '@authmesh/keystore';
-import { loadIdentity, saveIdentity } from './identity.js';
-import type { Identity } from './identity.js';
-import { writeFile, unlink, mkdir } from 'node:fs/promises';
-import { dirname } from 'node:path';
-import {
- getIdentityPath,
- getKeysDir,
- getAllowListPath,
- resolvePassphrase,
- getPidPath,
-} from './paths.js';
-import { runAgentShellHandshake, createMessageReader, send } from './shell-handshake.js';
-import {
- FrameType,
- encodeDataFrame,
- encodeExitFrame,
- encodePongFrame,
- parseFrame,
- parseResize,
-} from './frame.js';
-
-interface AgentOptions {
- relayUrl: string;
- allowRoot: boolean;
- idleTimeoutMinutes: number;
-}
-
-function sanitizeForLog(str: string, maxLen = 200): string {
- // Strip non-printable characters and truncate
- return str.replace(/[^\x20-\x7E]/g, '').slice(0, maxLen);
-}
-
-export async function startAgent(opts: AgentOptions): Promise {
- // Root guard (M1 fix)
- if (typeof process.getuid === 'function' && process.getuid() === 0 && !opts.allowRoot) {
- console.error('[amesh agent] ERROR: refusing to run as root.');
- console.error(' Running as root grants root shells to all authorized controllers.');
- console.error(' Use --allow-root to override.');
- process.exit(1);
- }
-
- const identity = await loadIdentity(getIdentityPath());
-
- // H2 — passphrase lives in a dedicated file, not identity.json.
- const { passphrase, migratedFromIdentity } = await resolvePassphrase(identity);
- if (migratedFromIdentity) {
- await saveIdentity(getIdentityPath(), identity);
- }
- const keyStore = await createForBackend(
- identity.storageBackend as StorageBackend,
- getKeysDir(),
- passphrase,
- );
-
- const keyAlias = identity.keyAlias ?? identity.deviceId;
- const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias);
- const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId);
-
- const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message);
-
- /**
- * Represents an in-flight shell session. Owned by the WebSocket that opened
- * it — on WS close (M4), the owning connect() scope tears it down so the
- * bash process doesn't orphan and `sessionActive` is correctly reset.
- */
- interface ActiveSession {
- proc: {
- kill: () => void;
- exited: Promise;
- terminal?: { write: (_: unknown) => void; resize: (_c: number, _r: number) => void };
- };
- cipher: ShellCipher;
- idleCheck: ReturnType;
- messageHandler: (event: MessageEvent) => void;
- }
-
- let sessionActive = false;
- // Current active session, if any. Scoped to the outer closure so the
- // connect()'s ws.close handler can tear it down.
- let activeSession: ActiveSession | null = null;
-
- function teardownActiveSession(reason: string): void {
- if (!activeSession) return;
- console.log(`[amesh agent] Tearing down active session (${reason})`);
- try {
- activeSession.proc.kill();
- } catch {
- /* already exited */
- }
- clearInterval(activeSession.idleCheck);
- try {
- activeSession.cipher.close();
- } catch {
- /* already closed */
- }
- activeSession = null;
- sessionActive = false;
- }
-
- // Write PID file for `agent stop`
- const pidPath = getPidPath();
- await mkdir(dirname(pidPath), { recursive: true });
- await writeFile(pidPath, String(process.pid), { mode: 0o600 });
-
- // Graceful shutdown on SIGTERM / SIGINT
- const shutdown = async () => {
- console.log('[amesh agent] Shutting down...');
- if (activeSession) teardownActiveSession('shutdown');
- await unlink(pidPath).catch(() => {});
- process.exit(0);
- };
- process.on('SIGTERM', shutdown);
- process.on('SIGINT', shutdown);
-
- console.log(`[amesh agent] Device: ${identity.deviceId} (${identity.friendlyName})`);
- console.log(`[amesh agent] Connecting to relay: ${opts.relayUrl}`);
-
- // Connect to relay with reconnect
- let reconnectDelay = 1000;
- const maxReconnectDelay = 30000;
-
- function connect(): void {
- const ws = new WebSocket(opts.relayUrl);
-
- ws.addEventListener('open', () => {
- reconnectDelay = 1000;
- // Step 1: Send registration request — relay will issue a challenge
- send(ws, {
- type: 'agent',
- deviceId: identity.deviceId,
- publicKey: identity.publicKey,
- });
-
- // Heartbeat
- const pingInterval = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- send(ws, { type: 'ping' });
- }
- }, 30_000);
-
- ws.addEventListener('close', () => {
- clearInterval(pingInterval);
- });
- });
-
- ws.addEventListener('message', async (event: MessageEvent) => {
- const raw = typeof event.data === 'string' ? event.data : String(event.data);
- let msg;
- try {
- msg = JSON.parse(raw);
- } catch {
- return;
- }
-
- // Step 2: Relay issues challenge — sign it to prove key ownership
- if (msg.type === 'agent_challenge') {
- const challenge = new TextEncoder().encode(msg.challenge as string);
- const sig = await signFn(challenge);
- send(ws, {
- type: 'agent_challenge_response',
- sig: Buffer.from(sig).toString('base64url'),
- });
- return;
- }
-
- if (msg.type === 'agent_registered') {
- console.log('[amesh agent] Registered with relay (identity verified).');
- const data = await allowList.read();
- const shellControllers = data.devices.filter(
- (d) => d.role === 'controller' && d.permissions?.shell,
- ).length;
- console.log(`[amesh agent] Authorized controllers with shell access: ${shellControllers}`);
- return;
- }
-
- if (msg.type === 'pong') return;
-
- if (msg.type === 'peer_found') {
- if (sessionActive) {
- console.error('[amesh agent] Session already active, rejecting');
- return;
- }
- sessionActive = true;
- handleShellRequest(ws, allowList, identity, signFn, opts.idleTimeoutMinutes)
- .catch(() => {})
- .finally(() => {
- sessionActive = false;
- activeSession = null;
- });
- return;
- }
- });
-
- ws.addEventListener('close', () => {
- // M4 — tear down any active shell session on disconnect. Previously the
- // bash process kept running, `sessionActive` stayed true, and reconnect
- // could never accept a new session until idle timeout fired.
- if (activeSession) {
- teardownActiveSession('ws_disconnect');
- }
- console.log(`[amesh agent] Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`);
- setTimeout(connect, reconnectDelay);
- reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
- });
-
- ws.addEventListener('error', () => {
- // close event will fire after error, triggering reconnect
- });
- }
-
- async function handleShellRequest(
- ws: WebSocket,
- al: AllowList,
- id: Identity,
- sign: (message: Uint8Array) => Promise,
- idleTimeoutMin: number,
- ): Promise {
- const reader = createMessageReader(ws);
-
- try {
- const result = await runAgentShellHandshake(
- ws,
- reader,
- id.deviceId,
- id.publicKey,
- id.friendlyName,
- sign,
- al,
- );
-
- // M4 — drop the handshake reader NOW. Its message listener would
- // otherwise accumulate every encrypted shell frame into an unread
- // queue for the rest of the session (unbounded memory growth).
- reader.dispose();
-
- const startTime = Date.now();
-
- console.log(
- `[amesh agent] Shell opened by ${result.peerDeviceId} (${result.peerFriendlyName})`,
- );
-
- // Set up encrypted cipher + zero the handshake result copy (L3 fix)
- const cipher = new ShellCipher(result.sessionKey, 'target');
- result.sessionKey.fill(0);
-
- // Spawn PTY
- const cols = process.stdout.columns ?? 80;
- const rows = process.stdout.rows ?? 24;
-
- const proc = Bun.spawn(['bash'], {
- terminal: {
- cols,
- rows,
- data(_terminal: unknown, data: Uint8Array) {
- // PTY stdout → encrypt → send
- const frame = encodeDataFrame(data);
- const encrypted = cipher.encrypt(frame);
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(
- JSON.stringify({
- type: 'data',
- payload: Buffer.from(encrypted).toString('base64'),
- }),
- );
- }
- },
- },
- });
-
- // Idle timeout (H1 fix)
- let lastActivity = Date.now();
- const idleCheck = setInterval(() => {
- if (Date.now() - lastActivity > idleTimeoutMin * 60_000) {
- console.log(`[amesh agent] Idle timeout for ${result.peerDeviceId}`);
- proc.kill();
- }
- }, 30_000);
-
- // Receive encrypted frames from controller
- const messageHandler = (event: MessageEvent) => {
- const raw = typeof event.data === 'string' ? event.data : String(event.data);
- let msg;
- try {
- msg = JSON.parse(raw);
- } catch {
- return;
- }
-
- if (msg.type !== 'data' || !msg.payload) return;
- lastActivity = Date.now();
-
- try {
- const decrypted = cipher.decrypt(Buffer.from(msg.payload, 'base64'));
- const { type, payload } = parseFrame(decrypted);
-
- switch (type) {
- case FrameType.DATA:
- proc.terminal?.write(payload);
- break;
- case FrameType.RESIZE: {
- const { cols: c, rows: r } = parseResize(payload);
- proc.terminal?.resize(c, r);
- break;
- }
- case FrameType.PING: {
- const pong = cipher.encrypt(encodePongFrame());
- ws.send(
- JSON.stringify({ type: 'data', payload: Buffer.from(pong).toString('base64') }),
- );
- break;
- }
- case FrameType.COMMAND: {
- const cmd = new TextDecoder().decode(payload);
- console.log(
- `[amesh agent] Command from ${result.peerDeviceId}: ${sanitizeForLog(cmd)}`,
- );
- proc.terminal?.write(cmd + '\nexit\n');
- break;
- }
- }
- } catch (err) {
- console.error('[amesh agent] Frame decryption error:', (err as Error).message);
- }
- };
- ws.addEventListener('message', messageHandler);
-
- // Register with the outer scope so the ws.close handler (M4 teardown)
- // can kill the proc, clear the timer, and close the cipher if the
- // relay disconnects mid-session.
- activeSession = {
- proc: proc as ActiveSession['proc'],
- cipher,
- idleCheck,
- messageHandler,
- };
-
- // Wait for process exit
- const exitCode = await proc.exited;
- clearInterval(idleCheck);
- ws.removeEventListener('message', messageHandler);
-
- // Send exit frame
- try {
- const exitFrame = cipher.encrypt(encodeExitFrame(exitCode));
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(
- JSON.stringify({ type: 'data', payload: Buffer.from(exitFrame).toString('base64') }),
- );
- }
- } catch {
- /* cipher may be closed */
- }
-
- const duration = Math.round((Date.now() - startTime) / 1000);
- console.log(
- `[amesh agent] Shell closed for ${result.peerDeviceId} (exit=${exitCode}, duration=${duration}s)`,
- );
-
- cipher.close();
- activeSession = null;
- // sessionActive reset by .finally() in caller
- } catch (err) {
- reader.dispose();
- console.error('[amesh agent] Shell handshake failed:', (err as Error).message);
- // sessionActive reset by .finally() in caller
- }
- }
-
- connect();
-
- // Keep process alive
- await new Promise(() => {});
-}
diff --git a/packages/cli/src/commands/agent/start.ts b/packages/cli/src/commands/agent/start.ts
deleted file mode 100644
index fd18f0a..0000000
--- a/packages/cli/src/commands/agent/start.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Command, Flags } from '@oclif/core';
-
-export default class AgentStart extends Command {
- static override description = 'Start the amesh agent daemon (accepts remote shell connections)';
-
- static override flags = {
- relay: Flags.string({
- char: 'r',
- description: 'Relay server URL',
- default: 'wss://relay.authmesh.dev/ws',
- env: 'AMESH_RELAY_URL',
- }),
- 'allow-root': Flags.boolean({
- description: 'Allow running as root (grants root shells to all controllers)',
- default: false,
- }),
- 'idle-timeout': Flags.integer({
- description: 'Idle session timeout in minutes',
- default: 30,
- min: 1,
- }),
- };
-
- async run(): Promise {
- // Agent daemon requires Bun for PTY support (Bun.spawn({ terminal: }))
- if (typeof globalThis.Bun === 'undefined') {
- this.error(
- 'The agent daemon requires Bun runtime for PTY support.\n' +
- ' Install Bun: curl -fsSL https://bun.sh/install | bash\n' +
- ' Then run: bun amesh agent start',
- );
- }
-
- const { flags } = await this.parse(AgentStart);
-
- const { startAgent } = await import('../../agent.js');
- await startAgent({
- relayUrl: flags.relay,
- allowRoot: flags['allow-root'],
- idleTimeoutMinutes: flags['idle-timeout'],
- });
- }
-}
diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts
deleted file mode 100644
index 9dcfdf0..0000000
--- a/packages/cli/src/commands/agent/stop.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Command } from '@oclif/core';
-import { readFile, unlink } from 'node:fs/promises';
-import { getPidPath } from '../../paths.js';
-
-export default class AgentStop extends Command {
- static override description = 'Stop the running amesh agent daemon';
-
- async run(): Promise {
- await this.parse(AgentStop);
-
- const pidPath = getPidPath();
- let pid: number;
- try {
- const content = await readFile(pidPath, 'utf-8');
- pid = parseInt(content.trim(), 10);
- if (isNaN(pid)) throw new Error('invalid pid');
- } catch {
- this.error(
- 'No running agent found.\n' +
- 'The agent may not be running, or the PID file is missing.\n' +
- `Expected PID file at: ${pidPath}`,
- );
- }
-
- try {
- process.kill(pid, 'SIGTERM');
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
- // Process doesn't exist — clean up stale PID file
- await unlink(pidPath).catch(() => {});
- this.error(`Agent process ${pid} is not running (stale PID file removed).`);
- }
- throw err;
- }
-
- // Clean up PID file (agent's SIGTERM handler also removes it, but be safe)
- await unlink(pidPath).catch(() => {});
-
- this.log('');
- this.log(` Agent (PID ${pid}) stopped.`);
- this.log('');
- }
-}
diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts
deleted file mode 100644
index 2e5860c..0000000
--- a/packages/cli/src/commands/grant.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Command, Args, Flags } from '@oclif/core';
-import { loadContext } from '../context.js';
-
-export default class Grant extends Command {
- static override description = 'Grant or revoke permissions for a paired device';
-
- static override args = {
- deviceId: Args.string({
- description: 'Device ID to modify (e.g., am_1a2b3c4d5e6f7a8b)',
- required: true,
- }),
- };
-
- static override flags = {
- shell: Flags.boolean({
- description: 'Grant shell access (remote terminal)',
- allowNo: true,
- }),
- };
-
- async run(): Promise {
- const { args, flags } = await this.parse(Grant);
-
- if (flags.shell === undefined) {
- this.error(
- 'Specify a permission to grant or revoke.\n' +
- ' Example: amesh grant --shell',
- );
- }
-
- const { allowList } = await loadContext().catch(() => {
- this.error('No identity found. Run `amesh init` first.');
- });
-
- const data = await allowList.read();
- const device = data.devices.find((d) => d.deviceId === args.deviceId);
- if (!device) {
- this.error(
- `Device ${args.deviceId} not found in allow list.\n` +
- 'Run `amesh list` to see paired devices.\n' +
- "Note: grant runs on the target — you're granting a controller permission to access this device.",
- );
- }
-
- await allowList.updatePermissions(args.deviceId, { shell: flags.shell });
-
- this.log('');
- this.log(` Device: ${device.friendlyName} (${args.deviceId})`);
- this.log(` Shell access: ${flags.shell ? 'granted' : 'revoked'}`);
- this.log('');
- }
-}
diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts
index faf556d..3d177ee 100644
--- a/packages/cli/src/commands/listen.ts
+++ b/packages/cli/src/commands/listen.ts
@@ -16,10 +16,6 @@ export default class Listen extends Command {
default: DEFAULT_RELAY,
env: 'AMESH_RELAY_URL',
}),
- shell: Flags.boolean({
- description: 'Auto-grant shell access to the controller after pairing',
- default: false,
- }),
};
async run(): Promise {
@@ -128,11 +124,6 @@ export default class Listen extends Command {
this.log('');
this.log(` "${result.peerFriendlyName}" added as controller.`);
- if (flags.shell) {
- await allowList.updatePermissions(newDevice.deviceId, { shell: true });
- this.log(' Shell access: granted');
- }
-
this.log('');
this.log(' Pairing complete. The relay connection is closed.');
this.log('');
diff --git a/packages/cli/src/commands/reset.ts b/packages/cli/src/commands/reset.ts
deleted file mode 100644
index 4c566ca..0000000
--- a/packages/cli/src/commands/reset.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Command } from '@oclif/core';
-import { readFile, unlink } from 'node:fs/promises';
-import { getAuthMeshDir, getPidPath } from '../paths.js';
-
-export default class Reset extends Command {
- static override description =
- 'Reset ephemeral state (stops agent, clears stale sessions without affecting identity or pairings)';
-
- async run(): Promise {
- await this.parse(Reset);
-
- this.log('');
- this.log(' Resetting agent state...');
-
- // Stop running agent if any
- const pidPath = getPidPath();
- try {
- const content = await readFile(pidPath, 'utf-8');
- const pid = parseInt(content.trim(), 10);
- if (!isNaN(pid)) {
- try {
- process.kill(pid, 'SIGTERM');
- this.log(` Stopped running agent (PID ${pid}).`);
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
- this.log(` Stale PID file found (process ${pid} not running).`);
- }
- }
- }
- } catch {
- // No PID file — agent not running
- }
-
- await unlink(pidPath).catch(() => {});
-
- this.log('');
- this.log(' Reset complete. Ephemeral session state cleared.');
- this.log(' Your identity and pairings are unchanged.');
- this.log('');
- this.log(' To reconnect:');
- this.log(' amesh agent start');
- this.log('');
- this.log(` Config directory: ${getAuthMeshDir()}`);
- this.log('');
- }
-}
diff --git a/packages/cli/src/commands/shell.ts b/packages/cli/src/commands/shell.ts
deleted file mode 100644
index ffa1f55..0000000
--- a/packages/cli/src/commands/shell.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Command, Args, Flags } from '@oclif/core';
-
-export default class Shell extends Command {
- static override description = 'Open a remote shell to a paired device';
-
- static override args = {
- device: Args.string({
- description: 'Device ID (am_...) or friendly name of the target',
- required: true,
- }),
- };
-
- static override flags = {
- command: Flags.string({
- char: 'c',
- description: 'Run a single command and exit',
- }),
- relay: Flags.string({
- char: 'r',
- description: 'Relay server URL',
- default: 'wss://relay.authmesh.dev/ws',
- env: 'AMESH_RELAY_URL',
- }),
- };
-
- async run(): Promise {
- const { args, flags } = await this.parse(Shell);
-
- const { connectShell } = await import('../shell-client.js');
- const exitCode = await connectShell({
- target: args.device,
- relayUrl: flags.relay,
- command: flags.command,
- });
-
- this.exit(exitCode);
- }
-}
diff --git a/packages/cli/src/frame.ts b/packages/cli/src/frame.ts
deleted file mode 100644
index b963b22..0000000
--- a/packages/cli/src/frame.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Shell frame protocol — binary frames over the encrypted tunnel.
- *
- * Each frame is: type_byte (1B) || payload (variable)
- * The entire frame is then encrypted with ShellCipher before transmission.
- */
-
-export const FrameType = {
- DATA: 0x01, // Raw terminal bytes (stdin/stdout)
- RESIZE: 0x02, // Terminal resize: { cols: u16, rows: u16 } (4 bytes BE)
- EXIT: 0x03, // Process exit: { code: i32 } (4 bytes BE)
- PING: 0x04, // Keepalive ping (empty payload)
- PONG: 0x05, // Keepalive pong (empty payload)
- COMMAND: 0x06, // Single command for -c mode (UTF-8 string)
-} as const;
-
-export type FrameTypeValue = (typeof FrameType)[keyof typeof FrameType];
-
-export function encodeDataFrame(data: Uint8Array): Uint8Array {
- const frame = new Uint8Array(1 + data.length);
- frame[0] = FrameType.DATA;
- frame.set(data, 1);
- return frame;
-}
-
-export function encodeResizeFrame(cols: number, rows: number): Uint8Array {
- const frame = new Uint8Array(5);
- frame[0] = FrameType.RESIZE;
- const view = new DataView(frame.buffer);
- view.setUint16(1, cols, false);
- view.setUint16(3, rows, false);
- return frame;
-}
-
-export function encodeExitFrame(code: number): Uint8Array {
- const frame = new Uint8Array(5);
- frame[0] = FrameType.EXIT;
- const view = new DataView(frame.buffer);
- view.setInt32(1, code, false);
- return frame;
-}
-
-export function encodePingFrame(): Uint8Array {
- return new Uint8Array([FrameType.PING]);
-}
-
-export function encodePongFrame(): Uint8Array {
- return new Uint8Array([FrameType.PONG]);
-}
-
-export function encodeCommandFrame(command: string): Uint8Array {
- const encoded = new TextEncoder().encode(command);
- const frame = new Uint8Array(1 + encoded.length);
- frame[0] = FrameType.COMMAND;
- frame.set(encoded, 1);
- return frame;
-}
-
-const VALID_FRAME_TYPES = new Set([
- FrameType.DATA,
- FrameType.RESIZE,
- FrameType.EXIT,
- FrameType.PING,
- FrameType.PONG,
- FrameType.COMMAND,
-]);
-
-export function parseFrame(frame: Uint8Array): { type: FrameTypeValue; payload: Uint8Array } {
- if (frame.length < 1) throw new Error('Empty frame');
- if (!VALID_FRAME_TYPES.has(frame[0]))
- throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`);
- return {
- type: frame[0] as FrameTypeValue,
- payload: frame.subarray(1),
- };
-}
-
-export function parseResize(payload: Uint8Array): { cols: number; rows: number } {
- if (payload.byteLength < 4) throw new Error('RESIZE frame too short');
- const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
- return { cols: view.getUint16(0, false), rows: view.getUint16(2, false) };
-}
-
-export function parseExit(payload: Uint8Array): { code: number } {
- if (payload.byteLength < 4) throw new Error('EXIT frame too short');
- const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
- return { code: view.getInt32(0, false) };
-}
diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts
index 2bb36ca..aaac0dc 100644
--- a/packages/cli/src/paths.ts
+++ b/packages/cli/src/paths.ts
@@ -20,10 +20,6 @@ export function getKeysDir(): string {
return join(getAuthMeshDir(), 'keys');
}
-export function getPidPath(): string {
- return join(getAuthMeshDir(), 'agent.pid');
-}
-
/**
* Location of the encrypted-file backend passphrase, stored separately from
* identity.json so a leak of identity.json alone does not compromise the key.
diff --git a/packages/cli/src/sea.ts b/packages/cli/src/sea.ts
index b1ff4f8..ac173d2 100644
--- a/packages/cli/src/sea.ts
+++ b/packages/cli/src/sea.ts
@@ -15,17 +15,12 @@ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
-import Grant from './commands/grant.js';
import Init from './commands/init.js';
import Invite from './commands/invite.js';
import List from './commands/list.js';
import Listen from './commands/listen.js';
import Provision from './commands/provision.js';
-import Reset from './commands/reset.js';
import Revoke from './commands/revoke.js';
-import Shell from './commands/shell.js';
-import AgentStart from './commands/agent/start.js';
-import AgentStop from './commands/agent/stop.js';
declare const __VERSION__: string;
const VERSION = __VERSION__; // replaced at build time by bun
@@ -47,23 +42,15 @@ interface CommandMeta {
}
const topLevelCommands: Record = {
- grant: Grant,
init: Init,
invite: Invite,
list: List,
listen: Listen,
provision: Provision,
- reset: Reset,
revoke: Revoke,
- shell: Shell,
};
-const nestedCommands: Record> = {
- agent: {
- start: AgentStart,
- stop: AgentStop,
- },
-};
+const nestedCommands: Record> = {};
/**
* Create a minimal oclif root so Config.load() works in compiled binaries.
@@ -145,7 +132,7 @@ async function main(): Promise {
const oclifRoot = getOclifRoot();
- // Nested commands: `amesh agent start [flags]`
+ // Nested commands (e.g. `amesh [flags]`)
const nested = nestedCommands[first];
if (nested) {
const sub = args[1];
diff --git a/packages/cli/src/shell-cipher.ts b/packages/cli/src/shell-cipher.ts
deleted file mode 100644
index 9b2019c..0000000
--- a/packages/cli/src/shell-cipher.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
-
-const NONCE_LEN = 12;
-
-/**
- * Encrypted shell session cipher using ChaCha20-Poly1305 with incrementing nonces.
- *
- * Each side maintains its own send counter:
- * - Controller starts at 0x00...00
- * - Target starts at 0x80...00 (high bit set)
- *
- * This ensures the two sides never produce the same nonce, and provides
- * ordering guarantees. Nonce reuse with ChaCha20-Poly1305 is catastrophic
- * (XOR of ciphertexts leaks plaintext), so this design eliminates it.
- *
- * MUST NOT be confused with the random-nonce encrypt()/decrypt() in handshake.ts.
- * That code is for one-shot pairing messages. This is for long-lived shell sessions.
- */
-export class ShellCipher {
- private readonly sessionKey: Uint8Array;
- private readonly sendNonce: Uint8Array;
- private readonly recvNonceStart: Uint8Array;
- private sendCounter: bigint;
- private recvCounter: bigint;
- private closed = false;
-
- /**
- * @param sessionKey - 32-byte key from deriveShellSessionKey()
- * @param role - 'controller' starts send nonce at 0x00, 'target' starts at 0x80
- */
- constructor(sessionKey: Uint8Array, role: 'controller' | 'target') {
- if (sessionKey.length !== 32) throw new Error('Session key must be 32 bytes');
- this.sessionKey = new Uint8Array(sessionKey);
- this.sendNonce = new Uint8Array(NONCE_LEN);
- this.recvNonceStart = new Uint8Array(NONCE_LEN);
-
- if (role === 'controller') {
- // Controller sends with nonces starting at 0x00..., receives 0x80...
- this.recvNonceStart[0] = 0x80;
- } else {
- // Target sends with nonces starting at 0x80..., receives 0x00...
- this.sendNonce[0] = 0x80;
- }
-
- this.sendCounter = 0n;
- this.recvCounter = 0n;
- }
-
- encrypt(plaintext: Uint8Array): Uint8Array {
- if (this.closed) throw new Error('Cipher is closed');
- const nonce = this.nextSendNonce();
- const cipher = chacha20poly1305(this.sessionKey, nonce);
- const ciphertext = cipher.encrypt(plaintext);
- // Prepend nonce so receiver can verify ordering
- const out = new Uint8Array(NONCE_LEN + ciphertext.length);
- out.set(nonce, 0);
- out.set(ciphertext, NONCE_LEN);
- return out;
- }
-
- decrypt(data: Uint8Array): Uint8Array {
- if (this.closed) throw new Error('Cipher is closed');
- if (data.length < NONCE_LEN + 16) throw new Error('Ciphertext too short'); // 16 = Poly1305 tag
- const nonce = data.subarray(0, NONCE_LEN);
- const ciphertext = data.subarray(NONCE_LEN);
-
- // Peek at expected nonce WITHOUT advancing the counter. Advancing before
- // authentication succeeds lets any injected/malformed frame permanently
- // desync the session — a one-packet DoS from an untrusted relay.
- const expected = this.peekRecvNonce();
- if (!constantTimeEqual(nonce, expected)) {
- throw new Error('Nonce mismatch — possible replay or out-of-order frame');
- }
-
- const cipher = chacha20poly1305(this.sessionKey, nonce);
- const plaintext = cipher.decrypt(ciphertext); // throws on Poly1305 auth failure
- // Only advance the receive counter after the frame is fully authenticated.
- this.recvCounter++;
- return plaintext;
- }
-
- close(): void {
- this.closed = true;
- this.sessionKey.fill(0);
- this.sendNonce.fill(0);
- this.recvNonceStart.fill(0);
- }
-
- private static readonly MAX_COUNTER = 2n ** 64n - 1n;
-
- private nextSendNonce(): Uint8Array {
- if (this.sendCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted');
- const nonce = new Uint8Array(this.sendNonce);
- this.incrementCounter(nonce, this.sendCounter);
- this.sendCounter++;
- return nonce;
- }
-
- /**
- * Compute the currently-expected receive nonce without mutating the counter.
- * The counter is advanced by decrypt() only after successful AEAD verification.
- */
- private peekRecvNonce(): Uint8Array {
- if (this.recvCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted');
- const nonce = new Uint8Array(this.recvNonceStart);
- this.incrementCounter(nonce, this.recvCounter);
- return nonce;
- }
-
- /**
- * Write counter into nonce bytes 4-11 (big-endian), preserving the role prefix in bytes 0-3.
- */
- private incrementCounter(nonce: Uint8Array, counter: bigint): void {
- const view = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength);
- // Write 64-bit counter into bytes 4-11
- view.setBigUint64(4, counter, false); // big-endian
- }
-}
-
-function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
- if (a.length !== b.length) return false;
- let diff = 0;
- for (let i = 0; i < a.length; i++) {
- diff |= a[i] ^ b[i];
- }
- return diff === 0;
-}
diff --git a/packages/cli/src/shell-client.ts b/packages/cli/src/shell-client.ts
deleted file mode 100644
index c93003c..0000000
--- a/packages/cli/src/shell-client.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { ShellCipher } from './shell-cipher.js';
-import { AllowList, createForBackend } from '@authmesh/keystore';
-import type { StorageBackend } from '@authmesh/keystore';
-import { loadIdentity, saveIdentity } from './identity.js';
-import { getIdentityPath, getKeysDir, getAllowListPath, resolvePassphrase } from './paths.js';
-import { runControllerShellHandshake, createMessageReader, send } from './shell-handshake.js';
-import {
- FrameType,
- encodeDataFrame,
- encodeResizeFrame,
- encodePingFrame,
- encodeCommandFrame,
- parseFrame,
- parseExit,
-} from './frame.js';
-
-interface ShellOptions {
- target: string; // device ID or friendly name
- relayUrl: string;
- command?: string; // -c mode
-}
-
-export async function connectShell(opts: ShellOptions): Promise {
- const identity = await loadIdentity(getIdentityPath());
-
- // H2 — passphrase lives in a dedicated file, not identity.json.
- const { passphrase, migratedFromIdentity } = await resolvePassphrase(identity);
- if (migratedFromIdentity) {
- await saveIdentity(getIdentityPath(), identity);
- }
- const keyStore = await createForBackend(
- identity.storageBackend as StorageBackend,
- getKeysDir(),
- passphrase,
- );
-
- const keyAlias = identity.keyAlias ?? identity.deviceId;
- const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias);
- const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId);
- const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message);
-
- // Resolve target: by device ID or friendly name
- const data = await allowList.read();
- const targetDevice = data.devices.find(
- (d) => (d.deviceId === opts.target || d.friendlyName === opts.target) && d.role === 'target',
- );
- if (!targetDevice) {
- console.error(`Error: target "${opts.target}" not found in allow list.`);
- console.error('Run `amesh list` to see paired devices.');
- return 1;
- }
-
- console.error(`Connecting to ${targetDevice.friendlyName} (${targetDevice.deviceId})...`);
-
- // Connect to relay
- const ws = new WebSocket(opts.relayUrl);
- await new Promise((resolve, reject) => {
- ws.addEventListener('open', () => resolve());
- ws.addEventListener('error', (e) => reject(e));
- });
-
- // Request shell (C3 fix — include targetPublicKey for relay matching)
- send(ws, {
- type: 'shell',
- targetDeviceId: targetDevice.deviceId,
- targetPublicKey: targetDevice.publicKey,
- });
-
- const reader = createMessageReader(ws);
- const peerFound = await reader.read(30_000);
- if (peerFound.type === 'error') {
- console.error(`Relay error: ${peerFound.code}`);
- reader.dispose();
- ws.close();
- return 1;
- }
-
- // Shell handshake
- let result;
- try {
- result = await runControllerShellHandshake(
- ws,
- reader,
- identity.deviceId,
- identity.publicKey,
- identity.friendlyName,
- signFn,
- allowList,
- );
- } catch (err) {
- console.error(`Handshake failed: ${(err as Error).message}`);
- console.error('Is the agent running on the target? Start it with: amesh agent start');
- reader.dispose();
- ws.close();
- return 1;
- }
-
- // M4 — drop the handshake reader. The encrypted frame loop below installs
- // its own listener; without dispose() the reader's queue would grow on
- // every frame for the lifetime of the shell session.
- reader.dispose();
-
- console.error(`Connected. Shell session started.\n`);
-
- const cipher = new ShellCipher(result.sessionKey, 'controller');
- result.sessionKey.fill(0); // L3 fix — zero handshake result copy
- const startTime = Date.now();
- let exitCode = 0;
-
- return new Promise((resolve) => {
- // If -c mode, send command frame
- if (opts.command) {
- const frame = cipher.encrypt(encodeCommandFrame(opts.command));
- ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') }));
- } else {
- // Interactive mode — raw terminal
- if (process.stdin.isTTY) {
- process.stdin.setRawMode(true);
- }
- process.stdin.on('data', (chunk: Buffer) => {
- const frame = cipher.encrypt(encodeDataFrame(chunk));
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') }));
- }
- });
-
- // Handle terminal resize
- process.stdout.on('resize', () => {
- const frame = cipher.encrypt(
- encodeResizeFrame(process.stdout.columns, process.stdout.rows),
- );
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') }));
- }
- });
-
- // Send initial resize
- if (process.stdout.columns && process.stdout.rows) {
- const frame = cipher.encrypt(
- encodeResizeFrame(process.stdout.columns, process.stdout.rows),
- );
- ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') }));
- }
- }
-
- // Keepalive ping
- const pingInterval = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- const frame = cipher.encrypt(encodePingFrame());
- ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(frame).toString('base64') }));
- }
- }, 30_000);
-
- // Receive frames from agent
- ws.addEventListener('message', (event: MessageEvent) => {
- const raw = typeof event.data === 'string' ? event.data : String(event.data);
- let msg;
- try {
- msg = JSON.parse(raw);
- } catch {
- return;
- }
-
- if (msg.type !== 'data' || !msg.payload) return;
-
- try {
- const decrypted = cipher.decrypt(Buffer.from(msg.payload, 'base64'));
- const { type, payload } = parseFrame(decrypted);
-
- switch (type) {
- case FrameType.DATA:
- process.stdout.write(payload);
- break;
- case FrameType.EXIT: {
- exitCode = parseExit(payload).code;
- cleanup();
- break;
- }
- case FrameType.PONG:
- break;
- }
- } catch (err) {
- console.error(`\nFrame error: ${(err as Error).message}`);
- }
- });
-
- ws.addEventListener('close', () => {
- cleanup();
- });
-
- function cleanup() {
- clearInterval(pingInterval);
- cipher.close();
- if (process.stdin.isTTY) {
- process.stdin.setRawMode(false);
- }
- ws.close();
- const duration = Math.round((Date.now() - startTime) / 1000);
- console.error(`\nSession closed (exit code ${exitCode}, duration ${duration}s).`);
- resolve(exitCode);
- }
- });
-}
diff --git a/packages/cli/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts
deleted file mode 100644
index b40e794..0000000
--- a/packages/cli/src/shell-handshake.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
-import { randomBytes } from '@noble/ciphers/utils.js';
-import { sha256 } from '@noble/hashes/sha2.js';
-import {
- generateEphemeralKeyPair,
- computeSharedSecret,
- deriveShellSessionKey,
- verifyMessage,
-} from '@authmesh/core';
-import type { AllowList } from '@authmesh/keystore';
-
-const SHELL_SIG_DOMAIN = 'amesh-shell-v1';
-
-interface PeerIdentity {
- publicKey: string; // base64
- deviceId: string;
- friendlyName: string;
- timestamp: string;
- selfSig: string; // base64
-}
-
-export interface ShellHandshakeResult {
- sessionKey: Uint8Array;
- peerDeviceId: string;
- peerFriendlyName: string;
- peerPublicKey: Uint8Array;
-}
-
-function send(ws: WebSocket, msg: object): void {
- ws.send(JSON.stringify(msg));
-}
-
-function createMessageReader(ws: WebSocket) {
- const queue: Record[] = [];
- let waiter: {
- resolve: (msg: Record) => void;
- reject: (err: Error) => void;
- } | null = null;
- let disposed = false;
-
- const handler = (event: MessageEvent) => {
- if (disposed) return;
- const raw = typeof event.data === 'string' ? event.data : String(event.data);
- const msg = JSON.parse(raw);
- if (waiter) {
- const w = waiter;
- waiter = null;
- w.resolve(msg);
- } else {
- queue.push(msg);
- }
- };
-
- ws.addEventListener('message', handler);
-
- return {
- read(timeoutMs = 30_000): Promise> {
- if (queue.length > 0) return Promise.resolve(queue.shift()!);
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- waiter = null;
- reject(new Error('Timeout waiting for message'));
- }, timeoutMs);
- waiter = {
- resolve: (msg) => {
- clearTimeout(timer);
- resolve(msg);
- },
- reject: (err) => {
- clearTimeout(timer);
- reject(err);
- },
- };
- });
- },
- /**
- * Remove the message listener and drain any pending waiter. Must be
- * called once the caller is done reading messages, otherwise the handler
- * keeps appending to `queue` on every incoming frame (M4 memory leak).
- */
- dispose() {
- if (disposed) return;
- disposed = true;
- ws.removeEventListener('message', handler);
- queue.length = 0;
- if (waiter) {
- const w = waiter;
- waiter = null;
- w.reject(new Error('reader_disposed'));
- }
- },
- };
-}
-
-function encrypt(sessionKey: Uint8Array, plaintext: Uint8Array): string {
- const nonce = randomBytes(12);
- const cipher = chacha20poly1305(sessionKey, nonce);
- const ciphertext = cipher.encrypt(plaintext);
- const combined = new Uint8Array(12 + ciphertext.length);
- combined.set(nonce, 0);
- combined.set(ciphertext, 12);
- return Buffer.from(combined).toString('base64');
-}
-
-function decrypt(sessionKey: Uint8Array, encoded: string): Uint8Array {
- const combined = Buffer.from(encoded, 'base64');
- const nonce = combined.subarray(0, 12);
- const ciphertext = combined.subarray(12);
- const cipher = chacha20poly1305(sessionKey, nonce);
- return cipher.decrypt(ciphertext);
-}
-
-/**
- * Canonical message bound to the current ECDH handshake.
- *
- * The signature covers (domain, peer identity fields, AND both ephemeral
- * public keys as observed on the wire). This prevents a MITM relay that holds
- * an ECDH secret on each leg from replaying a peer's encrypted selfSig
- * envelope across the two legs — the ephemeral keys differ per leg, so a
- * signature produced for one leg won't verify on the other.
- *
- * Format:
- * "amesh-shell-v1\n" || pubB64 || "\n" || deviceId || "\n" || friendlyName ||
- * "\n" || timestamp || "\n" || sha256(signerEph || verifierEph)
- */
-export function buildShellSigMessage(params: {
- publicKey: string;
- deviceId: string;
- friendlyName: string;
- timestamp: string;
- signerEphPub: Uint8Array;
- verifierEphPub: Uint8Array;
-}): Uint8Array {
- const transcript = new Uint8Array(params.signerEphPub.length + params.verifierEphPub.length);
- transcript.set(params.signerEphPub, 0);
- transcript.set(params.verifierEphPub, params.signerEphPub.length);
- const transcriptHash = sha256(transcript);
- const header = new TextEncoder().encode(
- `${SHELL_SIG_DOMAIN}\n${params.publicKey}\n${params.deviceId}\n${params.friendlyName}\n${params.timestamp}\n`,
- );
- const out = new Uint8Array(header.length + transcriptHash.length);
- out.set(header, 0);
- out.set(transcriptHash, header.length);
- return out;
-}
-
-function verifySelfSig(
- peer: PeerIdentity,
- signerEphPub: Uint8Array,
- verifierEphPub: Uint8Array,
-): boolean {
- const publicKey = new Uint8Array(Buffer.from(peer.publicKey, 'base64'));
- const message = buildShellSigMessage({
- publicKey: peer.publicKey,
- deviceId: peer.deviceId,
- friendlyName: peer.friendlyName,
- timestamp: peer.timestamp,
- signerEphPub,
- verifierEphPub,
- });
- const sig = new Uint8Array(Buffer.from(peer.selfSig, 'base64'));
- return verifyMessage(sig, message, publicKey);
-}
-
-const MAX_TIMESTAMP_SKEW_MS = 60_000; // 60 seconds
-
-function validateTimestamp(timestamp: string): void {
- const ts = new Date(timestamp).getTime();
- if (isNaN(ts)) throw new Error('Invalid timestamp in peer identity');
- if (Math.abs(Date.now() - ts) > MAX_TIMESTAMP_SKEW_MS) {
- throw new Error('Peer identity timestamp out of range');
- }
-}
-
-/**
- * Run the TARGET (agent) side of the shell handshake.
- * No OTC, no SAS — trust is pre-established via allow list.
- * Returns the session key for encrypted shell I/O.
- */
-export async function runAgentShellHandshake(
- ws: WebSocket,
- reader: ReturnType,
- myDeviceId: string,
- myPublicKeyBase64: string,
- myFriendlyName: string,
- signFn: (message: Uint8Array) => Promise,
- allowList: AllowList,
-): Promise {
- // Step 1: ECDH ephemeral exchange
- const ephemeral = generateEphemeralKeyPair();
- send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') });
-
- const peerEphMsg = await reader.read();
- const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64'));
-
- // Step 2: Derive session key (BOUND to device IDs — separate domain from pairing)
- const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub);
-
- // Step 3: Receive controller identity (encrypted with temp key for initial exchange)
- const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp');
- const encPeerIdentity = await reader.read();
- const peerIdentity = JSON.parse(
- new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)),
- ) as PeerIdentity;
-
- // The peer's selfSig must be bound to the ephemeral keys WE observed on the
- // wire: peerEphPub was the one they claim they sent, ephemeral.publicKey was
- // the one we sent (which they should have received). A MITM that substitutes
- // ephemeral keys cannot replay a signature captured from the other leg.
- if (!verifySelfSig(peerIdentity, peerEphPub, ephemeral.publicKey)) {
- throw new Error('selfSig verification failed');
- }
- validateTimestamp(peerIdentity.timestamp); // H1 fix
-
- // Step 4: Authorization — check allow list
- const device = await allowList.findByPublicKey(peerIdentity.publicKey);
- if (!device) throw new Error('Device not in allow list');
- if (device.role !== 'controller') throw new Error('Device is not a controller');
- if (!device.permissions?.shell) throw new Error('Shell access not granted for this device');
-
- // Step 5: Send our identity, signed over the current ECDH transcript.
- const timestamp = new Date().toISOString();
- const selfSig = await signFn(
- buildShellSigMessage({
- publicKey: myPublicKeyBase64,
- deviceId: myDeviceId,
- friendlyName: myFriendlyName,
- timestamp,
- signerEphPub: ephemeral.publicKey,
- verifierEphPub: peerEphPub,
- }),
- );
- const myIdentity: PeerIdentity = {
- publicKey: myPublicKeyBase64,
- deviceId: myDeviceId,
- friendlyName: myFriendlyName,
- timestamp,
- selfSig: Buffer.from(selfSig).toString('base64'),
- };
- send(ws, {
- type: 'data',
- payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))),
- });
-
- // Step 6: Derive final session key bound to actual device IDs
- const sessionKey = deriveShellSessionKey(sharedSecret, myDeviceId, peerIdentity.deviceId);
-
- // H2 fix — zero key material
- ephemeral.privateKey.fill(0);
- sharedSecret.fill(0);
- tempKey.fill(0);
-
- return {
- sessionKey,
- peerDeviceId: peerIdentity.deviceId,
- peerFriendlyName: peerIdentity.friendlyName,
- peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')),
- };
-}
-
-/**
- * Run the CONTROLLER side of the shell handshake.
- */
-export async function runControllerShellHandshake(
- ws: WebSocket,
- reader: ReturnType,
- myDeviceId: string,
- myPublicKeyBase64: string,
- myFriendlyName: string,
- signFn: (message: Uint8Array) => Promise,
- allowList: AllowList,
-): Promise {
- // Step 1: Receive agent ephemeral key
- const peerEphMsg = await reader.read();
- const peerEphPub = new Uint8Array(Buffer.from(peerEphMsg.payload as string, 'base64'));
-
- // Send our ephemeral key
- const ephemeral = generateEphemeralKeyPair();
- send(ws, { type: 'data', payload: Buffer.from(ephemeral.publicKey).toString('base64') });
-
- // Step 2: Derive shared secret
- const sharedSecret = computeSharedSecret(ephemeral.privateKey, peerEphPub);
- const tempKey = deriveShellSessionKey(sharedSecret, 'temp', 'temp');
-
- // Step 3: Send our identity, signed over the current ECDH transcript.
- const timestamp = new Date().toISOString();
- const selfSig = await signFn(
- buildShellSigMessage({
- publicKey: myPublicKeyBase64,
- deviceId: myDeviceId,
- friendlyName: myFriendlyName,
- timestamp,
- signerEphPub: ephemeral.publicKey,
- verifierEphPub: peerEphPub,
- }),
- );
- const myIdentity: PeerIdentity = {
- publicKey: myPublicKeyBase64,
- deviceId: myDeviceId,
- friendlyName: myFriendlyName,
- timestamp,
- selfSig: Buffer.from(selfSig).toString('base64'),
- };
- send(ws, {
- type: 'data',
- payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))),
- });
-
- // Step 4: Receive agent identity
- const encPeerIdentity = await reader.read();
- const peerIdentity = JSON.parse(
- new TextDecoder().decode(decrypt(tempKey, encPeerIdentity.payload as string)),
- ) as PeerIdentity;
-
- // Agent must have signed over the ephemeral keys WE observed: peerEphPub is
- // what they put on the wire (their ephemeral), ephemeral.publicKey is what
- // we sent (which they should have received as verifierEph on their side).
- if (!verifySelfSig(peerIdentity, peerEphPub, ephemeral.publicKey)) {
- throw new Error('selfSig verification failed');
- }
- validateTimestamp(peerIdentity.timestamp); // H1 fix
-
- // Step 5: Verify agent is in our allow list
- const device = await allowList.findByPublicKey(peerIdentity.publicKey);
- if (!device) throw new Error('Device not in allow list');
- if (device.role !== 'target') throw new Error('Device is not a target');
-
- // Step 6: Derive final session key bound to actual device IDs
- const sessionKey = deriveShellSessionKey(sharedSecret, peerIdentity.deviceId, myDeviceId);
-
- // H2 fix — zero key material
- ephemeral.privateKey.fill(0);
- sharedSecret.fill(0);
- tempKey.fill(0);
-
- return {
- sessionKey,
- peerDeviceId: peerIdentity.deviceId,
- peerFriendlyName: peerIdentity.friendlyName,
- peerPublicKey: new Uint8Array(Buffer.from(peerIdentity.publicKey, 'base64')),
- };
-}
-
-export { createMessageReader, send };
diff --git a/packages/core/src/__tests__/ecdh.test.ts b/packages/core/src/__tests__/ecdh.test.ts
index 66e14a2..f26f900 100644
--- a/packages/core/src/__tests__/ecdh.test.ts
+++ b/packages/core/src/__tests__/ecdh.test.ts
@@ -120,46 +120,6 @@ describe('deriveSessionKey', () => {
});
});
-describe('deriveShellSessionKey', () => {
- it('produces different keys than deriveSessionKey', async () => {
- const { deriveShellSessionKey } = await import('../ecdh.js');
- const a = generateEphemeralKeyPair();
- const b = generateEphemeralKeyPair();
- const shared = computeSharedSecret(a.privateKey, b.publicKey);
-
- const pairingKey = deriveSessionKey(shared);
- const shellKey = deriveShellSessionKey(shared, 'am_target', 'am_controller');
-
- expect(pairingKey).not.toEqual(shellKey);
- });
-
- it('produces different keys for different device ID pairs', async () => {
- const { deriveShellSessionKey } = await import('../ecdh.js');
- const a = generateEphemeralKeyPair();
- const b = generateEphemeralKeyPair();
- const shared = computeSharedSecret(a.privateKey, b.publicKey);
-
- const key1 = deriveShellSessionKey(shared, 'am_target1', 'am_controller1');
- const key2 = deriveShellSessionKey(shared, 'am_target2', 'am_controller1');
-
- expect(key1).not.toEqual(key2);
- });
-
- it('both sides derive the same shell session key', async () => {
- const { deriveShellSessionKey } = await import('../ecdh.js');
- const a = generateEphemeralKeyPair();
- const b = generateEphemeralKeyPair();
-
- const sharedAB = computeSharedSecret(a.privateKey, b.publicKey);
- const sharedBA = computeSharedSecret(b.privateKey, a.publicKey);
-
- const keyA = deriveShellSessionKey(sharedAB, 'am_target', 'am_ctrl');
- const keyB = deriveShellSessionKey(sharedBA, 'am_target', 'am_ctrl');
-
- expect(keyA).toEqual(keyB);
- });
-});
-
describe('full ECDH handshake simulation', () => {
it('target and controller derive matching session keys', () => {
// Simulate the handshake from docs/protocol-spec.md Step 5-6
diff --git a/packages/core/src/ecdh.ts b/packages/core/src/ecdh.ts
index 2bb4f0a..ba063fe 100644
--- a/packages/core/src/ecdh.ts
+++ b/packages/core/src/ecdh.ts
@@ -35,16 +35,3 @@ export function computeSharedSecret(
export function deriveSessionKey(sharedSecret: Uint8Array): Uint8Array {
return deriveKey(sharedSecret, HANDSHAKE_SALT, 'session-key', 32);
}
-
-/**
- * Derive a shell session key from ECDH shared secret, bound to both device IDs.
- * Uses a separate HKDF domain ('amesh-shell-v1') to ensure cryptographic
- * separation from pairing sessions.
- */
-export function deriveShellSessionKey(
- sharedSecret: Uint8Array,
- targetDeviceId: string,
- controllerDeviceId: string,
-): Uint8Array {
- return deriveKey(sharedSecret, 'amesh-shell-v1', `${targetDeviceId}:${controllerDeviceId}`, 32);
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f84a5f3..cf8fcf9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -4,9 +4,4 @@ export { InMemoryNonceStore } from './nonce.js';
export type { NonceStore } from './nonce.js';
export { computeHmac, verifyHmac } from './hmac.js';
export { deriveKey } from './hkdf.js';
-export {
- generateEphemeralKeyPair,
- computeSharedSecret,
- deriveSessionKey,
- deriveShellSessionKey,
-} from './ecdh.js';
+export { generateEphemeralKeyPair, computeSharedSecret, deriveSessionKey } from './ecdh.js';
diff --git a/packages/keystore/src/allow-list.ts b/packages/keystore/src/allow-list.ts
index ec8c8b9..52f8724 100644
--- a/packages/keystore/src/allow-list.ts
+++ b/packages/keystore/src/allow-list.ts
@@ -2,10 +2,6 @@ import { computeHmac, verifyHmac, deriveKey } from '@authmesh/core';
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
-export interface DevicePermissions {
- shell?: boolean;
-}
-
export interface AllowListDevice {
deviceId: string;
publicKey: string; // base64 compressed P-256
@@ -13,7 +9,6 @@ export interface AllowListDevice {
addedAt: string; // ISO 8601
addedBy: 'handshake' | 'manual';
role: 'controller' | 'target';
- permissions?: DevicePermissions;
}
export interface AllowListData {
@@ -224,24 +219,6 @@ export class AllowList {
return data;
}
- /**
- * Update permissions for a device. Reseals the allow list.
- */
- async updatePermissions(
- deviceId: string,
- permissions: DevicePermissions,
- ): Promise {
- const data = await this.read();
- const device = data.devices.find((d) => d.deviceId === deviceId);
- if (!device) {
- throw new Error(`Device ${deviceId} not found in allow list`);
- }
- device.permissions = { ...device.permissions, ...permissions };
- data.updatedAt = new Date().toISOString();
- await this.writeSealed(data);
- return data;
- }
-
/**
* Verify HMAC integrity using a specific canonicalization function.
* Returns true on match, false on mismatch. Used by `read()` to try the
diff --git a/packages/keystore/src/index.ts b/packages/keystore/src/index.ts
index f5b80d9..7916176 100644
--- a/packages/keystore/src/index.ts
+++ b/packages/keystore/src/index.ts
@@ -1,5 +1,5 @@
export type { KeyStore } from './interface.js';
export { AllowList } from './allow-list.js';
-export type { AllowListData, AllowListDevice, DevicePermissions } from './allow-list.js';
+export type { AllowListData, AllowListDevice } from './allow-list.js';
export { detectAndCreate, createForBackend, BACKEND_LABELS, generatePassphrase } from './detect.js';
export type { StorageBackend, DetectionResult } from './detect.js';
diff --git a/packages/relay/src/agent-store.ts b/packages/relay/src/agent-store.ts
deleted file mode 100644
index 9256a09..0000000
--- a/packages/relay/src/agent-store.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { ServerWebSocket } from 'bun';
-import type { WebSocketData } from './server.js';
-
-interface AgentEntry {
- socket: ServerWebSocket;
- publicKey: string;
- registeredAt: number;
- lastPing: number;
-}
-
-/**
- * Constant-time string comparison for the agent registry (L3).
- *
- * Public keys are not secret, but the relay's shell routing pipeline uses
- * the `(deviceId, publicKey)` tuple as the only gate between an enumerating
- * attacker and "this pair is currently registered" side-channel info. A
- * timing-safe compare removes one axis of the oracle; the uniform response
- * from handleShell (C3) removes the other.
- */
-function constantTimeStringEqual(a: string, b: string): boolean {
- if (a.length !== b.length) return false;
- let diff = 0;
- for (let i = 0; i < a.length; i++) {
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
- }
- return diff === 0;
-}
-
-/**
- * Tracks connected agent daemons by device ID.
- * Agents register with their public key; controllers must provide
- * the matching public key to route a shell request.
- */
-export class AgentStore {
- private readonly agents = new Map();
- private readonly cleanupTimer: ReturnType;
- private readonly heartbeatTimeoutMs: number;
-
- constructor(heartbeatTimeoutMs = 90_000) {
- this.heartbeatTimeoutMs = heartbeatTimeoutMs;
- this.cleanupTimer = setInterval(() => this.purgeStale(), 30_000);
- this.cleanupTimer.unref();
- }
-
- register(deviceId: string, publicKey: string, socket: ServerWebSocket): boolean {
- const existing = this.agents.get(deviceId);
- if (existing) {
- // Same public key = reconnect (allow), different = squatting attempt (reject)
- if (!constantTimeStringEqual(existing.publicKey, publicKey)) return false;
- // Close old connection if still open
- if (existing.socket.readyState === WebSocket.OPEN) {
- existing.socket.close(1000, 'replaced');
- }
- }
- this.agents.set(deviceId, {
- socket,
- publicKey,
- registeredAt: Date.now(),
- lastPing: Date.now(),
- });
- return true;
- }
-
- /**
- * Look up an agent by device ID and verify the public key matches.
- * Returns the agent's WebSocket if matched, undefined otherwise.
- */
- matchAndGet(
- deviceId: string,
- expectedPublicKey: string,
- ): ServerWebSocket | undefined {
- const entry = this.agents.get(deviceId);
- if (!entry) return undefined;
- if (!constantTimeStringEqual(entry.publicKey, expectedPublicKey)) return undefined;
- if (entry.socket.readyState !== WebSocket.OPEN) {
- this.agents.delete(deviceId);
- return undefined;
- }
- return entry.socket;
- }
-
- recordPing(socket: ServerWebSocket): void {
- for (const [, entry] of this.agents) {
- if (entry.socket === socket) {
- entry.lastPing = Date.now();
- return;
- }
- }
- }
-
- removeBySocket(socket: ServerWebSocket): void {
- for (const [deviceId, entry] of this.agents) {
- if (entry.socket === socket) {
- this.agents.delete(deviceId);
- return;
- }
- }
- }
-
- get size(): number {
- return this.agents.size;
- }
-
- private purgeStale(): void {
- const now = Date.now();
- for (const [deviceId, entry] of this.agents) {
- if (
- now - entry.lastPing > this.heartbeatTimeoutMs ||
- entry.socket.readyState !== WebSocket.OPEN
- ) {
- this.agents.delete(deviceId);
- }
- }
- }
-
- destroy(): void {
- clearInterval(this.cleanupTimer);
- this.agents.clear();
- }
-}
diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts
index 3d90c03..00d7c0f 100644
--- a/packages/relay/src/index.ts
+++ b/packages/relay/src/index.ts
@@ -1,5 +1,4 @@
export { createRelayServer } from './server.js';
export type { WebSocketData } from './server.js';
export { SessionStore } from './session.js';
-export { AgentStore } from './agent-store.js';
export { RateLimiter } from './rate-limit.js';
diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts
index 47ff9ff..b1d38db 100644
--- a/packages/relay/src/server.ts
+++ b/packages/relay/src/server.ts
@@ -1,8 +1,6 @@
import type { ServerWebSocket } from 'bun';
-import { verifyMessage } from '@authmesh/core';
import { SessionStore, SESSION_MAX_BYTES } from './session.js';
import { RateLimiter, OTCAttemptTracker } from './rate-limit.js';
-import { AgentStore } from './agent-store.js';
interface RelayMessage {
type:
@@ -10,10 +8,6 @@ interface RelayMessage {
| 'connect'
| 'data'
| 'done'
- | 'ping'
- | 'agent'
- | 'agent_challenge_response'
- | 'shell'
| 'bootstrap_watch'
| 'bootstrap_init'
| 'bootstrap_ack'
@@ -23,19 +17,12 @@ interface RelayMessage {
jti?: string;
token?: string;
targetPubKey?: string;
- deviceId?: string;
- publicKey?: string;
- timestamp?: string;
- sig?: string;
- targetDeviceId?: string;
- targetPublicKey?: string;
[key: string]: unknown;
}
export interface WebSocketData {
otc?: string;
btJti?: string;
- agentDeviceId?: string;
ip: string;
/** Set when open() rejected the socket for exceeding MAX_CONNECTIONS. */
rejected?: boolean;
@@ -135,9 +122,7 @@ export function createRelayServer(opts?: {
return envVal === '1' || envVal === 'true' || envVal === 'yes';
})();
const sessions = new SessionStore(opts?.maxSessions);
- const agentStore = new AgentStore();
const rateLimiter = new RateLimiter(5, 60_000);
- const shellRateLimiter = new RateLimiter(2, 60_000);
// Dedicated limiter for bootstrap_watch (M3). 10 per minute per IP is
// generous for legitimate fleet provisioning and tight enough to stop an
// attacker from brute-forcing jti claims. Kept separate from the OTC
@@ -397,105 +382,7 @@ export function createRelayServer(opts?: {
bootstrapWatchers.delete(jti);
}
- // Pending agent challenges: ws → { deviceId, publicKey, challenge }
- const pendingChallenges = new Map<
- ServerWebSocket,
- { deviceId: string; publicKey: string; challenge: string }
- >();
-
- // Shell: agent registration step 1 — issue challenge
- function handleAgent(ws: ServerWebSocket, msg: RelayMessage) {
- if (!msg.deviceId || !msg.publicKey) {
- ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' }));
- return;
- }
- // Generate a random challenge nonce
- const challenge = crypto.randomUUID();
- pendingChallenges.set(ws, { deviceId: msg.deviceId, publicKey: msg.publicKey, challenge });
- ws.send(JSON.stringify({ type: 'agent_challenge', challenge }));
- }
-
- // Shell: agent registration step 2 — verify challenge response
- function handleAgentChallengeResponse(ws: ServerWebSocket, msg: RelayMessage) {
- const pending = pendingChallenges.get(ws);
- if (!pending) {
- ws.send(JSON.stringify({ type: 'error', code: 'no_pending_challenge' }));
- return;
- }
- pendingChallenges.delete(ws);
-
- if (!msg.sig) {
- ws.send(JSON.stringify({ type: 'error', code: 'missing_signature' }));
- return;
- }
-
- // Verify the agent signed the challenge with the claimed private key
- const publicKey = new Uint8Array(Buffer.from(pending.publicKey, 'base64'));
- const message = new TextEncoder().encode(pending.challenge);
- const signature = new Uint8Array(Buffer.from(msg.sig as string, 'base64url'));
-
- if (!verifyMessage(signature, message, publicKey)) {
- ws.send(JSON.stringify({ type: 'error', code: 'invalid_signature' }));
- return;
- }
-
- // Signature valid — agent proves it holds the private key
- const ok = agentStore.register(pending.deviceId, pending.publicKey, ws);
- if (!ok) {
- ws.send(JSON.stringify({ type: 'error', code: 'device_id_conflict' }));
- return;
- }
- ws.data.agentDeviceId = pending.deviceId;
- ws.send(JSON.stringify({ type: 'agent_registered' }));
- }
-
- // Shell: controller requests shell to a target agent (C3 fix — uniform responses)
- function handleShell(ws: ServerWebSocket, msg: RelayMessage) {
- if (!msg.targetDeviceId || !msg.targetPublicKey) {
- ws.send(JSON.stringify({ type: 'error', code: 'missing_fields' }));
- return;
- }
- if (!shellRateLimiter.check(ws.data.ip)) {
- ws.send(JSON.stringify({ type: 'error', code: 'rate_limited' }));
- return;
- }
- const agentWs = agentStore.matchAndGet(msg.targetDeviceId, msg.targetPublicKey);
- if (!agentWs) {
- // M6 fix — only count failures against rate limit
- shellRateLimiter.recordFailure(ws.data.ip);
- // Uniform response — don't reveal whether agent exists (C3 fix)
- ws.send(JSON.stringify({ type: 'peer_found' }));
- return;
- }
- // Create a pairing-like session for the shell (reuse existing data forwarding)
- const shellOtc = `shell_${Date.now()}_${crypto.randomUUID()}`;
- try {
- sessions.create(shellOtc, agentWs, 600); // 10 min TTL for shell sessions
- sessions.get(shellOtc)!.controller = ws;
- ws.data.otc = shellOtc;
- agentWs.data.otc = shellOtc;
- agentWs.send(JSON.stringify({ type: 'peer_found' }));
- ws.send(JSON.stringify({ type: 'peer_found' }));
- } catch {
- ws.send(JSON.stringify({ type: 'peer_found' }));
- }
- }
-
- // Shell: agent heartbeat
- function handlePing(ws: ServerWebSocket) {
- agentStore.recordPing(ws);
- ws.send(JSON.stringify({ type: 'pong' }));
- }
-
function cleanupSocket(ws: ServerWebSocket) {
- // Clean up pending challenges
- pendingChallenges.delete(ws);
-
- // Clean up agent registration
- if (ws.data.agentDeviceId) {
- agentStore.removeBySocket(ws);
- }
-
// Clean up pairing sessions
const otc = ws.data.otc;
if (otc) {
@@ -542,7 +429,6 @@ export function createRelayServer(opts?: {
return Response.json({
status: 'ok',
sessions: sessions.size,
- agents: agentStore.size,
});
}
@@ -606,18 +492,6 @@ export function createRelayServer(opts?: {
case 'bootstrap_reject':
handleBootstrapResponse(ws, msg);
break;
- case 'agent':
- handleAgent(ws, msg);
- break;
- case 'agent_challenge_response':
- handleAgentChallengeResponse(ws, msg);
- break;
- case 'shell':
- handleShell(ws, msg);
- break;
- case 'ping':
- handlePing(ws);
- break;
default:
ws.send(JSON.stringify({ type: 'error', code: 'unknown_type' }));
}
@@ -639,9 +513,7 @@ export function createRelayServer(opts?: {
stop() {
clearInterval(bootstrapCleanupTimer);
sessions.destroy();
- agentStore.destroy();
rateLimiter.destroy();
- shellRateLimiter.destroy();
bootstrapWatchRateLimiter.destroy();
otcAttempts.destroy();
server?.stop();
diff --git a/packages/relay/src/session.ts b/packages/relay/src/session.ts
index 23c49a5..b424a79 100644
--- a/packages/relay/src/session.ts
+++ b/packages/relay/src/session.ts
@@ -2,9 +2,8 @@ import type { ServerWebSocket } from 'bun';
import type { WebSocketData } from './server.js';
/** Maximum bytes forwarded per session (5 MB). Prevents relay cost abuse.
- * 5 MB ≈ 100,000 lines of terminal output — generous for interactive shell,
- * tight enough to prevent bulk data streaming. Self-hosted relays can
- * override by setting a higher value. */
+ * Tight enough to prevent bulk data streaming through the relay. Self-hosted
+ * relays can override by setting a higher value. */
export const SESSION_MAX_BYTES = 5 * 1024 * 1024;
export interface PairingSession {
diff --git a/packaging/build-bun.mjs b/packaging/build-bun.mjs
index 7680b83..9c210d0 100644
--- a/packaging/build-bun.mjs
+++ b/packaging/build-bun.mjs
@@ -5,7 +5,7 @@
* bun packaging/build-bun.mjs [--target bun-darwin-arm64]
*
* Compiles a standalone binary via `bun build --compile`:
- * - packages/cli/src/sea.ts → dist/amesh (unified CLI + agent)
+ * - packages/cli/src/sea.ts → dist/amesh
*
* On macOS targets, also compiles the Swift Secure Enclave helper.
* Default target: current platform.
diff --git a/smoke-tests/lib/config.sh b/smoke-tests/lib/config.sh
index c06a3f3..6a2f306 100755
--- a/smoke-tests/lib/config.sh
+++ b/smoke-tests/lib/config.sh
@@ -4,7 +4,7 @@
GITHUB_REPO="ameshdev/amesh"
NPM_SCOPE="@authmesh"
-NPM_PACKAGES=(core keystore sdk cli relay agent)
+NPM_PACKAGES=(core keystore sdk cli relay)
RELAY_PORT=3001
# Set by the runner at invocation time
diff --git a/smoke-tests/tests/01-relay-image-boot.sh b/smoke-tests/tests/01-relay-image-boot.sh
index 67fbd3d..4e4df11 100755
--- a/smoke-tests/tests/01-relay-image-boot.sh
+++ b/smoke-tests/tests/01-relay-image-boot.sh
@@ -10,6 +10,4 @@ response=$(curl -sf "$RELAY_HEALTH" || true)
assert_not_empty "$response" "Health endpoint returned empty"
assert_contains "$response" '"status":"ok"' "Health should contain status:ok"
assert_contains "$response" '"sessions"' "Health should contain sessions field"
-assert_contains "$response" '"agents"' "Health should contain agents field"
-
-pass "Relay /health returns {status:'ok', sessions, agents}"
+pass "Relay /health returns {status:'ok', sessions}"
diff --git a/smoke-tests/tests/08-binary-runs.sh b/smoke-tests/tests/08-binary-runs.sh
index caed062..e195cca 100755
--- a/smoke-tests/tests/08-binary-runs.sh
+++ b/smoke-tests/tests/08-binary-runs.sh
@@ -36,7 +36,6 @@ assert_eq "$version_line" "amesh/${VERSION}" "--version mismatch"
help_output=$(echo "$output" | sed -n '/^HELP_START$/,/^HELP_END$/p')
assert_contains "$help_output" "init" "--help should list init command"
-assert_contains "$help_output" "shell" "--help should list shell command"
assert_contains "$help_output" "invite" "--help should list invite command"
-pass "amesh/$VERSION — --version matches, --help lists 8 commands"
+pass "amesh/$VERSION — --version matches, --help lists commands"
From a86beb9ecc8d205c58163ffae03bb0d08cc24fff Mon Sep 17 00:00:00 2001
From: YairEtzion
Date: Sun, 12 Apr 2026 16:06:26 +0300
Subject: [PATCH 2/2] style: format relay server.ts
---
packages/relay/src/server.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts
index b1d38db..b5a3ee7 100644
--- a/packages/relay/src/server.ts
+++ b/packages/relay/src/server.ts
@@ -247,10 +247,14 @@ export function createRelayServer(opts?: {
sessions.remove(otc);
try {
session.target.close();
- } catch { /* ignore */ }
+ } catch {
+ /* ignore */
+ }
try {
session.controller?.close();
- } catch { /* ignore */ }
+ } catch {
+ /* ignore */
+ }
return;
}