From 48ef8e4a830cb6f624cf0084907987b87b1dc44e Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Sat, 4 Apr 2026 08:48:24 +0300 Subject: [PATCH 1/5] dx: auto-generate passphrase, detection verbosity, identity info in list - Drop --passphrase flag entirely; encrypted-file backend auto-generates a 256-bit random passphrase stored in identity.json (0o600) - detectAndCreate() shows progress via onProgress callback: each tier reports its result so users see why a backend was chosen - amesh list now shows "This device" section with device ID, name, backend, and created date - Thread identity.passphrase through all runtime paths (context.ts, agent.ts, shell-client.ts, amesh.ts, bootstrap.ts) - Add keyAlias? and passphrase? to Identity interface, remove type casts - AUTH_MESH_PASSPHRASE env var kept as silent backward-compat fallback --- packages/agent/src/agent.ts | 3 +- packages/agent/src/commands/init.ts | 60 ++++++++++--------- packages/agent/src/commands/list.ts | 19 +++++- packages/agent/src/context.ts | 5 +- packages/agent/src/identity.ts | 3 + packages/agent/src/shell-client.ts | 3 +- packages/cli/src/commands/init.ts | 60 ++++++++++--------- packages/cli/src/commands/list.ts | 19 +++++- packages/cli/src/context.ts | 5 +- packages/cli/src/identity.ts | 3 + packages/cli/src/shell-client.ts | 3 +- .../keystore/src/__tests__/detect.test.ts | 20 ++++++- packages/keystore/src/detect.ts | 51 ++++++++-------- packages/sdk/src/amesh.ts | 5 +- packages/sdk/src/bootstrap.ts | 3 +- 15 files changed, 165 insertions(+), 97 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 41bf435..534537a 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -26,6 +26,7 @@ interface Identity { publicKey: string; friendlyName: string; storageBackend: string; + passphrase?: string; } function getAmeshDir(): string { @@ -53,7 +54,7 @@ export async function startAgent(opts: AgentOptions): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, join(ameshDir, 'keys'), - process.env.AUTH_MESH_PASSPHRASE, + identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, ); const keyAlias = identity.keyAlias ?? identity.deviceId; diff --git a/packages/agent/src/commands/init.ts b/packages/agent/src/commands/init.ts index 0c8038c..ff0fa32 100644 --- a/packages/agent/src/commands/init.ts +++ b/packages/agent/src/commands/init.ts @@ -1,6 +1,7 @@ import { Command, Flags } from '@oclif/core'; import { createForBackend, detectAndCreate } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; +import { randomBytes } from '@noble/ciphers/utils.js'; import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; import { getIdentityPath, getKeysDir } from '../paths.js'; import { rename } from 'node:fs/promises'; @@ -8,6 +9,13 @@ import { join } from 'node:path'; const deviceIdPlaceholder = 'am_init'; +const BACKEND_LABELS: Record = { + 'secure-enclave': 'Secure Enclave', + 'keychain': 'macOS Keychain', + 'tpm2': 'TPM 2.0', + 'encrypted-file': 'Encrypted file', +}; + export default class Init extends Command { static override description = 'Create a cryptographic identity for this device'; @@ -22,11 +30,6 @@ export default class Init extends Command { description: 'Force a specific storage backend', options: ['secure-enclave', 'keychain', 'tpm2', 'encrypted-file'], }), - passphrase: Flags.string({ - char: 'p', - description: 'Passphrase for encrypted-file backend (or set AUTH_MESH_PASSPHRASE)', - env: 'AUTH_MESH_PASSPHRASE', - }), force: Flags.boolean({ description: 'Overwrite existing identity', default: false, @@ -46,31 +49,30 @@ export default class Init extends Command { this.error('Identity already exists. Use --force to overwrite.'); } - // Validate passphrase requirement for encrypted-file backend - if (flags.backend === 'encrypted-file' && !flags.passphrase) { - this.error( - 'Encrypted-file backend requires a passphrase.\n' + - ' Use --passphrase or set AUTH_MESH_PASSPHRASE.', - ); - } - this.log(''); this.log('Generating P-256 keypair...'); const keysDir = getKeysDir(); let backend: StorageBackend; let keyStore; + let resolvedPassphrase: string | undefined; + let warning: string | undefined; if (flags.backend) { backend = flags.backend as StorageBackend; - keyStore = await createForBackend(backend, keysDir, flags.passphrase); + if (backend === 'encrypted-file') { + resolvedPassphrase = Buffer.from(randomBytes(32)).toString('hex'); + } + keyStore = await createForBackend(backend, keysDir, resolvedPassphrase); + this.log(` Using backend: ${BACKEND_LABELS[backend]}`); } else { - const result = await detectAndCreate(keysDir, flags.passphrase); + this.log(''); + this.log('Detecting key storage backend:'); + const result = await detectAndCreate(keysDir, (msg) => this.log(msg)); backend = result.backend; keyStore = result.keyStore; - if (result.warning) { - this.warn(result.warning); - } + resolvedPassphrase = result.passphrase; + warning = result.warning; } // Generate key and derive device ID from public key @@ -99,6 +101,7 @@ export default class Init extends Command { friendlyName: flags.name, createdAt: new Date().toISOString(), storageBackend: backend, + ...(resolvedPassphrase ? { passphrase: resolvedPassphrase } : {}), ...(flags['max-controllers'] > 1 ? { maxControllers: flags['max-controllers'] } : {}), }; @@ -109,19 +112,22 @@ export default class Init extends Command { const { unlink } = await import('node:fs/promises'); await unlink(getAllowListPath()).catch(() => {}); + this.log(''); this.log('Identity created.'); this.log(''); - this.log(` Device ID : ${deviceId}`); - this.log(` Public Key: ${identity.publicKey.slice(0, 20)}...`); - this.log(` Backend : ${backend}`); - if (backend === 'encrypted-file') { + this.log(` Device ID : ${deviceId}`); + this.log(` Public Key : ${identity.publicKey.slice(0, 20)}...`); + this.log(` Backend : ${BACKEND_LABELS[backend]}`); + this.log(` Friendly Name : ${flags.name}`); + + if (warning) { this.log(''); - this.warn( - 'Using file-based key storage. Keys are protected by filesystem permissions and a passphrase, not hardware.\n' + - ' For hardware-backed storage, use macOS or a Linux host with TPM 2.0.', - ); + this.warn(warning); } + this.log(''); - this.log('Run `amesh listen` on this machine, then `amesh invite` from your laptop.'); + this.log('Next steps:'); + this.log(' 1. Run `amesh listen` on this machine'); + this.log(' 2. Run `amesh invite` from your controller to pair'); } } diff --git a/packages/agent/src/commands/list.ts b/packages/agent/src/commands/list.ts index ee2c39f..4bc493c 100644 --- a/packages/agent/src/commands/list.ts +++ b/packages/agent/src/commands/list.ts @@ -1,8 +1,15 @@ import { Command } from '@oclif/core'; import { loadContext } from '../context.js'; +const BACKEND_LABELS: Record = { + 'secure-enclave': 'Secure Enclave', + 'keychain': 'macOS Keychain', + 'tpm2': 'TPM 2.0', + 'encrypted-file': 'Encrypted file', +}; + export default class List extends Command { - static override description = 'Show trusted devices in the allow list'; + static override description = 'Show this device and trusted devices in the allow list'; async run(): Promise { await this.parse(List); @@ -24,6 +31,14 @@ export default class List extends Command { } this.log(''); + this.log(' This device'); + this.log(' ' + '─'.repeat(55)); + this.log(` Device ID : ${identity.deviceId}`); + this.log(` Friendly Name : ${identity.friendlyName}`); + this.log(` Backend : ${BACKEND_LABELS[identity.storageBackend] ?? identity.storageBackend}`); + this.log(` Created : ${identity.createdAt.split('T')[0]}`); + this.log(''); + if (data.devices.length === 0) { this.log(' No trusted devices yet.'); this.log(' Run `amesh listen` to start pairing.'); @@ -39,7 +54,5 @@ export default class List extends Command { } this.log(''); - this.log(` Your identity: ${identity.deviceId} (${identity.friendlyName})`); - this.log(''); } } diff --git a/packages/agent/src/context.ts b/packages/agent/src/context.ts index d722220..89a459e 100644 --- a/packages/agent/src/context.ts +++ b/packages/agent/src/context.ts @@ -18,11 +18,10 @@ export async function loadContext(): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, getKeysDir(), - process.env.AUTH_MESH_PASSPHRASE, + identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, ); - // keyAlias: the name used in the keystore. Defaults to deviceId for backwards compat. - const keyAlias = (identity as Identity & { keyAlias?: string }).keyAlias ?? identity.deviceId; + const keyAlias = identity.keyAlias ?? identity.deviceId; const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); diff --git a/packages/agent/src/identity.ts b/packages/agent/src/identity.ts index e8d9ead..665b8e7 100644 --- a/packages/agent/src/identity.ts +++ b/packages/agent/src/identity.ts @@ -5,11 +5,14 @@ import { dirname } from 'node:path'; export interface Identity { version: '2.0.0'; deviceId: string; + keyAlias?: string; // internal key name in the keystore (may differ from deviceId for keychain/TPM) publicKey: string; // base64 friendlyName: string; createdAt: string; // ISO 8601 storageBackend: string; maxControllers?: number; // default 1 — max controllers allowed on this target + /** SENSITIVE — auto-generated passphrase for encrypted-file backend. Never log or display. */ + passphrase?: string; } /** diff --git a/packages/agent/src/shell-client.ts b/packages/agent/src/shell-client.ts index 4400e55..c94d8a3 100644 --- a/packages/agent/src/shell-client.ts +++ b/packages/agent/src/shell-client.ts @@ -27,6 +27,7 @@ interface Identity { publicKey: string; friendlyName: string; storageBackend: string; + passphrase?: string; } function getAmeshDir(): string { @@ -41,7 +42,7 @@ export async function connectShell(opts: ShellOptions): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, join(ameshDir, 'keys'), - process.env.AUTH_MESH_PASSPHRASE, + identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, ); const keyAlias = identity.keyAlias ?? identity.deviceId; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0c8038c..ff0fa32 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,6 +1,7 @@ import { Command, Flags } from '@oclif/core'; import { createForBackend, detectAndCreate } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; +import { randomBytes } from '@noble/ciphers/utils.js'; import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; import { getIdentityPath, getKeysDir } from '../paths.js'; import { rename } from 'node:fs/promises'; @@ -8,6 +9,13 @@ import { join } from 'node:path'; const deviceIdPlaceholder = 'am_init'; +const BACKEND_LABELS: Record = { + 'secure-enclave': 'Secure Enclave', + 'keychain': 'macOS Keychain', + 'tpm2': 'TPM 2.0', + 'encrypted-file': 'Encrypted file', +}; + export default class Init extends Command { static override description = 'Create a cryptographic identity for this device'; @@ -22,11 +30,6 @@ export default class Init extends Command { description: 'Force a specific storage backend', options: ['secure-enclave', 'keychain', 'tpm2', 'encrypted-file'], }), - passphrase: Flags.string({ - char: 'p', - description: 'Passphrase for encrypted-file backend (or set AUTH_MESH_PASSPHRASE)', - env: 'AUTH_MESH_PASSPHRASE', - }), force: Flags.boolean({ description: 'Overwrite existing identity', default: false, @@ -46,31 +49,30 @@ export default class Init extends Command { this.error('Identity already exists. Use --force to overwrite.'); } - // Validate passphrase requirement for encrypted-file backend - if (flags.backend === 'encrypted-file' && !flags.passphrase) { - this.error( - 'Encrypted-file backend requires a passphrase.\n' + - ' Use --passphrase or set AUTH_MESH_PASSPHRASE.', - ); - } - this.log(''); this.log('Generating P-256 keypair...'); const keysDir = getKeysDir(); let backend: StorageBackend; let keyStore; + let resolvedPassphrase: string | undefined; + let warning: string | undefined; if (flags.backend) { backend = flags.backend as StorageBackend; - keyStore = await createForBackend(backend, keysDir, flags.passphrase); + if (backend === 'encrypted-file') { + resolvedPassphrase = Buffer.from(randomBytes(32)).toString('hex'); + } + keyStore = await createForBackend(backend, keysDir, resolvedPassphrase); + this.log(` Using backend: ${BACKEND_LABELS[backend]}`); } else { - const result = await detectAndCreate(keysDir, flags.passphrase); + this.log(''); + this.log('Detecting key storage backend:'); + const result = await detectAndCreate(keysDir, (msg) => this.log(msg)); backend = result.backend; keyStore = result.keyStore; - if (result.warning) { - this.warn(result.warning); - } + resolvedPassphrase = result.passphrase; + warning = result.warning; } // Generate key and derive device ID from public key @@ -99,6 +101,7 @@ export default class Init extends Command { friendlyName: flags.name, createdAt: new Date().toISOString(), storageBackend: backend, + ...(resolvedPassphrase ? { passphrase: resolvedPassphrase } : {}), ...(flags['max-controllers'] > 1 ? { maxControllers: flags['max-controllers'] } : {}), }; @@ -109,19 +112,22 @@ export default class Init extends Command { const { unlink } = await import('node:fs/promises'); await unlink(getAllowListPath()).catch(() => {}); + this.log(''); this.log('Identity created.'); this.log(''); - this.log(` Device ID : ${deviceId}`); - this.log(` Public Key: ${identity.publicKey.slice(0, 20)}...`); - this.log(` Backend : ${backend}`); - if (backend === 'encrypted-file') { + this.log(` Device ID : ${deviceId}`); + this.log(` Public Key : ${identity.publicKey.slice(0, 20)}...`); + this.log(` Backend : ${BACKEND_LABELS[backend]}`); + this.log(` Friendly Name : ${flags.name}`); + + if (warning) { this.log(''); - this.warn( - 'Using file-based key storage. Keys are protected by filesystem permissions and a passphrase, not hardware.\n' + - ' For hardware-backed storage, use macOS or a Linux host with TPM 2.0.', - ); + this.warn(warning); } + this.log(''); - this.log('Run `amesh listen` on this machine, then `amesh invite` from your laptop.'); + this.log('Next steps:'); + this.log(' 1. Run `amesh listen` on this machine'); + this.log(' 2. Run `amesh invite` from your controller to pair'); } } diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index ee2c39f..4bc493c 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,8 +1,15 @@ import { Command } from '@oclif/core'; import { loadContext } from '../context.js'; +const BACKEND_LABELS: Record = { + 'secure-enclave': 'Secure Enclave', + 'keychain': 'macOS Keychain', + 'tpm2': 'TPM 2.0', + 'encrypted-file': 'Encrypted file', +}; + export default class List extends Command { - static override description = 'Show trusted devices in the allow list'; + static override description = 'Show this device and trusted devices in the allow list'; async run(): Promise { await this.parse(List); @@ -24,6 +31,14 @@ export default class List extends Command { } this.log(''); + this.log(' This device'); + this.log(' ' + '─'.repeat(55)); + this.log(` Device ID : ${identity.deviceId}`); + this.log(` Friendly Name : ${identity.friendlyName}`); + this.log(` Backend : ${BACKEND_LABELS[identity.storageBackend] ?? identity.storageBackend}`); + this.log(` Created : ${identity.createdAt.split('T')[0]}`); + this.log(''); + if (data.devices.length === 0) { this.log(' No trusted devices yet.'); this.log(' Run `amesh listen` to start pairing.'); @@ -39,7 +54,5 @@ export default class List extends Command { } this.log(''); - this.log(` Your identity: ${identity.deviceId} (${identity.friendlyName})`); - this.log(''); } } diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index d722220..89a459e 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -18,11 +18,10 @@ export async function loadContext(): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, getKeysDir(), - process.env.AUTH_MESH_PASSPHRASE, + identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, ); - // keyAlias: the name used in the keystore. Defaults to deviceId for backwards compat. - const keyAlias = (identity as Identity & { keyAlias?: string }).keyAlias ?? identity.deviceId; + const keyAlias = identity.keyAlias ?? identity.deviceId; const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); diff --git a/packages/cli/src/identity.ts b/packages/cli/src/identity.ts index e8d9ead..665b8e7 100644 --- a/packages/cli/src/identity.ts +++ b/packages/cli/src/identity.ts @@ -5,11 +5,14 @@ import { dirname } from 'node:path'; export interface Identity { version: '2.0.0'; deviceId: string; + keyAlias?: string; // internal key name in the keystore (may differ from deviceId for keychain/TPM) publicKey: string; // base64 friendlyName: string; createdAt: string; // ISO 8601 storageBackend: string; maxControllers?: number; // default 1 — max controllers allowed on this target + /** SENSITIVE — auto-generated passphrase for encrypted-file backend. Never log or display. */ + passphrase?: string; } /** diff --git a/packages/cli/src/shell-client.ts b/packages/cli/src/shell-client.ts index 4400e55..c94d8a3 100644 --- a/packages/cli/src/shell-client.ts +++ b/packages/cli/src/shell-client.ts @@ -27,6 +27,7 @@ interface Identity { publicKey: string; friendlyName: string; storageBackend: string; + passphrase?: string; } function getAmeshDir(): string { @@ -41,7 +42,7 @@ export async function connectShell(opts: ShellOptions): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, join(ameshDir, 'keys'), - process.env.AUTH_MESH_PASSPHRASE, + identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, ); const keyAlias = identity.keyAlias ?? identity.deviceId; diff --git a/packages/keystore/src/__tests__/detect.test.ts b/packages/keystore/src/__tests__/detect.test.ts index 0205db6..e6b0382 100644 --- a/packages/keystore/src/__tests__/detect.test.ts +++ b/packages/keystore/src/__tests__/detect.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createForBackend } from '../detect.js'; +import { createForBackend, detectAndCreate } from '../detect.js'; let tempDir: string; @@ -33,3 +33,21 @@ describe('createForBackend', () => { ); }); }); + +describe('detectAndCreate', () => { + it('auto-generates passphrase for encrypted-file fallback', async () => { + const result = await detectAndCreate(tempDir); + expect(result.backend).toBeDefined(); + expect(result.keyStore).toBeDefined(); + if (result.backend === 'encrypted-file') { + expect(result.passphrase).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it('calls onProgress callback', async () => { + const messages: string[] = []; + await detectAndCreate(tempDir, (msg) => messages.push(msg)); + expect(messages.length).toBeGreaterThan(0); + expect(messages.some((m) => m.includes('selected'))).toBe(true); + }); +}); diff --git a/packages/keystore/src/detect.ts b/packages/keystore/src/detect.ts index 1176799..b581182 100644 --- a/packages/keystore/src/detect.ts +++ b/packages/keystore/src/detect.ts @@ -1,4 +1,5 @@ import { platform } from 'node:os'; +import { randomBytes } from '@noble/ciphers/utils.js'; import type { KeyStore } from './interface.js'; export type StorageBackend = 'secure-enclave' | 'keychain' | 'tpm2' | 'encrypted-file'; @@ -7,6 +8,8 @@ export interface DetectionResult { backend: StorageBackend; keyStore: KeyStore; warning?: string; + /** SENSITIVE — auto-generated passphrase for encrypted-file backend. Never log or display. */ + passphrase?: string; } /** @@ -15,13 +18,11 @@ export interface DetectionResult { * Detection chain: * Tier 1: macOS Keychain (tries Secure Enclave first, falls back to software keychain) * Tier 2: TPM 2.0 (Linux) - * Tier 3: Encrypted file (only if passphrase is provided — explicit opt-in) - * - * If no backend is available, throws with guidance. + * Tier 3: Encrypted file (always available — passphrase auto-generated) */ export async function detectAndCreate( basePath: string, - passphrase?: string, + onProgress?: (msg: string) => void, ): Promise { // Tier 1: macOS — Swift helper (Secure Enclave → software keychain) if (platform() === 'darwin') { @@ -33,6 +34,8 @@ export async function detectAndCreate( if (available) { const keyStore = new MacOSKeychainKeyStore(basePath); if (backend === 'keychain') { + onProgress?.(' Secure Enclave not available (binary not signed)'); + onProgress?.(' macOS Keychain selected'); return { backend: 'keychain', keyStore, @@ -40,10 +43,14 @@ export async function detectAndCreate( 'Secure Enclave not available (binary not signed). Using macOS Keychain (software-protected).', }; } + onProgress?.(' Secure Enclave selected'); return { backend: 'secure-enclave', keyStore }; } + onProgress?.(' Secure Enclave not available'); + onProgress?.(' macOS Keychain not available'); } catch { - // Swift helper not found or not compiled — fall through + onProgress?.(' Secure Enclave not available (helper not found)'); + onProgress?.(' macOS Keychain not available'); } } @@ -52,31 +59,27 @@ export async function detectAndCreate( try { const { isTPM2Available, TPMKeyStore } = await import('./drivers/tpm.js'); if (await isTPM2Available()) { + onProgress?.(' TPM 2.0 selected'); return { backend: 'tpm2', keyStore: new TPMKeyStore(basePath) }; } + onProgress?.(' TPM 2.0 not available (tpm2-tools not found)'); } catch { - // tpm2-tools not installed — fall through + onProgress?.(' TPM 2.0 not available (tpm2-tools not found)'); } } - // Tier 3: Encrypted file — only if passphrase was explicitly provided - if (passphrase) { - const { EncryptedFileKeyStore } = await import('./drivers/encrypted-file.js'); - return { - backend: 'encrypted-file', - keyStore: new EncryptedFileKeyStore(basePath, passphrase), - warning: - 'Using file-based key storage. Keys are protected by filesystem permissions and a passphrase, not hardware. ' + - 'For hardware-backed storage, use macOS or a Linux host with TPM 2.0.', - }; - } - - throw new Error( - 'No supported key storage backend detected.\n' + - ' • macOS: Secure Enclave or Keychain (requires amesh-se-helper)\n' + - ' • Linux: TPM 2.0 (requires tpm2-tools)\n' + - ' • Any platform: --backend file --passphrase (file-based, explicit opt-in)', - ); + // Tier 3: Encrypted file — always available, passphrase auto-generated + const passphrase = Buffer.from(randomBytes(32)).toString('hex'); + onProgress?.(' Encrypted file selected (auto-generated passphrase)'); + const { EncryptedFileKeyStore } = await import('./drivers/encrypted-file.js'); + return { + backend: 'encrypted-file', + keyStore: new EncryptedFileKeyStore(basePath, passphrase), + passphrase, + warning: + 'Keys are software-protected (no hardware keystore detected).\n' + + ' To upgrade: install amesh-se-helper (macOS) or enable TPM 2.0 (Linux), then re-run `amesh init`.', + }; } /** diff --git a/packages/sdk/src/amesh.ts b/packages/sdk/src/amesh.ts index 250ca84..6d45565 100644 --- a/packages/sdk/src/amesh.ts +++ b/packages/sdk/src/amesh.ts @@ -17,6 +17,7 @@ interface Identity { publicKey: string; friendlyName: string; storageBackend: string; + passphrase?: string; } function getAmeshDir(): string { @@ -44,7 +45,7 @@ async function ameshFetch(url: string | URL, init?: RequestInit): Promise // Generate our identity const ameshDir = getAmeshDir(); const keysDir = join(ameshDir, 'keys'); - const { backend, keyStore } = await detectAndCreate(keysDir); + const { backend, keyStore, passphrase: autoPassphrase } = await detectAndCreate(keysDir); const { publicKey } = await keyStore.generateAndStore('am_pending'); @@ -167,6 +167,7 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise friendlyName: payload.name, createdAt: new Date().toISOString(), storageBackend: backend, + ...(autoPassphrase ? { passphrase: autoPassphrase } : {}), }; await writeFile(identityPath, JSON.stringify(identityData, null, 2), { mode: 0o600 }); From fc70ff9aee7b06fa37bd557addf45824e1c23d70 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Sat, 4 Apr 2026 09:15:39 +0300 Subject: [PATCH 2/5] review: consolidate shared code, security hardening, doc updates - Export BACKEND_LABELS, generatePassphrase from @authmesh/keystore - Remove duplicated Identity interfaces from agent.ts, shell-client.ts, sdk - Strip passphrase from memory after KeyStore creation (6 call sites) - Atomic write for identity.json in bootstrap.ts - Fix stale --passphrase error message in detect.ts - DX: role-neutral next steps, --force in warning, fix 0-device message - Update 28 stale doc references (--passphrase, init/list output samples) - ADR-010: passphrase colocation security decision --- README.md | 5 +- docs/architecture-decisions.md | 22 ++++- docs/guide.md | 44 ++++++--- docs/integration-guide.md | 26 +++--- docs/protocol-spec.md | 47 ++++++---- docs/use-cases-analysis.md | 4 +- landpage/src/routes/+page.svelte | 6 +- .../src/routes/docs/integration/+page.svelte | 8 +- .../src/routes/docs/remote-shell/+page.svelte | 2 +- packages/agent/src/agent.ts | 89 +++++++++++-------- packages/agent/src/commands/init.ts | 21 ++--- packages/agent/src/commands/list.ts | 18 ++-- packages/agent/src/context.ts | 4 +- packages/agent/src/shell-client.ts | 54 ++++++----- packages/cli/src/commands/init.ts | 21 ++--- packages/cli/src/commands/list.ts | 18 ++-- packages/cli/src/context.ts | 4 +- packages/cli/src/shell-client.ts | 54 ++++++----- packages/keystore/src/detect.ts | 26 ++++-- packages/keystore/src/index.ts | 2 +- packages/sdk/src/amesh.ts | 67 +++++++++++--- packages/sdk/src/bootstrap.ts | 52 +++++++---- 22 files changed, 361 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index 2efb038..b96abb8 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ npm install -g @authmesh/cli # CLI for device management ```bash amesh init --name "prod-api" # Identity created. -# Device ID : am_cOixWcOdI8-pLh4P -# Backend : secure-enclave +# Device ID : am_cOixWcOdI8-pLh4P +# Backend : Secure Enclave +# Friendly Name : prod-api ``` ### 2. Pair two machines diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 0b5b9ec..9234f0f 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -46,7 +46,7 @@ Key decisions made during spec review and project bootstrap (March 2026). Each e | 2 | macOS Keychain | Swift helper → software keychain (unsigned binary fallback) | | 3 | Linux TPM 2.0 | `tpm2-tools` subprocess via `execFile` (not `exec`) | -Note: The encrypted-file fallback (Tier 3) is available as an explicit opt-in (`--backend encrypted-file --passphrase`) for cloud VMs and containers without hardware key storage. Hardware backends are always preferred when available. +Note: The encrypted-file fallback (Tier 3) is always available as an automatic fallback for cloud VMs and containers without hardware key storage. The passphrase is auto-generated (256-bit random) and stored in `identity.json`. Hardware backends are always preferred when available. --- @@ -192,3 +192,23 @@ The controller CLI displays this code; the target CLI prompts the operator to en - Auto-granting shell on pairing — violates principle of least privilege - Reusing pairing handshake's random-nonce encryption — birthday-bound risk over long sessions - Session resumption — complexity and nonce-reuse risk outweigh the latency benefit + +--- + +## ADR-010: Auto-generated passphrase stored in identity.json + +**Decision:** The encrypted-file backend auto-generates a 256-bit random passphrase and stores it in `identity.json` alongside the device identity. The `--passphrase` CLI flag has been removed. + +**Why:** The previous model required users to provide and manage a passphrase (via `--passphrase` flag or `AUTH_MESH_PASSPHRASE` env var). This was the #1 onboarding friction point: users forgot passphrases, used weak ones, or had to manage env vars across machines. In practice, the passphrase was often stored in a `.env` file or systemd unit alongside the identity — offering no real second-factor benefit. + +**Security model change:** The encrypted-file backend's security now depends on Unix file permissions (`identity.json` is mode `0o600` in a `0o700` directory) rather than encryption + separate passphrase. The Argon2id + AES-256-GCM encryption layer is retained as defense-in-depth (protects against partial file reads, memory forensics of swap/core dumps, and accidental backups of the key file without the identity file). + +**Threat analysis:** +- **Same-user access:** Unchanged — the user who owns `~/.amesh/` can always access their own keys +- **Root compromise:** Unchanged — root can read everything regardless +- **Backup leak of `~/.amesh/`:** Slightly weaker — backup now contains both passphrase and encrypted key. Previously, the passphrase might have been stored separately. Mitigation: users should exclude `~/.amesh/` from backups, same as SSH keys. +- **Key file leak without identity file:** Still protected — the encryption is meaningful if only `keys/*.key.json` leaks without `identity.json` + +**Backwards compatibility:** Existing identities created before this change (without a `passphrase` field in `identity.json`) still work via the `AUTH_MESH_PASSPHRASE` env var fallback. + +**Memory hygiene:** The passphrase is stripped from the in-memory `Identity` object immediately after the `KeyStore` is created (`delete identity.passphrase`). JavaScript strings are immutable so a copy may remain in the V8/JSC heap, but this reduces the reference window. diff --git a/docs/guide.md b/docs/guide.md index bd18835..033aa48 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -26,17 +26,27 @@ amesh init --name "My Laptop" Output (macOS): ``` Generating P-256 keypair... + +Detecting key storage backend: + Secure Enclave not available (binary not signed) + macOS Keychain selected + Identity created. - Device ID : am_cOixWcOdI8-pLh4P - Public Key: A+B9pwI1/CGINmyozdPj... - Backend : keychain + Device ID : am_cOixWcOdI8-pLh4P + Public Key : A+B9pwI1/CGINmyozdPj... + Backend : macOS Keychain + Friendly Name : My Laptop + +Next steps: + Target: run `amesh listen`, then `amesh invite` from your controller + Controller: run `amesh listen` on a target first, then `amesh invite` here ``` -amesh uses hardware-backed key storage when available (Secure Enclave, macOS Keychain, or TPM 2.0). On machines without hardware key storage (cloud VMs, containers), use the encrypted-file backend: +amesh uses hardware-backed key storage when available (Secure Enclave, macOS Keychain, or TPM 2.0). On machines without hardware key storage (cloud VMs, containers), the encrypted-file backend is selected automatically. You can also force it: ```bash -amesh init --name "prod-api" --backend encrypted-file --passphrase "$AUTH_MESH_PASSPHRASE" +amesh init --name "prod-api" --backend encrypted-file ``` This creates two files: @@ -60,21 +70,31 @@ amesh list Output (empty initially): ``` - No trusted devices yet. - Run `amesh listen` to start pairing. + This device + ─────────────────────────────────────────────────────── + Device ID : am_cOixWcOdI8-pLh4P + Friendly Name : My Laptop + Backend : macOS Keychain + Created : 2026-03-30 - Your identity: am_cOixWcOdI8-pLh4P (My Laptop) + No trusted devices yet. + Pair with another device using `amesh listen` + `amesh invite`. ``` After devices are paired, it shows each device's role (`[controller]` or `[target]`): ``` + This device + ─────────────────────────────────────────────────────── + Device ID : am_cOixWcOdI8-pLh4P + Friendly Name : My Laptop + Backend : macOS Keychain + Created : 2026-03-30 + Trusted Devices (2) - ────────────────────────────────────────────────────────── + ─────────────────────────────────────────────────────── am_1a2b3c4d5e6f7a8b MacBook Pro — dev [controller] added 2026-03-28 am_9f8e7d6c5b4a3210 staging-api [target] added 2026-03-29 - ────────────────────────────────────────────────────────── - - Your identity: am_cOixWcOdI8-pLh4P (My Laptop) + ─────────────────────────────────────────────────────── ``` - **[controller]** — this device can authenticate TO you diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 0600a2e..1aa2043 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -292,12 +292,12 @@ Use the same `amesh.fetch()` and `amesh.verify()` code paths in local developmen ### Setup -Use the `encrypted-file` backend with a simple passphrase for local services: +Use the `encrypted-file` backend for local services (passphrase auto-generated): ```bash # Create identities for local services -AUTH_MESH_DIR=/tmp/amesh-a amesh init --name "local-service-a" --backend encrypted-file --passphrase "dev" -AUTH_MESH_DIR=/tmp/amesh-b amesh init --name "local-service-b" --backend encrypted-file --passphrase "dev" +AUTH_MESH_DIR=/tmp/amesh-a amesh init --name "local-service-a" --backend encrypted-file +AUTH_MESH_DIR=/tmp/amesh-b amesh init --name "local-service-b" --backend encrypted-file # Start the relay (needed only for pairing) bunx @authmesh/relay @@ -323,13 +323,12 @@ const res = await amesh.fetch('http://localhost:4000/api/data', { The only difference is the key storage backend: - **Production:** macOS Keychain, Secure Enclave, or TPM 2.0 -- **Local dev:** `--backend encrypted-file --passphrase "dev"` +- **Local dev:** `--backend encrypted-file` (passphrase auto-generated) ### Tips -- Use a shared passphrase like `"dev"` for all local identities. Security is not the goal — dev/prod parity is. - Use `AUTH_MESH_DIR` to isolate each service's identity directory. -- For Docker Compose, set `AUTH_MESH_PASSPHRASE=dev` and mount `AUTH_MESH_DIR` as a volume so identities persist across restarts. +- For Docker Compose, mount `AUTH_MESH_DIR` as a volume so identities (and their auto-generated passphrases) persist across restarts. - The relay is only needed during initial pairing. Once devices are paired, stop it. --- @@ -339,7 +338,7 @@ The only difference is the key storage backend: | Variable | Description | Default | |----------|-------------|---------| | `AUTH_MESH_DIR` | Directory for identity and keys | `~/.amesh/` | -| `AUTH_MESH_PASSPHRASE` | Passphrase for encrypted-file backend | (optional) | +| `AUTH_MESH_PASSPHRASE` | Override auto-generated passphrase for encrypted-file backend (rarely needed) | (optional) | | `AMESH_BOOTSTRAP_TOKEN` | Bootstrap token for automated pairing | (optional) | | `AMESH_RELAY_URL` | WebSocket relay URL | `wss://relay.authmesh.dev/ws` | | `REDIS_URL` | Redis URL for nonce store | (optional) | @@ -382,13 +381,12 @@ The allow list file (`~/.amesh/allow_list.json`) was modified outside of amesh. You're running in production without a Redis nonce store. Replay attacks could succeed by hitting different instances. See Recipe 3 above. -### "No supported key storage backend detected" +### "Keys are software-protected (no hardware keystore detected)" -amesh prefers hardware-backed storage (Secure Enclave, macOS Keychain, TPM 2.0) but also supports an encrypted-file backend for cloud VMs: +amesh prefers hardware-backed storage (Secure Enclave, macOS Keychain, TPM 2.0). When none is available, it falls back to the encrypted-file backend automatically with an auto-generated passphrase. -```bash -amesh init --name "my-server" --backend encrypted-file --passphrase "your-passphrase" -# Or set AUTH_MESH_PASSPHRASE environment variable -``` +To upgrade to hardware-backed storage: +- **macOS:** Ensure the Swift helper binary is installed alongside the `amesh` binary for Keychain/Secure Enclave support. +- **Linux:** Install `tpm2-tools` for TPM 2.0 support. -On macOS, ensure the Swift helper binary (`amesh-se-helper`) is installed alongside the `amesh` binary for Keychain/Secure Enclave support. +Then re-run `amesh init --force` to regenerate with the hardware backend. diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index e70d04c..449eff4 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -70,7 +70,7 @@ Every choice below is made for a reason. Do not substitute without understanding | **Crypto — Ciphers** | `@noble/ciphers` (ChaCha20-Poly1305) | Handshake tunnel encryption. Same ecosystem. | | **Hardware — macOS** | Swift helper subprocess → Apple Security.framework | Direct Secure Enclave access via `SecKeyCreateRandomKey` with `kSecAttrTokenIDSecureEnclave`. Generates P-256 keys in hardware. `node-keytar` is deprecated (archived Dec 2022) and cannot access Secure Enclave — it is only a password store. | | **Hardware — Linux** | `tpm2-tools` (subprocess via `execFile`) | Industry standard TPM 2.0 interface. P-256 universally supported. | -| **Hardware — Fallback** | Encrypted file (AES-256-GCM + Argon2id) | Explicit opt-in via `--backend file --passphrase`. For cloud VMs without hardware key storage. | +| **Hardware — Fallback** | Encrypted file (AES-256-GCM + Argon2id) | Automatic fallback. Passphrase auto-generated and stored in identity.json. For cloud VMs without hardware key storage. | | **Relay Server** | Bun.serve() native | Zero deps — no Fastify, no ws. | | **Allow List Storage** | JSON file + HMAC integrity seal | See Section 9 — the plaintext JSON without integrity protection is a critical vulnerability | | **Package Manager** | Bun workspaces | Monorepo-friendly, fast installs, native test runner | @@ -179,19 +179,23 @@ The prefix `am_` makes amesh IDs visually identifiable in logs. ### CLI output ``` -$ amesh init +$ amesh init --name "prod-api-us-east-1" -? What is this device's friendly name? prod-api-us-east-1 +Generating P-256 keypair... -✔ Generating P-256 keypair... -✔ Storing private key in Secure Enclave (macOS) -✔ Identity created. +Detecting key storage backend: + Secure Enclave selected - Device ID : am_8f3a9b2c1d4e5f6a - Public Key: 8f3a9b2c... - Backend : secure-enclave +Identity created. -Run `amesh listen` on this machine, then `amesh invite` from your laptop. + Device ID : am_8f3a9b2c1d4e5f6a + Public Key : A+B9pwI1/CGINmyozdPj... + Backend : Secure Enclave + Friendly Name : prod-api-us-east-1 + +Next steps: + Target: run `amesh listen`, then `amesh invite` from your controller + Controller: run `amesh listen` on a target first, then `amesh invite` here ``` --- @@ -648,11 +652,11 @@ Every device goes through this decision tree at `amesh init`. The selected backe │ NO ▼ ┌──────────────────────────────────────────────────────┐ -│ Tier 3 — Encrypted file (explicit opt-in only) │ -│ Requires: --backend file --passphrase │ +│ Tier 3 — Encrypted file (automatic fallback) │ +│ Passphrase auto-generated, stored in identity.json │ │ → AES-256-GCM + Argon2id, filesystem permissions │ │ → Private key encrypted at rest, decrypted per-sign │ -│ → WARNING printed: "file-based, not hardware" │ +│ → WARNING printed: "software-protected" │ └─────────────────────────────��────────────────────────┘ ``` @@ -714,13 +718,18 @@ Document this limitation clearly in the CLI output and README. ``` $ amesh list + This device + ─────────────────────────────────────────────────────── + Device ID : am_8f3a9b2c1d4e5f6a + Friendly Name : prod-api-us-east-1 + Backend : Secure Enclave + Created : 2026-03-28 + Trusted Devices (2) - ─────────────────────────────────────────────── - am_1a2b3c4d5e6f7a8b MacBook Pro — dev added 2026-03-28 - am_9f8e7d6c5b4a3210 prod-api-us-east added 2026-03-29 - ─────────────────────────────────────────────── - - Your identity: am_8f3a9b2c1d4e5f6a (prod-api-us-east-1) + ─────────────────────────────────────────────────────── + am_1a2b3c4d5e6f7a8b MacBook Pro — dev [controller] added 2026-03-28 + am_9f8e7d6c5b4a3210 prod-api-us-east [target] added 2026-03-29 + ─────────────────────────────────────────────────────── ``` --- diff --git a/docs/use-cases-analysis.md b/docs/use-cases-analysis.md index 54f2417..77a7468 100644 --- a/docs/use-cases-analysis.md +++ b/docs/use-cases-analysis.md @@ -212,8 +212,8 @@ This is the best point in the feedback. Dev/prod parity is a real pain point we ```bash # Dev machine -amesh init --name "local-service-a" --backend encrypted-file --passphrase "dev" -amesh init --name "local-service-b" --backend encrypted-file --passphrase "dev" +amesh init --name "local-service-a" --backend encrypted-file +amesh init --name "local-service-b" --backend encrypted-file # Pair them, then use the exact same amesh.fetch() / amesh.verify() code as production ``` diff --git a/landpage/src/routes/+page.svelte b/landpage/src/routes/+page.svelte index 390f340..462ead6 100644 --- a/landpage/src/routes/+page.svelte +++ b/landpage/src/routes/+page.svelte @@ -36,7 +36,7 @@ { n: '1', title: 'Create a device identity', desc: 'Each machine gets a unique keypair. The private key never leaves the device.', - code: `$ amesh init --name "prod-api"\n\nIdentity created.\n Device ID : am_cOixWcOdI8-pLh4P\n Backend : secure-enclave` + code: `$ amesh init --name "prod-api"\n\nIdentity created.\n Device ID : am_cOixWcOdI8-pLh4P\n Backend : Secure Enclave\n Friendly Name : prod-api` }, { n: '2', title: 'Pair two machines', @@ -80,7 +80,7 @@ const cliTabs = [ { label: 'Device Management', - code: `$ amesh list\n\n Trusted Devices (2)\n ──────────────────────────────────────────────────────\n am_1a2b3c4d Dev Laptop [controller] added 2026-03-28\n am_9f8e7d6c staging-api [target] added 2026-03-29\n ──────────────────────────────────────────────────────\n\n$ amesh revoke am_1a2b3c4d\n\n Are you sure? (y/N): y\n Removed. Access revoked immediately.` + code: `$ amesh list\n\n This device\n ───────────────────────────────────────────────────────\n Device ID : am_cOixWcOd\n Friendly Name : prod-api\n Backend : Secure Enclave\n\n Trusted Devices (2)\n ───────────────────────────────────────────────────────\n am_1a2b3c4d Dev Laptop [controller] added 2026-03-28\n am_9f8e7d6c staging-api [target] added 2026-03-29\n ───────────────────────────────────────────────────────\n\n$ amesh revoke am_1a2b3c4d\n\n Are you sure? (y/N): y\n Removed. Access revoked immediately.` }, { label: 'Pairing', @@ -88,7 +88,7 @@ }, { label: 'Init', - code: `$ amesh init --name "prod-api"\n\n Identity created.\n Device ID : am_cOixWcOdI8-pLh4P\n Backend : secure-enclave\n Public key: 04a1b2c3...(65 bytes)\n\n Ready. Run amesh invite to pair with another device.` + code: `$ amesh init --name "prod-api"\n\n Identity created.\n Device ID : am_cOixWcOdI8-pLh4P\n Backend : Secure Enclave\n Friendly Name : prod-api\n\n Next steps:\n Target: run amesh listen, then amesh invite from your controller\n Controller: run amesh listen on a target first, then amesh invite here` }, ]; let activeCliTab = $state(0); diff --git a/landpage/src/routes/docs/integration/+page.svelte b/landpage/src/routes/docs/integration/+page.svelte index b996066..7515f38 100644 --- a/landpage/src/routes/docs/integration/+page.svelte +++ b/landpage/src/routes/docs/integration/+page.svelte @@ -221,8 +221,8 @@ app.post('/webhooks', amesh.verify(), (req

Setup

# Create local identities (encrypted-file backend for dev) -AUTH_MESH_DIR=/tmp/amesh-a amesh init --name "local-service-a" --backend encrypted-file --passphrase "dev" -AUTH_MESH_DIR=/tmp/amesh-b amesh init --name "local-service-b" --backend encrypted-file --passphrase "dev" +AUTH_MESH_DIR=/tmp/amesh-a amesh init --name "local-service-a" --backend encrypted-file +AUTH_MESH_DIR=/tmp/amesh-b amesh init --name "local-service-b" --backend encrypted-file # Pair them (same ceremony as production) AUTH_MESH_DIR=/tmp/amesh-b amesh listen # on service-b (target) @@ -243,7 +243,7 @@ const res = await amesh.fetch('http://localhost:4

Production: macOS Keychain, Secure Enclave, or TPM 2.0
- Local dev: --backend encrypted-file --passphrase "dev" + Local dev: --backend encrypted-file (passphrase auto-generated)

@@ -254,7 +254,7 @@ const res = await amesh.fetch('http://localhost:4
{#each [ { name: 'AUTH_MESH_DIR', desc: 'Directory for identity and keys', def: '~/.amesh/' }, - { name: 'AUTH_MESH_PASSPHRASE', desc: 'Passphrase for encrypted-file backend', def: 'optional' }, + { name: 'AUTH_MESH_PASSPHRASE', desc: 'Override auto-generated passphrase (rarely needed)', def: 'optional' }, { name: 'AMESH_BOOTSTRAP_TOKEN', desc: 'Bootstrap token for automated pairing', def: 'optional' }, { name: 'AMESH_RELAY_URL', desc: 'WebSocket relay URL', def: 'wss://relay.authmesh.dev/ws' }, { name: 'REDIS_URL', desc: 'Redis URL for nonce store', def: 'optional' }, diff --git a/landpage/src/routes/docs/remote-shell/+page.svelte b/landpage/src/routes/docs/remote-shell/+page.svelte index 8a70531..4515ddc 100644 --- a/landpage/src/routes/docs/remote-shell/+page.svelte +++ b/landpage/src/routes/docs/remote-shell/+page.svelte @@ -147,7 +147,7 @@ Filesystem Size Used Avail Use% Mounted on
{#each [ { name: 'AUTH_MESH_DIR', desc: 'Directory for identity and keys', def: '~/.amesh/' }, - { name: 'AUTH_MESH_PASSPHRASE', desc: 'Passphrase for encrypted-file backend', def: 'optional' }, + { name: 'AUTH_MESH_PASSPHRASE', desc: 'Override auto-generated passphrase (rarely needed)', def: 'optional' }, { name: 'AMESH_RELAY_URL', desc: 'WebSocket relay URL', def: 'wss://relay.authmesh.dev/ws' }, ] as env}
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 534537a..bb3352d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,9 +1,9 @@ import { ShellCipher } from './shell-cipher.js'; import { AllowList, createForBackend } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { loadIdentity } from './identity.js'; +import type { Identity } from './identity.js'; +import { getIdentityPath, getKeysDir, getAllowListPath } from './paths.js'; import { runAgentShellHandshake, createMessageReader, send } from './shell-handshake.js'; import { FrameType, @@ -20,19 +20,6 @@ interface AgentOptions { idleTimeoutMinutes: number; } -interface Identity { - deviceId: string; - keyAlias?: string; - publicKey: string; - friendlyName: string; - storageBackend: string; - passphrase?: string; -} - -function getAmeshDir(): string { - return process.env.AUTH_MESH_DIR ?? join(homedir(), '.amesh'); -} - function sanitizeForLog(str: string, maxLen = 200): string { // Strip non-printable characters and truncate return str.replace(/[^\x20-\x7E]/g, '').slice(0, maxLen); @@ -47,19 +34,19 @@ export async function startAgent(opts: AgentOptions): Promise { process.exit(1); } - const ameshDir = getAmeshDir(); - const identityContent = await readFile(join(ameshDir, 'identity.json'), 'utf-8'); - const identity = JSON.parse(identityContent) as Identity; + const identity = await loadIdentity(getIdentityPath()); + const passphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; const keyStore = await createForBackend( identity.storageBackend as StorageBackend, - join(ameshDir, 'keys'), - identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, + getKeysDir(), + passphrase, ); const keyAlias = identity.keyAlias ?? identity.deviceId; const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); - const allowList = new AllowList(join(ameshDir, 'allow_list.json'), hmacKey, identity.deviceId); + const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); @@ -99,7 +86,11 @@ export async function startAgent(opts: AgentOptions): Promise { 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; } + try { + msg = JSON.parse(raw); + } catch { + return; + } // Step 2: Relay issues challenge — sign it to prove key ownership if (msg.type === 'agent_challenge') { @@ -132,7 +123,9 @@ export async function startAgent(opts: AgentOptions): Promise { sessionActive = true; handleShellRequest(ws, allowList, identity, signFn, opts.idleTimeoutMinutes) .catch(() => {}) - .finally(() => { sessionActive = false; }); + .finally(() => { + sessionActive = false; + }); return; } }); @@ -159,14 +152,20 @@ export async function startAgent(opts: AgentOptions): Promise { try { const result = await runAgentShellHandshake( - ws, reader, - id.deviceId, id.publicKey, id.friendlyName, - sign, al, + ws, + reader, + id.deviceId, + id.publicKey, + id.friendlyName, + sign, + al, ); const startTime = Date.now(); - console.log(`[amesh-agent] Shell opened by ${result.peerDeviceId} (${result.peerFriendlyName})`); + 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'); @@ -185,7 +184,12 @@ export async function startAgent(opts: AgentOptions): Promise { 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') })); + ws.send( + JSON.stringify({ + type: 'data', + payload: Buffer.from(encrypted).toString('base64'), + }), + ); } }, }, @@ -204,7 +208,11 @@ export async function startAgent(opts: AgentOptions): Promise { 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; } + try { + msg = JSON.parse(raw); + } catch { + return; + } if (msg.type !== 'data' || !msg.payload) return; lastActivity = Date.now(); @@ -224,12 +232,16 @@ export async function startAgent(opts: AgentOptions): Promise { } case FrameType.PING: { const pong = cipher.encrypt(encodePongFrame()); - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(pong).toString('base64') })); + 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)}`); + console.log( + `[amesh-agent] Command from ${result.peerDeviceId}: ${sanitizeForLog(cmd)}`, + ); proc.terminal?.write(cmd + '\nexit\n'); break; } @@ -247,16 +259,21 @@ export async function startAgent(opts: AgentOptions): Promise { try { const exitFrame = cipher.encrypt(encodeExitFrame(exitCode)); if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'data', payload: Buffer.from(exitFrame).toString('base64') })); + ws.send( + JSON.stringify({ type: 'data', payload: Buffer.from(exitFrame).toString('base64') }), + ); } - } catch { /* cipher may be closed */ } + } 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)`); + console.log( + `[amesh-agent] Shell closed for ${result.peerDeviceId} (exit=${exitCode}, duration=${duration}s)`, + ); cipher.close(); // sessionActive reset by .finally() in caller - } catch (err) { console.error('[amesh-agent] Shell handshake failed:', (err as Error).message); // sessionActive reset by .finally() in caller diff --git a/packages/agent/src/commands/init.ts b/packages/agent/src/commands/init.ts index ff0fa32..763b0b4 100644 --- a/packages/agent/src/commands/init.ts +++ b/packages/agent/src/commands/init.ts @@ -1,7 +1,11 @@ import { Command, Flags } from '@oclif/core'; -import { createForBackend, detectAndCreate } from '@authmesh/keystore'; +import { + createForBackend, + detectAndCreate, + BACKEND_LABELS, + generatePassphrase, +} from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; -import { randomBytes } from '@noble/ciphers/utils.js'; import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; import { getIdentityPath, getKeysDir } from '../paths.js'; import { rename } from 'node:fs/promises'; @@ -9,13 +13,6 @@ import { join } from 'node:path'; const deviceIdPlaceholder = 'am_init'; -const BACKEND_LABELS: Record = { - 'secure-enclave': 'Secure Enclave', - 'keychain': 'macOS Keychain', - 'tpm2': 'TPM 2.0', - 'encrypted-file': 'Encrypted file', -}; - export default class Init extends Command { static override description = 'Create a cryptographic identity for this device'; @@ -61,7 +58,7 @@ export default class Init extends Command { if (flags.backend) { backend = flags.backend as StorageBackend; if (backend === 'encrypted-file') { - resolvedPassphrase = Buffer.from(randomBytes(32)).toString('hex'); + resolvedPassphrase = generatePassphrase(); } keyStore = await createForBackend(backend, keysDir, resolvedPassphrase); this.log(` Using backend: ${BACKEND_LABELS[backend]}`); @@ -127,7 +124,7 @@ export default class Init extends Command { this.log(''); this.log('Next steps:'); - this.log(' 1. Run `amesh listen` on this machine'); - this.log(' 2. Run `amesh invite` from your controller to pair'); + this.log(' Target: run `amesh listen`, then `amesh invite` from your controller'); + this.log(' Controller: run `amesh listen` on a target first, then `amesh invite` here'); } } diff --git a/packages/agent/src/commands/list.ts b/packages/agent/src/commands/list.ts index 4bc493c..5bd8349 100644 --- a/packages/agent/src/commands/list.ts +++ b/packages/agent/src/commands/list.ts @@ -1,13 +1,7 @@ import { Command } from '@oclif/core'; +import { BACKEND_LABELS } from '@authmesh/keystore'; import { loadContext } from '../context.js'; -const BACKEND_LABELS: Record = { - 'secure-enclave': 'Secure Enclave', - 'keychain': 'macOS Keychain', - 'tpm2': 'TPM 2.0', - 'encrypted-file': 'Encrypted file', -}; - export default class List extends Command { static override description = 'Show this device and trusted devices in the allow list'; @@ -35,20 +29,24 @@ export default class List extends Command { this.log(' ' + '─'.repeat(55)); this.log(` Device ID : ${identity.deviceId}`); this.log(` Friendly Name : ${identity.friendlyName}`); - this.log(` Backend : ${BACKEND_LABELS[identity.storageBackend] ?? identity.storageBackend}`); + this.log( + ` Backend : ${BACKEND_LABELS[identity.storageBackend as keyof typeof BACKEND_LABELS] ?? identity.storageBackend}`, + ); this.log(` Created : ${identity.createdAt.split('T')[0]}`); this.log(''); if (data.devices.length === 0) { this.log(' No trusted devices yet.'); - this.log(' Run `amesh listen` to start pairing.'); + this.log(' Pair with another device using `amesh listen` + `amesh invite`.'); } else { this.log(` Trusted Devices (${data.devices.length})`); this.log(' ' + '─'.repeat(55)); for (const device of data.devices) { const date = device.addedAt.split('T')[0]; const roleTag = device.role === 'controller' ? '[controller]' : '[target]'; - this.log(` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`); + this.log( + ` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`, + ); } this.log(' ' + '─'.repeat(55)); } diff --git a/packages/agent/src/context.ts b/packages/agent/src/context.ts index 89a459e..cd8786c 100644 --- a/packages/agent/src/context.ts +++ b/packages/agent/src/context.ts @@ -15,10 +15,12 @@ export interface AmeshContext { export async function loadContext(): Promise { const identity = await loadIdentity(getIdentityPath()); + const passphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; const keyStore = await createForBackend( identity.storageBackend as StorageBackend, getKeysDir(), - identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, + passphrase, ); const keyAlias = identity.keyAlias ?? identity.deviceId; diff --git a/packages/agent/src/shell-client.ts b/packages/agent/src/shell-client.ts index c94d8a3..6e7bc50 100644 --- a/packages/agent/src/shell-client.ts +++ b/packages/agent/src/shell-client.ts @@ -1,9 +1,8 @@ import { ShellCipher } from './shell-cipher.js'; import { AllowList, createForBackend } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { loadIdentity } from './identity.js'; +import { getIdentityPath, getKeysDir, getAllowListPath } from './paths.js'; import { runControllerShellHandshake, createMessageReader, send } from './shell-handshake.js'; import { FrameType, @@ -21,33 +20,20 @@ interface ShellOptions { command?: string; // -c mode } -interface Identity { - deviceId: string; - keyAlias?: string; - publicKey: string; - friendlyName: string; - storageBackend: string; - passphrase?: string; -} - -function getAmeshDir(): string { - return process.env.AUTH_MESH_DIR ?? join(homedir(), '.amesh'); -} - export async function connectShell(opts: ShellOptions): Promise { - const ameshDir = getAmeshDir(); - const identityContent = await readFile(join(ameshDir, 'identity.json'), 'utf-8'); - const identity = JSON.parse(identityContent) as Identity; + const identity = await loadIdentity(getIdentityPath()); + const passphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; const keyStore = await createForBackend( identity.storageBackend as StorageBackend, - join(ameshDir, 'keys'), - identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, + getKeysDir(), + passphrase, ); const keyAlias = identity.keyAlias ?? identity.deviceId; const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); - const allowList = new AllowList(join(ameshDir, 'allow_list.json'), hmacKey, identity.deviceId); + const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); // Resolve target: by device ID or friendly name @@ -89,9 +75,13 @@ export async function connectShell(opts: ShellOptions): Promise { let result; try { result = await runControllerShellHandshake( - ws, reader, - identity.deviceId, identity.publicKey, identity.friendlyName, - signFn, allowList, + ws, + reader, + identity.deviceId, + identity.publicKey, + identity.friendlyName, + signFn, + allowList, ); } catch (err) { console.error(`Handshake failed: ${(err as Error).message}`); @@ -126,7 +116,9 @@ export async function connectShell(opts: ShellOptions): Promise { // Handle terminal resize process.stdout.on('resize', () => { - const frame = cipher.encrypt(encodeResizeFrame(process.stdout.columns, process.stdout.rows)); + 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') })); } @@ -134,7 +126,9 @@ export async function connectShell(opts: ShellOptions): Promise { // Send initial resize if (process.stdout.columns && process.stdout.rows) { - const frame = cipher.encrypt(encodeResizeFrame(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') })); } } @@ -151,7 +145,11 @@ export async function connectShell(opts: ShellOptions): Promise { 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; } + try { + msg = JSON.parse(raw); + } catch { + return; + } if (msg.type !== 'data' || !msg.payload) return; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ff0fa32..763b0b4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,7 +1,11 @@ import { Command, Flags } from '@oclif/core'; -import { createForBackend, detectAndCreate } from '@authmesh/keystore'; +import { + createForBackend, + detectAndCreate, + BACKEND_LABELS, + generatePassphrase, +} from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; -import { randomBytes } from '@noble/ciphers/utils.js'; import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; import { getIdentityPath, getKeysDir } from '../paths.js'; import { rename } from 'node:fs/promises'; @@ -9,13 +13,6 @@ import { join } from 'node:path'; const deviceIdPlaceholder = 'am_init'; -const BACKEND_LABELS: Record = { - 'secure-enclave': 'Secure Enclave', - 'keychain': 'macOS Keychain', - 'tpm2': 'TPM 2.0', - 'encrypted-file': 'Encrypted file', -}; - export default class Init extends Command { static override description = 'Create a cryptographic identity for this device'; @@ -61,7 +58,7 @@ export default class Init extends Command { if (flags.backend) { backend = flags.backend as StorageBackend; if (backend === 'encrypted-file') { - resolvedPassphrase = Buffer.from(randomBytes(32)).toString('hex'); + resolvedPassphrase = generatePassphrase(); } keyStore = await createForBackend(backend, keysDir, resolvedPassphrase); this.log(` Using backend: ${BACKEND_LABELS[backend]}`); @@ -127,7 +124,7 @@ export default class Init extends Command { this.log(''); this.log('Next steps:'); - this.log(' 1. Run `amesh listen` on this machine'); - this.log(' 2. Run `amesh invite` from your controller to pair'); + this.log(' Target: run `amesh listen`, then `amesh invite` from your controller'); + this.log(' Controller: run `amesh listen` on a target first, then `amesh invite` here'); } } diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 4bc493c..5bd8349 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,13 +1,7 @@ import { Command } from '@oclif/core'; +import { BACKEND_LABELS } from '@authmesh/keystore'; import { loadContext } from '../context.js'; -const BACKEND_LABELS: Record = { - 'secure-enclave': 'Secure Enclave', - 'keychain': 'macOS Keychain', - 'tpm2': 'TPM 2.0', - 'encrypted-file': 'Encrypted file', -}; - export default class List extends Command { static override description = 'Show this device and trusted devices in the allow list'; @@ -35,20 +29,24 @@ export default class List extends Command { this.log(' ' + '─'.repeat(55)); this.log(` Device ID : ${identity.deviceId}`); this.log(` Friendly Name : ${identity.friendlyName}`); - this.log(` Backend : ${BACKEND_LABELS[identity.storageBackend] ?? identity.storageBackend}`); + this.log( + ` Backend : ${BACKEND_LABELS[identity.storageBackend as keyof typeof BACKEND_LABELS] ?? identity.storageBackend}`, + ); this.log(` Created : ${identity.createdAt.split('T')[0]}`); this.log(''); if (data.devices.length === 0) { this.log(' No trusted devices yet.'); - this.log(' Run `amesh listen` to start pairing.'); + this.log(' Pair with another device using `amesh listen` + `amesh invite`.'); } else { this.log(` Trusted Devices (${data.devices.length})`); this.log(' ' + '─'.repeat(55)); for (const device of data.devices) { const date = device.addedAt.split('T')[0]; const roleTag = device.role === 'controller' ? '[controller]' : '[target]'; - this.log(` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`); + this.log( + ` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`, + ); } this.log(' ' + '─'.repeat(55)); } diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 89a459e..cd8786c 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -15,10 +15,12 @@ export interface AmeshContext { export async function loadContext(): Promise { const identity = await loadIdentity(getIdentityPath()); + const passphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; const keyStore = await createForBackend( identity.storageBackend as StorageBackend, getKeysDir(), - identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, + passphrase, ); const keyAlias = identity.keyAlias ?? identity.deviceId; diff --git a/packages/cli/src/shell-client.ts b/packages/cli/src/shell-client.ts index c94d8a3..6e7bc50 100644 --- a/packages/cli/src/shell-client.ts +++ b/packages/cli/src/shell-client.ts @@ -1,9 +1,8 @@ import { ShellCipher } from './shell-cipher.js'; import { AllowList, createForBackend } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { loadIdentity } from './identity.js'; +import { getIdentityPath, getKeysDir, getAllowListPath } from './paths.js'; import { runControllerShellHandshake, createMessageReader, send } from './shell-handshake.js'; import { FrameType, @@ -21,33 +20,20 @@ interface ShellOptions { command?: string; // -c mode } -interface Identity { - deviceId: string; - keyAlias?: string; - publicKey: string; - friendlyName: string; - storageBackend: string; - passphrase?: string; -} - -function getAmeshDir(): string { - return process.env.AUTH_MESH_DIR ?? join(homedir(), '.amesh'); -} - export async function connectShell(opts: ShellOptions): Promise { - const ameshDir = getAmeshDir(); - const identityContent = await readFile(join(ameshDir, 'identity.json'), 'utf-8'); - const identity = JSON.parse(identityContent) as Identity; + const identity = await loadIdentity(getIdentityPath()); + const passphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; const keyStore = await createForBackend( identity.storageBackend as StorageBackend, - join(ameshDir, 'keys'), - identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE, + getKeysDir(), + passphrase, ); const keyAlias = identity.keyAlias ?? identity.deviceId; const hmacKey = await keyStore.getHmacKeyMaterial(keyAlias); - const allowList = new AllowList(join(ameshDir, 'allow_list.json'), hmacKey, identity.deviceId); + const allowList = new AllowList(getAllowListPath(), hmacKey, identity.deviceId); const signFn = (message: Uint8Array) => keyStore.sign(keyAlias, message); // Resolve target: by device ID or friendly name @@ -89,9 +75,13 @@ export async function connectShell(opts: ShellOptions): Promise { let result; try { result = await runControllerShellHandshake( - ws, reader, - identity.deviceId, identity.publicKey, identity.friendlyName, - signFn, allowList, + ws, + reader, + identity.deviceId, + identity.publicKey, + identity.friendlyName, + signFn, + allowList, ); } catch (err) { console.error(`Handshake failed: ${(err as Error).message}`); @@ -126,7 +116,9 @@ export async function connectShell(opts: ShellOptions): Promise { // Handle terminal resize process.stdout.on('resize', () => { - const frame = cipher.encrypt(encodeResizeFrame(process.stdout.columns, process.stdout.rows)); + 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') })); } @@ -134,7 +126,9 @@ export async function connectShell(opts: ShellOptions): Promise { // Send initial resize if (process.stdout.columns && process.stdout.rows) { - const frame = cipher.encrypt(encodeResizeFrame(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') })); } } @@ -151,7 +145,11 @@ export async function connectShell(opts: ShellOptions): Promise { 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; } + try { + msg = JSON.parse(raw); + } catch { + return; + } if (msg.type !== 'data' || !msg.payload) return; diff --git a/packages/keystore/src/detect.ts b/packages/keystore/src/detect.ts index b581182..0fd8a0e 100644 --- a/packages/keystore/src/detect.ts +++ b/packages/keystore/src/detect.ts @@ -4,6 +4,19 @@ import type { KeyStore } from './interface.js'; export type StorageBackend = 'secure-enclave' | 'keychain' | 'tpm2' | 'encrypted-file'; +/** Human-readable labels for each storage backend. */ +export const BACKEND_LABELS: Record = { + 'secure-enclave': 'Secure Enclave', + keychain: 'macOS Keychain', + tpm2: 'TPM 2.0', + 'encrypted-file': 'Encrypted file', +}; + +/** Generate a 256-bit random passphrase for the encrypted-file backend. */ +export function generatePassphrase(): string { + return Buffer.from(randomBytes(32)).toString('hex'); +} + export interface DetectionResult { backend: StorageBackend; keyStore: KeyStore; @@ -27,9 +40,8 @@ export async function detectAndCreate( // Tier 1: macOS — Swift helper (Secure Enclave → software keychain) if (platform() === 'darwin') { try { - const { isMacOSKeychainAvailable, MacOSKeychainKeyStore } = await import( - './drivers/macos-keychain.js' - ); + const { isMacOSKeychainAvailable, MacOSKeychainKeyStore } = + await import('./drivers/macos-keychain.js'); const { available, backend } = await isMacOSKeychainAvailable(); if (available) { const keyStore = new MacOSKeychainKeyStore(basePath); @@ -69,8 +81,8 @@ export async function detectAndCreate( } // Tier 3: Encrypted file — always available, passphrase auto-generated - const passphrase = Buffer.from(randomBytes(32)).toString('hex'); - onProgress?.(' Encrypted file selected (auto-generated passphrase)'); + const passphrase = generatePassphrase(); + onProgress?.(' Encrypted file selected (software fallback)'); const { EncryptedFileKeyStore } = await import('./drivers/encrypted-file.js'); return { backend: 'encrypted-file', @@ -78,7 +90,7 @@ export async function detectAndCreate( passphrase, warning: 'Keys are software-protected (no hardware keystore detected).\n' + - ' To upgrade: install amesh-se-helper (macOS) or enable TPM 2.0 (Linux), then re-run `amesh init`.', + ' For hardware-backed storage, use macOS (Keychain) or Linux with TPM 2.0, then re-run `amesh init --force`.', }; } @@ -104,7 +116,7 @@ export async function createForBackend( if (!passphrase) { throw new Error( 'Encrypted-file backend requires a passphrase. ' + - 'Set AUTH_MESH_PASSPHRASE or pass --passphrase.', + 'Set AUTH_MESH_PASSPHRASE or re-run `amesh init` to auto-generate one.', ); } const { EncryptedFileKeyStore } = await import('./drivers/encrypted-file.js'); diff --git a/packages/keystore/src/index.ts b/packages/keystore/src/index.ts index 8b5e702..f5b80d9 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 { detectAndCreate, createForBackend } from './detect.js'; +export { detectAndCreate, createForBackend, BACKEND_LABELS, generatePassphrase } from './detect.js'; export type { StorageBackend, DetectionResult } from './detect.js'; diff --git a/packages/sdk/src/amesh.ts b/packages/sdk/src/amesh.ts index 6d45565..21945eb 100644 --- a/packages/sdk/src/amesh.ts +++ b/packages/sdk/src/amesh.ts @@ -25,11 +25,15 @@ function getAmeshDir(): string { } let cachedIdentity: Identity | null = null; +let cachedPassphrase: string | undefined; async function loadIdentity(): Promise { if (cachedIdentity) return cachedIdentity; const content = await readFile(join(getAmeshDir(), 'identity.json'), 'utf-8'); - cachedIdentity = JSON.parse(content) as Identity; + const identity = JSON.parse(content) as Identity; + cachedPassphrase = identity.passphrase ?? process.env.AUTH_MESH_PASSPHRASE; + delete identity.passphrase; + cachedIdentity = identity; return cachedIdentity; } @@ -45,7 +49,7 @@ async function ameshFetch(url: string | URL, init?: RequestInit): Promise clockSkew) { sendUnauthorized(res); return; } + if (isNaN(requestTs) || Math.abs(serverNow - requestTs) > clockSkew) { + sendUnauthorized(res); + return; + } - if (!(await nonceStore.checkAndRecord(parsed.nonce, nonceWindowSeconds))) { sendUnauthorized(res); return; } + if (!(await nonceStore.checkAndRecord(parsed.nonce, nonceWindowSeconds))) { + sendUnauthorized(res); + return; + } const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const body = await getBody(req); - const canonical = buildCanonicalString(req.method ?? 'GET', url.pathname + url.search, parsed.ts, parsed.nonce, body); + const canonical = buildCanonicalString( + req.method ?? 'GET', + url.pathname + url.search, + parsed.ts, + parsed.nonce, + body, + ); const signature = new Uint8Array(Buffer.from(parsed.sig, 'base64url')); const publicKey = new Uint8Array(Buffer.from(parsed.id, 'base64url')); - if (!verifyMessage(signature, new TextEncoder().encode(canonical), publicKey)) { sendUnauthorized(res); return; } + if (!verifyMessage(signature, new TextEncoder().encode(canonical), publicKey)) { + sendUnauthorized(res); + return; + } - req.authMesh = { deviceId: device.deviceId, friendlyName: device.friendlyName, verifiedAt: serverNow }; + req.authMesh = { + deviceId: device.deviceId, + friendlyName: device.friendlyName, + verifiedAt: serverNow, + }; next(); } catch (err) { if (err instanceof Error && err.message.includes('integrity check failed')) { @@ -168,7 +205,9 @@ function ameshVerify(opts?: { clockSkewSeconds?: number; nonceWindowSeconds?: nu * Handles: express.text() (string), express.raw() (Buffer), express.json() (object), * and no body parser (buffers from stream). */ -async function getBody(req: IncomingMessage & { body?: string | Buffer | object }): Promise { +async function getBody( + req: IncomingMessage & { body?: string | Buffer | object }, +): Promise { if (typeof req.body === 'string') return req.body; if (Buffer.isBuffer(req.body)) return req.body.toString('utf-8'); if (req.body !== null && req.body !== undefined && typeof req.body === 'object') { diff --git a/packages/sdk/src/bootstrap.ts b/packages/sdk/src/bootstrap.ts index c6c5569..927830f 100644 --- a/packages/sdk/src/bootstrap.ts +++ b/packages/sdk/src/bootstrap.ts @@ -29,7 +29,11 @@ interface BootstrapPayload { single_use: boolean; } -function decodeToken(token: string): { payload: BootstrapPayload; signatureInput: string; signature: Uint8Array } { +function decodeToken(token: string): { + payload: BootstrapPayload; + signatureInput: string; + signature: Uint8Array; +} { const parts = token.replace(/^amesh-bt-v1\./, '').split('.'); if (parts.length !== 3) throw new Error('invalid_token_format'); const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as BootstrapPayload; @@ -54,13 +58,15 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise if (!token && hasIdentity) return; // ready if (token && hasIdentity) { - console.warn('[amesh] AMESH_BOOTSTRAP_TOKEN is set but identity already exists. Ignoring token.'); + console.warn( + '[amesh] AMESH_BOOTSTRAP_TOKEN is set but identity already exists. Ignoring token.', + ); return; } if (!token && !hasIdentity) { throw new Error( 'No amesh identity found and no AMESH_BOOTSTRAP_TOKEN set.\n' + - 'Run `amesh init` on this machine or set AMESH_BOOTSTRAP_TOKEN.', + 'Run `amesh init` on this machine or set AMESH_BOOTSTRAP_TOKEN.', ); } @@ -83,18 +89,23 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise await new Promise((resolve, reject) => { const timeout = setTimeout( - () => { ws.close(); reject(new Error('bootstrap_timeout')); }, + () => { + ws.close(); + reject(new Error('bootstrap_timeout')); + }, (opts?.timeoutSeconds ?? 30) * 1000, ); ws.on('open', () => { // Send bootstrap init - ws.send(JSON.stringify({ - type: 'bootstrap_init', - jti: payload.jti, - token: token, - targetPubKey: Buffer.from(publicKey).toString('base64'), - })); + ws.send( + JSON.stringify({ + type: 'bootstrap_init', + jti: payload.jti, + token: token, + targetPubKey: Buffer.from(publicKey).toString('base64'), + }), + ); }); ws.on('message', async (raw: Buffer) => { @@ -129,7 +140,10 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise // Verify relay-provided key matches the token-embedded key if (msg.controllerPubKey) { const relayPubKey = new Uint8Array(Buffer.from(msg.controllerPubKey, 'base64')); - if (Buffer.from(controllerPubKey).toString('base64') !== Buffer.from(relayPubKey).toString('base64')) { + if ( + Buffer.from(controllerPubKey).toString('base64') !== + Buffer.from(relayPubKey).toString('base64') + ) { ws.close(); reject(new Error('controller_pubkey_mismatch')); return; @@ -154,10 +168,11 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise // Hardware keystores can't rename keys. Store a keyAlias mapping // so the real deviceId maps to the 'am_pending' key in hardware. - // Write identity.json - const { writeFile, mkdir } = await import('node:fs/promises'); + // Write identity.json (atomic: tmp + rename) + const { writeFile, mkdir, rename: renameFile } = await import('node:fs/promises'); const { dirname } = await import('node:path'); const identityPath = join(ameshDir, 'identity.json'); + const tmpPath = `${identityPath}.tmp`; await mkdir(dirname(identityPath), { recursive: true, mode: 0o700 }); const identityData = { version: '2.0.0', @@ -169,7 +184,11 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise storageBackend: backend, ...(autoPassphrase ? { passphrase: autoPassphrase } : {}), }; - await writeFile(identityPath, JSON.stringify(identityData, null, 2), { mode: 0o600 }); + await writeFile(tmpPath, JSON.stringify(identityData, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + await renameFile(tmpPath, identityPath); // Write allow list with controller const hmacKey = await keyStore.getHmacKeyMaterial('am_pending'); @@ -195,6 +214,9 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise } }); - ws.on('error', (err: Error) => { clearTimeout(timeout); reject(err); }); + ws.on('error', (err: Error) => { + clearTimeout(timeout); + reject(err); + }); }); } From 2da754df04bf9063507e970fdd8cfacc9467aac2 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Sat, 4 Apr 2026 09:16:00 +0300 Subject: [PATCH 3/5] style: prettier format across all packages --- .../agent/src/__tests__/shell-cipher.test.ts | 4 +- packages/agent/src/commands/agent/start.ts | 4 +- packages/agent/src/commands/grant.ts | 4 +- packages/agent/src/commands/invite.ts | 1 - packages/agent/src/commands/listen.ts | 7 ++- packages/agent/src/frame.ts | 23 ++++--- packages/agent/src/handshake.ts | 19 ++++-- packages/agent/src/shell-cipher.ts | 2 +- packages/agent/src/shell-handshake.ts | 25 ++++++-- .../cli/src/__tests__/shell-cipher.test.ts | 4 +- packages/cli/src/commands/grant.ts | 4 +- packages/cli/src/commands/invite.ts | 1 - packages/cli/src/commands/listen.ts | 7 ++- packages/cli/src/frame.ts | 23 ++++--- packages/cli/src/handshake.ts | 19 ++++-- packages/cli/src/shell-cipher.ts | 2 +- packages/cli/src/shell-handshake.ts | 25 ++++++-- packages/core/src/__tests__/canonical.test.ts | 8 ++- packages/core/src/__tests__/ecdh.test.ts | 6 +- packages/core/src/__tests__/hmac.test.ts | 4 +- packages/core/src/hkdf.ts | 7 +-- .../keystore/src/__tests__/allow-list.test.ts | 35 ++++++----- .../src/__tests__/encrypted-file.test.ts | 12 ++-- packages/keystore/src/allow-list.ts | 10 ++- packages/relay/src/agent-store.ts | 10 ++- packages/relay/src/server.ts | 63 ++++++++++++++----- packages/relay/src/session.ts | 12 +++- packages/sdk/src/__tests__/header.test.ts | 4 +- packages/sdk/src/middleware.ts | 8 ++- 29 files changed, 245 insertions(+), 108 deletions(-) diff --git a/packages/agent/src/__tests__/shell-cipher.test.ts b/packages/agent/src/__tests__/shell-cipher.test.ts index 5bfdea3..06e1fed 100644 --- a/packages/agent/src/__tests__/shell-cipher.test.ts +++ b/packages/agent/src/__tests__/shell-cipher.test.ts @@ -83,7 +83,9 @@ describe('ShellCipher', () => { }); it('rejects session key of wrong length', () => { - expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow('Session key must be 32 bytes'); + expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow( + 'Session key must be 32 bytes', + ); }); it('controller and target nonces do not overlap', () => { diff --git a/packages/agent/src/commands/agent/start.ts b/packages/agent/src/commands/agent/start.ts index 8d23d0f..f5f6c0c 100644 --- a/packages/agent/src/commands/agent/start.ts +++ b/packages/agent/src/commands/agent/start.ts @@ -26,8 +26,8 @@ export default class AgentStart extends Command { 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 agent start', + ' Install Bun: curl -fsSL https://bun.sh/install | bash\n' + + ' Then run: bun amesh-agent agent start', ); } diff --git a/packages/agent/src/commands/grant.ts b/packages/agent/src/commands/grant.ts index 488c8e5..cea5198 100644 --- a/packages/agent/src/commands/grant.ts +++ b/packages/agent/src/commands/grant.ts @@ -22,7 +22,9 @@ export default class Grant extends Command { const { args, flags } = await this.parse(Grant); if (flags.shell === undefined) { - this.error('Specify a permission to grant or revoke. Example: amesh grant --shell'); + this.error( + 'Specify a permission to grant or revoke. Example: amesh grant --shell', + ); } const { allowList } = await loadContext().catch(() => { diff --git a/packages/agent/src/commands/invite.ts b/packages/agent/src/commands/invite.ts index 53b618d..30b2381 100644 --- a/packages/agent/src/commands/invite.ts +++ b/packages/agent/src/commands/invite.ts @@ -79,5 +79,4 @@ export default class Invite extends Command { this.log(' Pairing complete. The relay connection is closed.'); this.log(''); } - } diff --git a/packages/agent/src/commands/listen.ts b/packages/agent/src/commands/listen.ts index 94c68d6..875dd34 100644 --- a/packages/agent/src/commands/listen.ts +++ b/packages/agent/src/commands/listen.ts @@ -81,11 +81,14 @@ export default class Listen extends Command { }; // Enforce maxControllers limit (default: 1) - const maxControllers = (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; + const maxControllers = + (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; const currentControllers = await allowList.countByRole('controller'); if (currentControllers >= maxControllers) { - this.log(` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`); + this.log( + ` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`, + ); const replace = await this.confirm(' Replace existing controller(s)? (Y/n): '); if (!replace) { this.log(''); diff --git a/packages/agent/src/frame.ts b/packages/agent/src/frame.ts index 8da47d5..b963b22 100644 --- a/packages/agent/src/frame.ts +++ b/packages/agent/src/frame.ts @@ -6,12 +6,12 @@ */ 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) + 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]; @@ -57,13 +57,18 @@ export function encodeCommandFrame(command: string): Uint8Array { } const VALID_FRAME_TYPES = new Set([ - FrameType.DATA, FrameType.RESIZE, FrameType.EXIT, - FrameType.PING, FrameType.PONG, FrameType.COMMAND, + 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)}`); + if (!VALID_FRAME_TYPES.has(frame[0])) + throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`); return { type: frame[0] as FrameTypeValue, payload: frame.subarray(1), diff --git a/packages/agent/src/handshake.ts b/packages/agent/src/handshake.ts index b2933cb..54d194c 100644 --- a/packages/agent/src/handshake.ts +++ b/packages/agent/src/handshake.ts @@ -29,7 +29,10 @@ function send(ws: WebSocket, msg: object): void { */ function createMessageReader(ws: WebSocket) { const queue: Record[] = []; - let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + 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); @@ -54,8 +57,14 @@ function createMessageReader(ws: WebSocket) { reject(new Error('Timeout waiting for message')); }, timeoutMs); waiter = { - resolve: (msg) => { clearTimeout(timer); resolve(msg); }, - reject: (err) => { clearTimeout(timer); reject(err); }, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, }; }); }, @@ -96,7 +105,9 @@ export function computeSAS( controllerPubKey: Uint8Array, sharedSecret: Uint8Array, ): string { - const combined = new Uint8Array(targetPubKey.length + controllerPubKey.length + sharedSecret.length); + const combined = new Uint8Array( + targetPubKey.length + controllerPubKey.length + sharedSecret.length, + ); combined.set(targetPubKey, 0); combined.set(controllerPubKey, targetPubKey.length); combined.set(sharedSecret, targetPubKey.length + controllerPubKey.length); diff --git a/packages/agent/src/shell-cipher.ts b/packages/agent/src/shell-cipher.ts index 6f9d286..da15037 100644 --- a/packages/agent/src/shell-cipher.ts +++ b/packages/agent/src/shell-cipher.ts @@ -81,7 +81,7 @@ export class ShellCipher { this.recvNonceStart.fill(0); } - private static readonly MAX_COUNTER = (2n ** 64n) - 1n; + private static readonly MAX_COUNTER = 2n ** 64n - 1n; private nextSendNonce(): Uint8Array { if (this.sendCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); diff --git a/packages/agent/src/shell-handshake.ts b/packages/agent/src/shell-handshake.ts index cb731cd..3dd4d51 100644 --- a/packages/agent/src/shell-handshake.ts +++ b/packages/agent/src/shell-handshake.ts @@ -29,7 +29,10 @@ function send(ws: WebSocket, msg: object): void { function createMessageReader(ws: WebSocket) { const queue: Record[] = []; - let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + 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); @@ -52,8 +55,14 @@ function createMessageReader(ws: WebSocket) { reject(new Error('Timeout waiting for message')); }, timeoutMs); waiter = { - resolve: (msg) => { clearTimeout(timer); resolve(msg); }, - reject: (err) => { clearTimeout(timer); reject(err); }, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, }; }); }, @@ -149,7 +158,10 @@ export async function runAgentShellHandshake( timestamp, selfSig: Buffer.from(selfSig).toString('base64'), }; - send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + 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); @@ -203,7 +215,10 @@ export async function runControllerShellHandshake( timestamp, selfSig: Buffer.from(selfSig).toString('base64'), }; - send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + send(ws, { + type: 'data', + payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))), + }); // Step 4: Receive agent identity const encPeerIdentity = await reader.read(); diff --git a/packages/cli/src/__tests__/shell-cipher.test.ts b/packages/cli/src/__tests__/shell-cipher.test.ts index 5bfdea3..06e1fed 100644 --- a/packages/cli/src/__tests__/shell-cipher.test.ts +++ b/packages/cli/src/__tests__/shell-cipher.test.ts @@ -83,7 +83,9 @@ describe('ShellCipher', () => { }); it('rejects session key of wrong length', () => { - expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow('Session key must be 32 bytes'); + expect(() => new ShellCipher(new Uint8Array(16), 'controller')).toThrow( + 'Session key must be 32 bytes', + ); }); it('controller and target nonces do not overlap', () => { diff --git a/packages/cli/src/commands/grant.ts b/packages/cli/src/commands/grant.ts index 488c8e5..cea5198 100644 --- a/packages/cli/src/commands/grant.ts +++ b/packages/cli/src/commands/grant.ts @@ -22,7 +22,9 @@ export default class Grant extends Command { const { args, flags } = await this.parse(Grant); if (flags.shell === undefined) { - this.error('Specify a permission to grant or revoke. Example: amesh grant --shell'); + this.error( + 'Specify a permission to grant or revoke. Example: amesh grant --shell', + ); } const { allowList } = await loadContext().catch(() => { diff --git a/packages/cli/src/commands/invite.ts b/packages/cli/src/commands/invite.ts index 53b618d..30b2381 100644 --- a/packages/cli/src/commands/invite.ts +++ b/packages/cli/src/commands/invite.ts @@ -79,5 +79,4 @@ export default class Invite extends Command { this.log(' Pairing complete. The relay connection is closed.'); this.log(''); } - } diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index 94c68d6..875dd34 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -81,11 +81,14 @@ export default class Listen extends Command { }; // Enforce maxControllers limit (default: 1) - const maxControllers = (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; + const maxControllers = + (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; const currentControllers = await allowList.countByRole('controller'); if (currentControllers >= maxControllers) { - this.log(` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`); + this.log( + ` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`, + ); const replace = await this.confirm(' Replace existing controller(s)? (Y/n): '); if (!replace) { this.log(''); diff --git a/packages/cli/src/frame.ts b/packages/cli/src/frame.ts index 8da47d5..b963b22 100644 --- a/packages/cli/src/frame.ts +++ b/packages/cli/src/frame.ts @@ -6,12 +6,12 @@ */ 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) + 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]; @@ -57,13 +57,18 @@ export function encodeCommandFrame(command: string): Uint8Array { } const VALID_FRAME_TYPES = new Set([ - FrameType.DATA, FrameType.RESIZE, FrameType.EXIT, - FrameType.PING, FrameType.PONG, FrameType.COMMAND, + 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)}`); + if (!VALID_FRAME_TYPES.has(frame[0])) + throw new Error(`Unknown frame type: 0x${frame[0].toString(16)}`); return { type: frame[0] as FrameTypeValue, payload: frame.subarray(1), diff --git a/packages/cli/src/handshake.ts b/packages/cli/src/handshake.ts index b2933cb..54d194c 100644 --- a/packages/cli/src/handshake.ts +++ b/packages/cli/src/handshake.ts @@ -29,7 +29,10 @@ function send(ws: WebSocket, msg: object): void { */ function createMessageReader(ws: WebSocket) { const queue: Record[] = []; - let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + 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); @@ -54,8 +57,14 @@ function createMessageReader(ws: WebSocket) { reject(new Error('Timeout waiting for message')); }, timeoutMs); waiter = { - resolve: (msg) => { clearTimeout(timer); resolve(msg); }, - reject: (err) => { clearTimeout(timer); reject(err); }, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, }; }); }, @@ -96,7 +105,9 @@ export function computeSAS( controllerPubKey: Uint8Array, sharedSecret: Uint8Array, ): string { - const combined = new Uint8Array(targetPubKey.length + controllerPubKey.length + sharedSecret.length); + const combined = new Uint8Array( + targetPubKey.length + controllerPubKey.length + sharedSecret.length, + ); combined.set(targetPubKey, 0); combined.set(controllerPubKey, targetPubKey.length); combined.set(sharedSecret, targetPubKey.length + controllerPubKey.length); diff --git a/packages/cli/src/shell-cipher.ts b/packages/cli/src/shell-cipher.ts index 6f9d286..da15037 100644 --- a/packages/cli/src/shell-cipher.ts +++ b/packages/cli/src/shell-cipher.ts @@ -81,7 +81,7 @@ export class ShellCipher { this.recvNonceStart.fill(0); } - private static readonly MAX_COUNTER = (2n ** 64n) - 1n; + private static readonly MAX_COUNTER = 2n ** 64n - 1n; private nextSendNonce(): Uint8Array { if (this.sendCounter >= ShellCipher.MAX_COUNTER) throw new Error('Nonce space exhausted'); diff --git a/packages/cli/src/shell-handshake.ts b/packages/cli/src/shell-handshake.ts index cb731cd..3dd4d51 100644 --- a/packages/cli/src/shell-handshake.ts +++ b/packages/cli/src/shell-handshake.ts @@ -29,7 +29,10 @@ function send(ws: WebSocket, msg: object): void { function createMessageReader(ws: WebSocket) { const queue: Record[] = []; - let waiter: { resolve: (msg: Record) => void; reject: (err: Error) => void } | null = null; + 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); @@ -52,8 +55,14 @@ function createMessageReader(ws: WebSocket) { reject(new Error('Timeout waiting for message')); }, timeoutMs); waiter = { - resolve: (msg) => { clearTimeout(timer); resolve(msg); }, - reject: (err) => { clearTimeout(timer); reject(err); }, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, }; }); }, @@ -149,7 +158,10 @@ export async function runAgentShellHandshake( timestamp, selfSig: Buffer.from(selfSig).toString('base64'), }; - send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + 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); @@ -203,7 +215,10 @@ export async function runControllerShellHandshake( timestamp, selfSig: Buffer.from(selfSig).toString('base64'), }; - send(ws, { type: 'data', payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))) }); + send(ws, { + type: 'data', + payload: encrypt(tempKey, new TextEncoder().encode(JSON.stringify(myIdentity))), + }); // Step 4: Receive agent identity const encPeerIdentity = await reader.read(); diff --git a/packages/core/src/__tests__/canonical.test.ts b/packages/core/src/__tests__/canonical.test.ts index 1b9ba1e..4888f26 100644 --- a/packages/core/src/__tests__/canonical.test.ts +++ b/packages/core/src/__tests__/canonical.test.ts @@ -89,7 +89,13 @@ describe('buildCanonicalString', () => { // Spec Appendix A test vector it('matches spec test vector', () => { const body = '{"amount":100}'; - const result = buildCanonicalString('POST', '/api/orders?b=2&a=1', '1743160800', 'dGVzdG5vbmNl', body); + const result = buildCanonicalString( + 'POST', + '/api/orders?b=2&a=1', + '1743160800', + 'dGVzdG5vbmNl', + body, + ); const lines = result.split('\n'); expect(lines[0]).toBe('AMv1'); expect(lines[1]).toBe('POST'); diff --git a/packages/core/src/__tests__/ecdh.test.ts b/packages/core/src/__tests__/ecdh.test.ts index 6ee75a6..66e14a2 100644 --- a/packages/core/src/__tests__/ecdh.test.ts +++ b/packages/core/src/__tests__/ecdh.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from 'bun:test'; -import { - generateEphemeralKeyPair, - computeSharedSecret, - deriveSessionKey, -} from '../ecdh.js'; +import { generateEphemeralKeyPair, computeSharedSecret, deriveSessionKey } from '../ecdh.js'; describe('generateEphemeralKeyPair', () => { it('returns private and public keys', () => { diff --git a/packages/core/src/__tests__/hmac.test.ts b/packages/core/src/__tests__/hmac.test.ts index dfc7c82..da956c6 100644 --- a/packages/core/src/__tests__/hmac.test.ts +++ b/packages/core/src/__tests__/hmac.test.ts @@ -78,9 +78,7 @@ describe('verifyHmac', () => { // Adversarial: allow list tamper detection scenario it('detects allow list tampering', () => { - const allowList = new TextEncoder().encode( - JSON.stringify({ devices: [{ id: 'am_abc123' }] }), - ); + const allowList = new TextEncoder().encode(JSON.stringify({ devices: [{ id: 'am_abc123' }] })); const seal = computeHmac(key, allowList); expect(verifyHmac(key, allowList, seal)).toBe(true); diff --git a/packages/core/src/hkdf.ts b/packages/core/src/hkdf.ts index e69c0c0..f67a4bd 100644 --- a/packages/core/src/hkdf.ts +++ b/packages/core/src/hkdf.ts @@ -11,11 +11,6 @@ const encoder = new TextEncoder(); * @param info - Context/info string * @param length - Output key length in bytes (default: 32) */ -export function deriveKey( - ikm: Uint8Array, - salt: string, - info: string, - length = 32, -): Uint8Array { +export function deriveKey(ikm: Uint8Array, salt: string, info: string, length = 32): Uint8Array { return hkdf(sha256, ikm, encoder.encode(salt), encoder.encode(info), length); } diff --git a/packages/keystore/src/__tests__/allow-list.test.ts b/packages/keystore/src/__tests__/allow-list.test.ts index 45a956f..cd2f736 100644 --- a/packages/keystore/src/__tests__/allow-list.test.ts +++ b/packages/keystore/src/__tests__/allow-list.test.ts @@ -13,7 +13,11 @@ function filePath() { return join(tempDir, 'allow_list.json'); } -function makeDevice(id: string, name: string, role: 'controller' | 'target' = 'controller'): AllowListDevice { +function makeDevice( + id: string, + name: string, + role: 'controller' | 'target' = 'controller', +): AllowListDevice { return { deviceId: id, publicKey: Buffer.from(new Uint8Array(33).fill(0x02)).toString('base64'), @@ -202,16 +206,23 @@ describe('AllowList', () => { // sealed with valid HMAC (simulating pre-role allow list) const { computeHmac } = await import('@authmesh/core'); const { deriveKey } = await import('@authmesh/core'); - const hmacKey = deriveKey(PRIVATE_KEY_MATERIAL, 'amesh-allow-list-integrity-v1', DEVICE_ID, 32); + const hmacKey = deriveKey( + PRIVATE_KEY_MATERIAL, + 'amesh-allow-list-integrity-v1', + DEVICE_ID, + 32, + ); const legacyData = { version: '2.0.0', - devices: [{ - deviceId: 'am_legacy', - publicKey: content.devices[0].publicKey, - friendlyName: 'Legacy', - addedAt: content.devices[0].addedAt, - addedBy: 'handshake', - }], + devices: [ + { + deviceId: 'am_legacy', + publicKey: content.devices[0].publicKey, + friendlyName: 'Legacy', + addedAt: content.devices[0].addedAt, + addedBy: 'handshake', + }, + ], updatedAt: new Date().toISOString(), }; const canonical = JSON.stringify({ @@ -281,11 +292,7 @@ describe('AllowList', () => { // Attacker creates a valid HMAC but with different key material const differentKey = new Uint8Array(32).fill(0xcd); - const alEvil = new AllowList( - join(tempDir, 'evil_list.json'), - differentKey, - DEVICE_ID, - ); + const alEvil = new AllowList(join(tempDir, 'evil_list.json'), differentKey, DEVICE_ID); const evilData = await alEvil.read(); // Copy evil HMAC to legitimate file diff --git a/packages/keystore/src/__tests__/encrypted-file.test.ts b/packages/keystore/src/__tests__/encrypted-file.test.ts index 6d2d4ae..a2196b6 100644 --- a/packages/keystore/src/__tests__/encrypted-file.test.ts +++ b/packages/keystore/src/__tests__/encrypted-file.test.ts @@ -115,9 +115,9 @@ describe('EncryptedFileKeyStore', () => { await store.generateAndStore(DEVICE_ID); const wrongStore = new EncryptedFileKeyStore(tempDir, 'wrong-passphrase'); - await expect( - wrongStore.sign(DEVICE_ID, new TextEncoder().encode('test')), - ).rejects.toThrow(/[Dd]ecryption failed/); + await expect(wrongStore.sign(DEVICE_ID, new TextEncoder().encode('test'))).rejects.toThrow( + /[Dd]ecryption failed/, + ); }); }); @@ -170,9 +170,9 @@ describe('EncryptedFileKeyStore', () => { content.ciphertext = ct.toString('base64'); await writeFile(filePath, JSON.stringify(content)); - await expect( - store.sign(DEVICE_ID, new TextEncoder().encode('test')), - ).rejects.toThrow(/[Dd]ecryption failed/); + await expect(store.sign(DEVICE_ID, new TextEncoder().encode('test'))).rejects.toThrow( + /[Dd]ecryption failed/, + ); }); }); }); diff --git a/packages/keystore/src/allow-list.ts b/packages/keystore/src/allow-list.ts index 3f26d67..2633ed5 100644 --- a/packages/keystore/src/allow-list.ts +++ b/packages/keystore/src/allow-list.ts @@ -149,7 +149,10 @@ export class AllowList { * Replace all devices with the given role with a single new device. * Used to enforce single-controller limit on targets. */ - async replaceByRole(role: 'controller' | 'target', device: AllowListDevice): Promise { + async replaceByRole( + role: 'controller' | 'target', + device: AllowListDevice, + ): Promise { const data = await this.read(); data.devices = data.devices.filter((d) => d.role !== role); data.devices.push(device); @@ -161,7 +164,10 @@ export class AllowList { /** * Update permissions for a device. Reseals the allow list. */ - async updatePermissions(deviceId: string, permissions: DevicePermissions): Promise { + async updatePermissions( + deviceId: string, + permissions: DevicePermissions, + ): Promise { const data = await this.read(); const device = data.devices.find((d) => d.deviceId === deviceId); if (!device) { diff --git a/packages/relay/src/agent-store.ts b/packages/relay/src/agent-store.ts index 4a1c2c9..4c04759 100644 --- a/packages/relay/src/agent-store.ts +++ b/packages/relay/src/agent-store.ts @@ -47,7 +47,10 @@ export class AgentStore { * 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 { + matchAndGet( + deviceId: string, + expectedPublicKey: string, + ): ServerWebSocket | undefined { const entry = this.agents.get(deviceId); if (!entry) return undefined; if (entry.publicKey !== expectedPublicKey) return undefined; @@ -83,7 +86,10 @@ export class AgentStore { 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) { + if ( + now - entry.lastPing > this.heartbeatTimeoutMs || + entry.socket.readyState !== WebSocket.OPEN + ) { this.agents.delete(deviceId); } } diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 5a69962..1a5040d 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -5,7 +5,19 @@ import { RateLimiter, OTCAttemptTracker } from './rate-limit.js'; import { AgentStore } from './agent-store.js'; interface RelayMessage { - type: 'listen' | 'connect' | 'data' | 'done' | 'ping' | 'agent' | 'agent_challenge_response' | 'shell' | 'bootstrap_watch' | 'bootstrap_init' | 'bootstrap_ack' | 'bootstrap_reject'; + type: + | 'listen' + | 'connect' + | 'data' + | 'done' + | 'ping' + | 'agent' + | 'agent_challenge_response' + | 'shell' + | 'bootstrap_watch' + | 'bootstrap_init' + | 'bootstrap_ack' + | 'bootstrap_reject'; otc?: string; payload?: string; jti?: string; @@ -38,7 +50,10 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { const shellRateLimiter = new RateLimiter(5, 60_000); const otcAttempts = new OTCAttemptTracker(5); // Bootstrap watchers: jti → { socket, createdAt } - const bootstrapWatchers = new Map; createdAt: number }>(); + const bootstrapWatchers = new Map< + string, + { socket: ServerWebSocket; createdAt: number } + >(); // Track all connected sockets for bootstrap response routing const connectedSockets = new Set>(); let connectionCount = 0; @@ -47,7 +62,10 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { const bootstrapCleanupTimer = setInterval(() => { const now = Date.now(); for (const [jti, entry] of bootstrapWatchers) { - if (now - entry.createdAt > BOOTSTRAP_WATCHER_TTL_MS || entry.socket.readyState !== WebSocket.OPEN) { + if ( + now - entry.createdAt > BOOTSTRAP_WATCHER_TTL_MS || + entry.socket.readyState !== WebSocket.OPEN + ) { bootstrapWatchers.delete(jti); } } @@ -148,14 +166,20 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { // Bootstrap: controller registers to watch for a specific jti function handleBootstrapWatch(ws: ServerWebSocket, msg: RelayMessage) { - if (!msg.jti) { ws.send(JSON.stringify({ type: 'error', code: 'missing_jti' })); return; } + if (!msg.jti) { + ws.send(JSON.stringify({ type: 'error', code: 'missing_jti' })); + return; + } bootstrapWatchers.set(msg.jti, { socket: ws, createdAt: Date.now() }); ws.send(JSON.stringify({ type: 'bootstrap_watching', jti: msg.jti })); } // Bootstrap: target initiates pairing with a token function handleBootstrapInit(ws: ServerWebSocket, msg: RelayMessage) { - if (!msg.jti) { ws.send(JSON.stringify({ type: 'error', code: 'missing_jti' })); return; } + if (!msg.jti) { + ws.send(JSON.stringify({ type: 'error', code: 'missing_jti' })); + return; + } const entry = bootstrapWatchers.get(msg.jti); if (!entry || entry.socket.readyState !== WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'bootstrap_reject', error: 'no_watcher' })); @@ -164,12 +188,14 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { // Store target socket for response routing ws.data.btJti = msg.jti; // Whitelist forwarded fields — do not forward arbitrary attacker-controlled data - entry.socket.send(JSON.stringify({ - type: msg.type, - jti: msg.jti, - token: msg.token, - targetPubKey: msg.targetPubKey, - })); + entry.socket.send( + JSON.stringify({ + type: msg.type, + jti: msg.jti, + token: msg.token, + targetPubKey: msg.targetPubKey, + }), + ); } // Bootstrap: controller responds (ack or reject) — forward to target @@ -189,7 +215,10 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { } // Pending agent challenges: ws → { deviceId, publicKey, challenge } - const pendingChallenges = new Map, { deviceId: string; publicKey: string; challenge: string }>(); + const pendingChallenges = new Map< + ServerWebSocket, + { deviceId: string; publicKey: string; challenge: string } + >(); // Shell: agent registration step 1 — issue challenge function handleAgent(ws: ServerWebSocket, msg: RelayMessage) { @@ -313,7 +342,9 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { return { sessions, - get server() { return server; }, + get server() { + return server; + }, start() { server = Bun.serve({ hostname: host, @@ -322,7 +353,11 @@ export function createRelayServer(opts?: { host?: string; port?: number }) { const url = new URL(req.url); if (url.pathname === '/health') { - return Response.json({ status: 'ok', sessions: sessions.size, agents: agentStore.size }); + return Response.json({ + status: 'ok', + sessions: sessions.size, + agents: agentStore.size, + }); } if (url.pathname === '/ws') { diff --git a/packages/relay/src/session.ts b/packages/relay/src/session.ts index a4e55ae..ce2f897 100644 --- a/packages/relay/src/session.ts +++ b/packages/relay/src/session.ts @@ -70,8 +70,16 @@ export class SessionStore { for (const [otc, session] of this.sessions) { if (now > session.expiresAt) { // Close connections if still open - try { session.target.close(); } catch { /* ignore */ } - try { session.controller?.close(); } catch { /* ignore */ } + try { + session.target.close(); + } catch { + /* ignore */ + } + try { + session.controller?.close(); + } catch { + /* ignore */ + } this.sessions.delete(otc); } } diff --git a/packages/sdk/src/__tests__/header.test.ts b/packages/sdk/src/__tests__/header.test.ts index 9dc97ec..5a24661 100644 --- a/packages/sdk/src/__tests__/header.test.ts +++ b/packages/sdk/src/__tests__/header.test.ts @@ -10,7 +10,9 @@ describe('buildAuthHeader', () => { nonce: 'abc123', sig: 'sig456', }); - expect(header).toBe('AuthMesh v="1",id="pubkey123",ts="1743160800",nonce="abc123",sig="sig456"'); + expect(header).toBe( + 'AuthMesh v="1",id="pubkey123",ts="1743160800",nonce="abc123",sig="sig456"', + ); }); }); diff --git a/packages/sdk/src/middleware.ts b/packages/sdk/src/middleware.ts index 6f1d718..43730b2 100644 --- a/packages/sdk/src/middleware.ts +++ b/packages/sdk/src/middleware.ts @@ -142,7 +142,9 @@ function sendError(res: ServerResponse, status: number, _code: string): void { * - express.json() → req.body is an object (re-serialized deterministically) * - No body parser → buffer from the request stream */ -async function getBody(req: IncomingMessage & { body?: string | Buffer | object }): Promise { +async function getBody( + req: IncomingMessage & { body?: string | Buffer | object }, +): Promise { if (typeof req.body === 'string') return req.body; if (Buffer.isBuffer(req.body)) return req.body.toString('utf-8'); // express.json() or similar parsed body into an object — re-serialize deterministically @@ -160,5 +162,7 @@ async function getBody(req: IncomingMessage & { body?: string | Buffer | object function logServerSide(code: string, deviceId: string, serverNow: number, requestTs: number): void { // Server-side logging only — never exposed to client - console.error(`[amesh] ${code}: device=${deviceId} serverNow=${serverNow} requestTs=${requestTs}`); + console.error( + `[amesh] ${code}: device=${deviceId} serverNow=${serverNow} requestTs=${requestTs}`, + ); } From 95fa677d911fee93b73335823cccfd7f0fcdf217 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Sat, 4 Apr 2026 09:28:38 +0300 Subject: [PATCH 4/5] docs: content precision, docs sidebar, use cases nav fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content precision: - Fix "first that requires no shared secrets" (mTLS predates amesh) - Fix "Nothing to leak" → "One device" blast radius in comparison table - Qualify "Secrets on disk" for encrypted-file backend - Fix "Same as Signal and Bluetooth" → "similar to Bluetooth pairing" - Replace SOC2 claims with "per-device audit trail" - Fix non-existent ghcr.io Docker image reference - Fix Node.js REPL → Bun REPL in guide.md - Remove sales copy from technical docs (integration guide, docs hub) Docs sidebar: - New DocsSidebar.svelte (guides, reference, packages sections) - New /docs/+layout.svelte (sidebar + content flex layout) - Desktop: persistent left sidebar. Mobile: collapsible dropdown. Use cases nav: - Add Remote Shell to /use-cases index (was missing) - Make "Use Cases" navbar text a clickable link to /use-cases - Replace docs hub "Use Cases" grid with single cross-link --- docs/guide.md | 8 +- .../src/lib/components/DocsSidebar.svelte | 133 ++++++++++++++++++ landpage/src/lib/components/Nav.svelte | 15 +- landpage/src/routes/+page.svelte | 18 +-- landpage/src/routes/docs/+layout.svelte | 32 +++++ landpage/src/routes/docs/+page.svelte | 20 +-- .../src/routes/docs/integration/+page.svelte | 6 +- .../src/routes/docs/self-hosting/+page.svelte | 2 +- landpage/src/routes/use-cases/+page.svelte | 5 +- .../routes/use-cases/cron-jobs/+page.svelte | 2 +- .../use-cases/internal-tools/+page.svelte | 8 +- 11 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 landpage/src/lib/components/DocsSidebar.svelte create mode 100644 landpage/src/routes/docs/+layout.svelte diff --git a/docs/guide.md b/docs/guide.md index 033aa48..d989c95 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -53,7 +53,7 @@ This creates two files: - `~/.amesh/identity.json` — your device ID, public key, friendly name - `~/.amesh/allow_list.json` — HMAC-sealed trust store (starts empty) -The private key is protected by the OS keychain (macOS) or TPM (Linux) and never written to disk as plaintext. +The private key is protected by the OS keychain (macOS), TPM (Linux), or encrypted with Argon2id (file backend). Hardware-backed keys never leave the secure element. To use a custom directory (useful for testing): ```bash @@ -114,11 +114,11 @@ Prompts for confirmation, then removes the device from the allow list and reseal ## 5. Use the Crypto Primitives Directly -Open a Node.js REPL from the core package: +Open a REPL from the core package: ```bash cd packages/core -node --input-type=module +bun repl ``` ### Sign and verify a message @@ -244,7 +244,7 @@ The server automatically: 5. Verifies the ECDSA-P256-SHA256 signature 6. Attaches `req.authMesh` with the verified device identity -**No API key. No Bearer token. No secret to leak.** +**No API key. No Bearer token. No shared secret.** --- diff --git a/landpage/src/lib/components/DocsSidebar.svelte b/landpage/src/lib/components/DocsSidebar.svelte new file mode 100644 index 0000000..c6d73b4 --- /dev/null +++ b/landpage/src/lib/components/DocsSidebar.svelte @@ -0,0 +1,133 @@ + + + + + + +
+ + {#if mobileOpen} + + {/if} +
diff --git a/landpage/src/lib/components/Nav.svelte b/landpage/src/lib/components/Nav.svelte index 80a32a9..e3af607 100644 --- a/landpage/src/lib/components/Nav.svelte +++ b/landpage/src/lib/components/Nav.svelte @@ -14,12 +14,15 @@