From 8114a9dc6d0d8f507b11b4a477dea9f2825916e1 Mon Sep 17 00:00:00 2001 From: YairEtzion Date: Thu, 2 Apr 2026 18:53:51 +0300 Subject: [PATCH] feat: re-add encrypted-file backend, fix body parsing, reframe messaging Re-add encrypted-file keystore as explicit opt-in for cloud VMs: - `amesh init --backend encrypted-file --passphrase ` - Auto-detected only when passphrase is provided and no hardware found - CLI, SDK, and context all pass AUTH_MESH_PASSPHRASE through Fix SDK body parsing footgun: - Middleware now handles express.json(), express.text(), raw streams - No longer requires express.text({ type: '*/*' }) workaround - Buffers from stream if no body parser ran Reframe messaging from "hardware-bound" to "device-bound": - Landing page, README, protocol spec, guides, packaging - Remove "key is in silicon", "signed by hardware" claims - Add language limitation disclosure (TypeScript/Node.js) - Remove Fastify from landing page CTA (doesn't exist yet) - Remove "serverless" from protocol spec target user - Fix ADR-003 note about encrypted-file removal --- .github/workflows/release-packages.yml | 2 +- README.md | 10 +- docs/architecture-decisions.md | 2 +- docs/guide.md | 10 +- docs/integration-guide.md | 22 +- docs/project-review-2026-04-02.md | 247 ++++++++++++++++++ docs/protocol-spec.md | 16 +- docs/why-amesh.md | 8 +- landpage/src/app.html | 12 +- landpage/src/routes/+page.svelte | 10 +- landpage/src/routes/docs/+page.svelte | 2 +- packages/cli/src/commands/init.ts | 44 +++- packages/cli/src/context.ts | 1 + packages/core/package.json | 2 +- packages/keystore/README.md | 5 +- .../keystore/src/__tests__/detect.test.ts | 11 + packages/keystore/src/detect.ts | 35 ++- packages/sdk/README.md | 3 +- packages/sdk/src/amesh.ts | 29 +- packages/sdk/src/middleware.ts | 25 +- packaging/homebrew/amesh.rb | 2 +- packaging/nfpm.yaml | 2 +- 22 files changed, 439 insertions(+), 61 deletions(-) create mode 100644 docs/project-review-2026-04-02.md diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 51d44fa..5ba423f 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -132,7 +132,7 @@ jobs: VERSION=${{ steps.version.outputs.VERSION }} cat > homebrew-tap/Formula/amesh.rb << FORMULA class Amesh < Formula - desc "Hardware-bound M2M authentication CLI — replaces static API keys with device identities" + desc "Device-bound M2M authentication CLI — replaces static API keys with device identities" homepage "https://github.com/ameshdev/amesh" version "${VERSION}" license "MIT" diff --git a/README.md b/README.md index cf08e11..2a9dc2c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ app.use(amesh.verify()); ## How It Works -- **Device identity** --- each machine gets a unique P-256 ECDSA keypair. The private key is protected by the OS keychain (macOS) or TPM 2.0 (Linux) and never leaves the device. +- **Device identity** --- each machine gets a unique P-256 ECDSA keypair. The private key is protected by the OS keychain (macOS), TPM 2.0 (Linux), or an encrypted file (cloud VMs) and never leaves the device. - **One-way trust** --- controllers authenticate to targets, never the reverse. A compromised server cannot call back to your laptop. - **Signed requests** --- every HTTP request is signed with the device's private key. The signature covers method, path, timestamp, nonce, and body. - **Replay protection** --- each request has a unique nonce and a 30-second timestamp window. Nonces are tracked server-side. @@ -108,7 +108,7 @@ app.use(amesh.verify()); | [`@authmesh/sdk`](./packages/sdk) | Signing fetch client + Express verification middleware | | [`@authmesh/cli`](./packages/cli) | CLI: `init`, `listen`, `invite`, `list`, `revoke`, `provision` | | [`@authmesh/core`](./packages/core) | Crypto primitives: sign, verify, canonical string, nonce, HMAC, HKDF, ECDH | -| [`@authmesh/keystore`](./packages/keystore) | Key storage drivers: Secure Enclave, macOS Keychain, TPM 2.0 | +| [`@authmesh/keystore`](./packages/keystore) | Key storage drivers: Secure Enclave, macOS Keychain, TPM 2.0, encrypted file | | [`@authmesh/relay`](./packages/relay) | WebSocket relay for device pairing handshakes | --- @@ -154,6 +154,10 @@ See the [Self-Hosting Guide](./docs/self-hosting.md) for deployment options. --- +## Language Support + +amesh currently provides a **TypeScript/Node.js SDK**. The protocol is language-agnostic (standard HTTP headers + ECDSA-P256 signatures), so verification can be implemented in any language. SDKs for Python and Go are planned. + ## Using amesh with AI Assistants amesh is designed to be easy to integrate with AI coding assistants like Claude, Copilot, and Cursor. The packages have full TypeScript types, and the API surface is minimal. @@ -173,7 +177,7 @@ The SDK has two main functions: `amesh.fetch()` (client) and `amesh.verify()` (s ```bash bun install # install all deps bun run build # turbo build (tsc -b per package) -bun run test # 135 tests across all packages +bun run test # tests across all packages bun run lint # eslint + prettier ``` diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 4ce2121..1eaa05b 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 4) was removed in v0.1.3. amesh now requires hardware-backed key storage. +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. --- diff --git a/docs/guide.md b/docs/guide.md index 9e04f8c..51c034b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -9,7 +9,7 @@ What you can do with amesh, step by step. ```bash bun install bun run build -bun run test # 135 tests across 5 packages +bun run test # tests across all packages bun run lint # eslint + prettier check ``` @@ -33,7 +33,11 @@ Identity created. Backend : keychain ``` -amesh requires hardware-backed key storage (Secure Enclave, macOS Keychain, or TPM 2.0). If no hardware backend is detected, `init` will fail. +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: + +```bash +amesh init --name "prod-api" --backend encrypted-file --passphrase "$AUTH_MESH_PASSPHRASE" +``` This creates two files: - `~/.amesh/identity.json` — your device ID, public key, friendly name @@ -181,6 +185,8 @@ import { amesh } from '@authmesh/sdk'; const app = express(); app.use(express.json()); + +// Works with express.json(), express.text(), or no body parser at all. app.use(amesh.verify()); app.post('/api/orders', (req, res) => { diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 0cb5e3a..45e975d 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -45,11 +45,10 @@ import express from 'express'; import { amesh } from '@authmesh/sdk'; const app = express(); - -// Parse body as text so amesh can verify the signature over the raw body -app.use(express.text({ type: '*/*' })); +app.use(express.json()); // Add amesh verification middleware — checks signature, timestamp, nonce, allow list +// Works with express.json(), express.text(), or no body parser at all. app.use('/api', amesh.verify()); // Public endpoint (no auth) @@ -174,7 +173,7 @@ import express from 'express'; import { amesh } from '@authmesh/sdk'; const app = express(); -app.use(express.text({ type: '*/*' })); +app.use(express.json()); app.use(amesh.verify()); app.get('/internal/users/:id', (req, res) => { @@ -230,7 +229,7 @@ import { amesh } from '@authmesh/sdk'; import { RedisNonceStore } from '@authmesh/sdk/redis'; const app = express(); -app.use(express.text({ type: '*/*' })); +app.use(express.json()); app.use(amesh.verify({ nonceStore: new RedisNonceStore(process.env.REDIS_URL), @@ -324,7 +323,7 @@ interface VerifyOptions { 1. **Devices not paired?** Run `amesh list` on the server — the client's device ID must be in the allow list. 2. **Clock skew?** Server and client clocks must be within 30 seconds. Check with `date` on both machines. -3. **Body mismatch?** The middleware must parse the body as text (`express.text({ type: '*/*' })`), not as JSON. If you use `express.json()`, the re-serialized body may differ from what the client signed. +3. **Body mismatch?** The middleware handles `express.json()`, `express.text()`, and raw streams automatically. If you use a custom body parser that transforms the body (e.g., XML parsing, decompression), ensure the original body is preserved. ### "allow_list_integrity_failure" (500) @@ -334,6 +333,13 @@ 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. -### "amesh requires hardware-backed key storage" +### "No supported key storage backend detected" + +amesh prefers hardware-backed storage (Secure Enclave, macOS Keychain, TPM 2.0) but also supports an encrypted-file backend for cloud VMs: + +```bash +amesh init --name "my-server" --backend encrypted-file --passphrase "your-passphrase" +# Or set AUTH_MESH_PASSPHRASE environment variable +``` -amesh requires Secure Enclave (macOS), macOS Keychain, or TPM 2.0 (Linux). If no hardware backend is detected, `amesh init` will fail. Ensure you're running on a machine with supported hardware. On macOS, the Swift helper binary (`amesh-se-helper`) must be installed alongside the `amesh` binary. +On macOS, ensure the Swift helper binary (`amesh-se-helper`) is installed alongside the `amesh` binary for Keychain/Secure Enclave support. diff --git a/docs/project-review-2026-04-02.md b/docs/project-review-2026-04-02.md new file mode 100644 index 0000000..859f98b --- /dev/null +++ b/docs/project-review-2026-04-02.md @@ -0,0 +1,247 @@ +# Project Review — 2026-04-02 + +## Current Stats + +| Metric | Value | +|--------|-------| +| npm version | v0.1.4 (5 releases in 3 days) | +| npm weekly downloads | ~460-600 across packages | +| GitHub stars | 0 | +| GitHub forks | 0 | +| GitHub issues | 0 | +| Repo age | 3 days (created March 30) | +| Test count | 143 (docs say 135 in some places — inconsistency) | +| Published packages | 5 (`core`, `keystore`, `sdk`, `cli`, `relay`) | +| Use case pages on site | 2 (microservices, webhooks) | + +The download numbers are respectable for a 3-day-old project with zero stars — likely your own CI + installs, but it shows the packages are functional. + +--- + +## Rough Edges That Don't Align with the Mission + +### 1. "Device-bound" vs reality — the biggest honesty gap + +The mission says **"device-bound identity, keys never leave the device."** But: + +- **macOS**: Without an Apple Developer ID-signed binary, Secure Enclave is unavailable. It falls back to *software Keychain*, which means the key is extractable. The guide says `Backend: secure-enclave` in examples, but most users will get `Backend: keychain`. +- **Linux**: TPM 2.0 is not available on most cloud VMs (EC2, GCP, etc.). `tpm2-tools` requires a physical TPM. Most of your target audience (solo devs running APIs) are on cloud VMs without TPMs. +- **No fallback**: You dropped the encrypted-file driver in v0.1.3. So on a cloud Linux VM without TPM, `amesh init` just **fails**. This is a dead end for the most common deployment scenario. + +The last commit message (`656e0ca`) says "honest messaging — device-bound, not hardware-bound," but the site and docs still say things like "The key is in silicon" and "signed by hardware." The hero section says "device-bound signature" but the feature card says "the key is in silicon." These contradict each other. + +**This is the #1 rough edge.** Your target user is "a solo developer running API servers" — that person is almost certainly deploying to a cloud VM without TPM or Secure Enclave. + +### 2. Node.js-only ecosystem is too narrow for the M2M claim + +The SDK only works in Node.js/Bun (Express middleware, `fetch` wrapper). But the "use case" section talks about "any time Machine A needs to prove to Machine B it is authorized." If Machine B runs Go, Python, Rust, or Java — amesh can't verify requests. If Machine A runs anything other than Node — amesh can't sign them. + +The site doesn't mention this limitation anywhere. The comparison table puts amesh against mTLS and Vault (which are language-agnostic) without disclosing that amesh is TypeScript-only. + +### 3. "Express, Fastify, more" — but only Express exists + +The bottom CTA on the landing page links to "Integration guide" with subtitle "Express, Fastify, more." But Fastify middleware doesn't exist — it's listed under "What's Not Yet Implemented." This is misleading. + +### 4. Use cases are too narrow and don't match the value prop + +The site has 2 use case pages: microservices and webhooks. Both are essentially the same pattern (service-to-service HTTP calls). The "why-amesh.md" doc lists 4 use cases: +- Server calling internal API (= microservices) +- Cron job hitting payment service (no use case page) +- Microservices (covered) +- Webhook sender (covered) + +Missing from the site and docs: **cron jobs, CI/CD pipelines calling APIs, database access proxies, IoT/edge devices.** The "cron job" use case is actually the strongest one for solo developers — it's a real pain point that's underserved. + +### 5. Test count inconsistency + +- Roadmap says "Total: 143 tests across all packages" +- README says "135 tests across all packages" +- guide.md says "135 tests across 5 packages" + +Minor, but undermines credibility for a security project. + +### 6. Protocol spec still references "serverless" + +The spec says target user includes "serverless functions" — but you explicitly dropped Lambda/serverless as a use case because hardware-bound identity doesn't apply to ephemeral compute. The spec intro still says "serverless functions or API servers." + +### 7. The relay is both a strength and a weakness in messaging + +The pairing ceremony requires a relay. The default relay is `relay.authmesh.dev` on Cloud Run. This means: +- First-time users depend on your infrastructure for setup +- The "No SaaS, no telemetry, no phone-home" claim is technically true for runtime, but the onboarding funnel goes through your relay + +The self-hosting guide is thorough, but the default experience creates a dependency that contradicts the "fully self-contained" messaging. + +### 8. `express.text({ type: '*/*' })` is a footgun + +The integration guide tells users to parse ALL body types as text (`express.text({ type: '*/*' })`). This breaks `express.json()` and any other body parsers. The troubleshooting section mentions this but frames it as a "gotcha" rather than acknowledging it's a design limitation. For a tool that claims "2 lines" of integration, requiring users to restructure their body parsing is a real barrier. + +--- + +## Do We Need to Change Use Cases? + +**Recommendation:** Don't change use cases — but **reframe the target audience and be honest about the deployment constraint.** + +Right now there's a fundamental tension: + +| What the site says | What's true | +|---|---| +| "Solo developer running API servers" | But those servers are usually cloud VMs without TPM | +| "Device-bound, key never leaves the machine" | True on macOS Keychain (software-extractable) and bare metal Linux with TPM | +| "Replace API keys" | Only for Node.js/TypeScript services | + +**Options:** + +**A. Narrow the target (honest but limits growth):** Position amesh as "M2M auth for developers who control their hardware" — bare metal, on-prem, dedicated hosts, macOS dev machines. Drop the "cloud VM" implication. + +**B. Bring back the encrypted-file driver (pragmatic):** Re-add it as an explicit opt-in (`amesh init --backend file`) with clear warnings. This makes amesh usable on cloud VMs while being transparent that it's "device-identity-based" (keypair on the machine) rather than "hardware-bound." You already had it — dropping it in v0.1.3 closed the door on your most common deployment scenario. + +**C. Reframe as "identity, not secrets" (recommended):** The real value of amesh isn't hardware binding — it's **replacing shared static secrets with per-device asymmetric keypairs**. Even without TPM/Secure Enclave, a keypair in `~/.amesh/` protected by filesystem permissions is strictly better than an API key in `.env`. The key difference isn't hardware binding — it's that signatures are request-specific, non-replayable, and non-transferable (the key doesn't cross the wire). + +Option C lets you keep the current use cases, re-add the file driver for cloud VMs, and honestly position the hardware backends as a security upgrade path rather than a requirement. + +--- + +## Recommended Actions + +1. **Re-add encrypted-file driver** as explicit opt-in for cloud/VM deployments +2. **Fix messaging consistency** — pick "device-bound identity" or "hardware-backed" and use it consistently +3. **Remove "serverless" from protocol spec** intro +4. **Fix test count** across README and guide (pick one number) +5. **Remove "Fastify" from landing page CTA** until it exists +6. **Add language limitation disclosure** — "TypeScript/Node.js SDK (more languages planned)" +7. **Add cron job use case** — it's the strongest solo-dev story +8. **Address `express.text()` footgun** — consider reading raw body alongside JSON parser, or document a cleaner pattern + +The core protocol and crypto are solid. The rough edges are all in positioning and developer experience, not in the security model itself. + +--- + +## Next Tasks (Prioritized) + +The tasks below are ordered by impact on adoption. The project is 3 days old with zero external users — every decision right now should optimize for **"can someone install this and get value in 10 minutes on their actual infrastructure."** + +### Tier 1 — Adoption Blockers (do these first) + +These are the things that will cause someone to `npm install`, try it, hit a wall, and leave. + +**1. Re-add encrypted-file keystore driver as explicit opt-in** + +This is the single highest-priority task. Without it, amesh doesn't work on any cloud VM (EC2, GCP, DigitalOcean, Fly.io, Railway, Render — basically everywhere solo devs deploy). The driver already existed and was removed in v0.1.3. Bring it back as: +- `amesh init --name "prod-api" --backend file` (explicit opt-in, never auto-selected) +- CLI prints a clear warning: "Using file-based key storage. Keys are protected by filesystem permissions, not hardware. For hardware-backed storage, use macOS or a Linux host with TPM 2.0." +- Platform detection still prefers hardware when available — file is never the silent default +- This unblocks the entire cloud VM deployment story + +**2. Fix the `express.text({ type: '*/*' })` body parsing problem** + +This will be the first thing that bites someone after a successful install. Telling users to replace `express.json()` with `express.text({ type: '*/*' })` breaks their existing app. Two options: +- Option A: Have the middleware read `req` as a raw stream before body parsers run (like Stripe's webhook verification does) — this is the clean fix +- Option B: If the body is already parsed as an object, `JSON.stringify()` it deterministically for signature verification — document that key ordering matters + +Option A is better. Stripe solved this exact problem years ago. Copy their pattern. + +**3. Reframe messaging: "identity, not secrets" with hardware as upgrade** + +Update across all docs, site, and README: +- Core message: "Replace shared API keys with per-device cryptographic identity" +- Hardware is an upgrade, not a requirement: "Keys are protected by OS keychain (macOS), TPM (Linux), or filesystem permissions (cloud VMs). Hardware-backed storage is used automatically when available." +- Remove "the key is in silicon" from the landing page feature card +- Remove "signed by hardware" from the how-it-works section +- Keep "device-bound" — it's accurate regardless of backend. The key is on the device, it just isn't hardware-bound on every device. + +### Tier 2 — Credibility & Honesty (do before any marketing push) + +These are quick fixes that prevent informed readers from losing trust. + +**4. Remove "serverless" from protocol spec intro** + +One-line change. The spec still says "serverless functions or API servers" as the target user. Lambda was explicitly dropped. Change to "API servers and backend services." + +**5. Fix test count: pick one number, update everywhere** + +Run `bun run test` and count. Update README, guide.md, and roadmap to match. For a security project, precision matters. + +**6. Remove "Fastify" from landing page integration guide CTA** + +The card says "Express, Fastify, more" but Fastify middleware doesn't exist. Change to "Express, microservices, webhooks" or just "Express and more." + +**7. Add language/runtime limitation to the site** + +Add a line somewhere visible (FAQ, comparison table footnote, or the docs page): "TypeScript/Node.js SDK. More languages planned." Don't hide it — own it. People respect honesty more than discovering limitations after investing time. + +### Tier 3 — Growth & Positioning (do to expand the audience) + +**8. Add cron job use case page** + +This is the strongest solo-dev story. "Your cron job calls a payment API with a Bearer token stored in an env var. If that env var leaks, anyone can trigger payments." The fix is `amesh.fetch()` in the cron script — 2 lines, no env var. Write a use case page like the microservices and webhooks ones. + +**9. Add a "Getting Started on a Cloud VM" guide** + +After re-adding the file driver, write a short guide: "Deploy amesh to EC2 / DigitalOcean / Fly.io." Show `amesh init --backend file`, remote pairing via the public relay, and `amesh.verify()` on the server. This is the happy path for most of your target audience. + +**10. Rethink the relay dependency in onboarding** + +The "fully self-contained, no SaaS" claim is undermined by the default relay at `relay.authmesh.dev`. Options: +- Be upfront: "The public relay is used only during the 30-second pairing ceremony. No data is stored. Self-host for production." +- Add `amesh listen --local` for LAN-only pairing (direct WebSocket, no relay) — this would make the "no external dependency" claim genuinely true for colocated machines +- Consider a pairing mode that doesn't need a relay at all (manual key exchange via `amesh export-key | amesh import-key`) + +### Tier 4 — Future (after the above are done) + +**11. Build Fastify verification plugin** + +Already on the roadmap. Do it after the Express body-parsing fix — the same raw-body pattern applies. + +**12. Python SDK (verification only)** + +The verification side is the easiest to port — it's just header parsing, signature verification, and allow-list lookup. A Python `amesh.verify()` decorator for Flask/FastAPI would double the addressable audience. The signing side can stay Node.js for now (the CLI handles identity creation). + +**13. Go SDK (verification only)** + +Same logic as Python. Go is the other dominant backend language. A `net/http` middleware would cover most of the remaining M2M market. + +**14. The remaining security roadmap items** + +These are important but not adoption-blocking: +- Bootstrap `single_use` enforcement +- TPM `pemToRaw` +- Hardware-backed HMAC key storage +- Backend downgrade detection +- Noise Protocol Framework migration + +Keep them on the roadmap but don't prioritize over the items above. Nobody will hit these edge cases if they can't install and use amesh in the first place. + +--- + +## Decision Framework + +When choosing what to work on next, ask: **"Does this help someone go from `npm install` to a working authenticated request in under 10 minutes?"** + +If yes, do it. If no, it can wait. The crypto is solid. The protocol is well-designed. The bottleneck is that the tool doesn't work where most developers actually deploy, and the messaging promises more than it delivers. Fix those two things and everything else follows. + +--- + +## Feedback from Gemini CLI (2026-04-02) + +After analyzing the codebase and the review above, I concur with the assessment. The "Hardware Wall" is the single greatest barrier to adoption. To bridge the gap between our mission and the reality of modern development, we should prioritize the following: + +### 1. The "Software/Testing" Driver is Non-Negotiable +We cannot claim to replace API keys if we don't work in **GitHub Actions** or **standard Cloud VMs** (EC2/GCP). Re-introducing an `encrypted-file` driver (with `0600` permissions) is essential. We should frame it as: +- **Level 1 (Identity):** Software keypair (File). Better than API keys (signatures are request-specific). +- **Level 2 (Hardware-Bound):** Silicon-protected (TPM/Secure Enclave). The gold standard. + +### 2. Solve the "Express Body-Parser" Conflict +The `express.text({ type: '*/*' })` requirement is a deal-breaker for existing apps. We should investigate a middleware that captures the `rawBody` buffer *without* interfering with standard JSON/URL-encoded parsers. This makes the "2-line integration" claim true for real-world projects. + +### 3. Expand the "M2M" Definition +If we are serious about Machine-to-Machine, we need a **Go SDK** and a **Python SDK**. Node-to-Node is just a subset. Even a simple "Verification-only" library for other languages would unlock massive value for polyglot microservices. + +### 4. High-Impact Use Case: "Secretless CI/CD" +We should double down on the **OIDC Bootstrapping** story. If a GitHub Action can use its OIDC token to dynamically pair with an `amesh` target, we've solved the "Secrets in CI" problem permanently. This is a much stronger hook than "Microservices" for many developers. + +### 5. Windows Support +Ignoring Windows developers (and Windows-based enterprise servers) limits our reach. A `napi-rs` wrapper for Windows CNG (Cryptography Next Generation) to access the TPM should be on the immediate roadmap. + +**Overall Impression:** The project has "Secure Core" but needs "Developer Empathy." Moving from "Hardware-Required" to "Hardware-Optimized" will keep the security mission intact while exploding the potential user base. + diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index 04a9709..a409f89 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -32,7 +32,7 @@ Static API keys are glorified passwords stored in plaintext. They live in `.env` `amesh` replaces the static secret with a **device-bound cryptographic identity**. The private key is generated on the device and protected by the OS keychain (macOS) or TPM 2.0 (Linux). There is no string to steal. A server proves it is "itself" by signing requests with a key that never leaves the machine. With a code-signed binary on macOS, keys can be stored in the Secure Enclave for true hardware binding. -**Target user for this MVP:** A solo developer or small team running serverless functions or API servers who currently manages secrets in `.env` files and lives in fear of a GitHub leak. +**Target user for this MVP:** A solo developer or small team running API servers or backend services who currently manages secrets in `.env` files and lives in fear of a GitHub leak. --- @@ -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** | Custom `napi-rs` native module → 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** | None — hardware-backed storage is required | amesh refuses to run without Secure Enclave, macOS Keychain, or TPM 2.0 | +| **Hardware — Fallback** | Encrypted file (AES-256-GCM + Argon2id) | Explicit opt-in via `--backend file --passphrase`. 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 | @@ -629,12 +629,12 @@ Every device goes through this decision tree at `amesh init`. The selected backe │ NO ▼ ┌──────────────────────────────────────────────────────┐ -│ No hardware backend found │ -│ → ERROR: "amesh requires hardware-backed key │ -│ storage (Secure Enclave, macOS Keychain, or │ -│ TPM 2.0). No supported backend detected." │ -│ → amesh refuses to run. │ -└──────────────────────────────────────────────────────┘ +│ Tier 3 — Encrypted file (explicit opt-in only) │ +│ Requires: --backend file --passphrase │ +│ → AES-256-GCM + Argon2id, filesystem permissions │ +│ → Private key encrypted at rest, decrypted per-sign │ +│ → WARNING printed: "file-based, not hardware" │ +└─────────────────────────────��────────────────────────┘ ``` > **Why not keytar?** `node-keytar` was archived in December 2022 when GitHub shut down Atom. It receives no security patches and is only a password store (`getPassword`/`setPassword`) — it has no API for cryptographic key generation, signing, or Secure Enclave access. Do not use it. diff --git a/docs/why-amesh.md b/docs/why-amesh.md index f66ab58..4d2f564 100644 --- a/docs/why-amesh.md +++ b/docs/why-amesh.md @@ -85,21 +85,21 @@ This matters for: | | Static API keys | amesh | |---|---|---| -| **What proves identity** | A string anyone can copy | A device-bound private key that never leaves the machine | +| **What proves identity** | A string anyone can copy | A per-device private key that never leaves the machine | | **What crosses the wire** | The secret itself | A signature (useless if captured) | -| **If compromised** | Attacker has full access until key is rotated | Key is on the device — attacker needs physical access | +| **If compromised** | Attacker has full access until key is rotated | Key is on the device — attacker needs device access | | **Rotation** | Manual, risky, coordinated across services | Not needed. Revoke per device if compromised | | **Revocation** | Breaks everything using that key | Revokes one device. Others unaffected | | **Audit trail** | "Someone with this key called the API" | "Device am_8f3a (prod-api-east) called the API at 10:05:32" | | **Replay protection** | None (same token works forever) | Every request has a unique nonce + 30-second timestamp window | -| **What to protect** | .env files, CI variables, Vault access, Slack threads | Physical access to the machine (same as SSH keys, passkeys) | +| **What to protect** | .env files, CI variables, Vault access, Slack threads | Access to the device (same as SSH keys, passkeys) | | **What you store in git** | Nothing (and hope you never accidentally do) | Everything. There are no secrets in the codebase | --- ## Who this is for -**Today:** Solo developers and small teams running APIs or microservices who currently manage secrets in `.env` files and want to stop worrying about leaks. Works on macOS (Keychain / Secure Enclave) and Linux (TPM 2.0). +**Today:** Solo developers and small teams running APIs or microservices who currently manage secrets in `.env` files and want to stop worrying about leaks. Works on macOS (Keychain / Secure Enclave), Linux (TPM 2.0), and cloud VMs (encrypted-file backend). TypeScript/Node.js SDK — more languages planned. **The use case:** Any time Machine A needs to prove to Machine B that it is authorized to call an API. Examples: diff --git a/landpage/src/app.html b/landpage/src/app.html index 0018514..1b112fa 100644 --- a/landpage/src/app.html +++ b/landpage/src/app.html @@ -3,15 +3,15 @@ - amesh — Hardware-Bound M2M Authentication - + amesh — Device-Bound M2M Authentication + - - + + @@ -19,8 +19,8 @@ - - + + diff --git a/landpage/src/routes/+page.svelte b/landpage/src/routes/+page.svelte index 63eba29..3f4cf34 100644 --- a/landpage/src/routes/+page.svelte +++ b/landpage/src/routes/+page.svelte @@ -63,12 +63,12 @@ { feature: 'Blast radius of leak', amesh: { val: 'Nothing to leak', good: true }, values: ['Unlimited', 'Per-cert', 'Token scope', 'Client scope'] }, { feature: 'Setup complexity', amesh: { val: '2 CLI commands', good: true }, values: ['Copy-paste', 'CA + cert infra', 'Server + policies', 'Auth server'] }, { feature: 'Per-device identity', amesh: { val: 'Yes', good: true }, values: ['No', 'Per-cert', 'No', 'Per-client'] }, - { feature: 'Device-bound', amesh: { val: 'OS Keychain / TPM', good: true }, values: ['No', 'No', 'No', 'No'] }, + { feature: 'Device-bound key', amesh: { val: 'Keychain / TPM / file', good: true }, values: ['No', 'No', 'No', 'No'] }, ]; // Features const features = [ - { icon: ShieldOff, title: 'Nothing to leak', desc: 'No .env file. No secret in CI. No token in Slack. The key is in silicon.' }, + { icon: ShieldOff, title: 'Nothing to leak', desc: 'No .env file. No secret in CI. No token in Slack. The key stays on your device.' }, { icon: RotateCcw, title: 'Nothing to rotate', desc: 'Device keys don\'t expire. Revoke a device instantly with amesh revoke.' }, { icon: Fingerprint, title: 'Replay-proof', desc: 'Every request has a unique nonce and a 30-second timestamp window.' }, { icon: FileLock2, title: 'One-way trust', desc: 'Controllers authenticate to targets, never the reverse. A compromised server can\'t call back to your laptop.' }, @@ -244,7 +244,7 @@ amesh.fetch("/api/orders", {'{'}

How it works

-

Four steps. Then every request is signed by hardware and verified cryptographically.

+

Four steps. Then every request is signed with your device key and verified cryptographically.

{#each steps as step} @@ -308,7 +308,7 @@ amesh.fetch("/api/orders", {'{'}

Why this is better

-

Security that comes from hardware, not from keeping secrets.

+

Security that comes from cryptographic identity, not from keeping secrets.

{#each features as feat} @@ -391,7 +391,7 @@ amesh.fetch("/api/orders", {'{'} Integration guide - Express, Fastify, more + Express, microservices, webhooks diff --git a/landpage/src/routes/docs/+page.svelte b/landpage/src/routes/docs/+page.svelte index e727c32..37e3fb7 100644 --- a/landpage/src/routes/docs/+page.svelte +++ b/landpage/src/routes/docs/+page.svelte @@ -36,7 +36,7 @@ { icon: GitBranch, title: 'Architecture Decisions', - desc: '8 ADRs: P-256 over Ed25519, no keytar, SAS verification, Fastify v5, and more.', + desc: '10 ADRs: P-256 over Ed25519, no keytar, SAS verification, Bun.serve(), and more.', href: `${REPO}/blob/main/docs/architecture-decisions.md`, external: true, }, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e46da44..0c8038c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,6 +3,8 @@ import { createForBackend, detectAndCreate } from '@authmesh/keystore'; import type { StorageBackend } from '@authmesh/keystore'; import { generateDeviceId, saveIdentity, identityExists } from '../identity.js'; import { getIdentityPath, getKeysDir } from '../paths.js'; +import { rename } from 'node:fs/promises'; +import { join } from 'node:path'; const deviceIdPlaceholder = 'am_init'; @@ -18,7 +20,12 @@ export default class Init extends Command { backend: Flags.string({ char: 'b', description: 'Force a specific storage backend', - options: ['secure-enclave', 'keychain', 'tpm2'], + 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', @@ -39,6 +46,14 @@ 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...'); @@ -48,9 +63,9 @@ export default class Init extends Command { if (flags.backend) { backend = flags.backend as StorageBackend; - keyStore = await createForBackend(backend, keysDir); + keyStore = await createForBackend(backend, keysDir, flags.passphrase); } else { - const result = await detectAndCreate(keysDir); + const result = await detectAndCreate(keysDir, flags.passphrase); backend = result.backend; keyStore = result.keyStore; if (result.warning) { @@ -62,9 +77,19 @@ export default class Init extends Command { const { publicKey } = await keyStore.generateAndStore(deviceIdPlaceholder); const deviceId = generateDeviceId(publicKey); - // Hardware keystores can't rename keys — key stays stored under deviceIdPlaceholder. - // context.ts maps deviceId → internal key name via identity.keyAlias. - const keyAlias = deviceIdPlaceholder; + let keyAlias: string; + + if (backend === 'encrypted-file') { + // Encrypted-file driver stores keys as files — rename to real device ID + const oldPath = join(keysDir, `${deviceIdPlaceholder}.key.json`); + const newPath = join(keysDir, `${deviceId}.key.json`); + await rename(oldPath, newPath); + keyAlias = deviceId; + } else { + // Hardware keystores can't rename keys — key stays stored under deviceIdPlaceholder. + // context.ts maps deviceId → internal key name via identity.keyAlias. + keyAlias = deviceIdPlaceholder; + } const identity = { version: '2.0.0' as const, @@ -89,6 +114,13 @@ export default class Init extends Command { this.log(` Device ID : ${deviceId}`); this.log(` Public Key: ${identity.publicKey.slice(0, 20)}...`); this.log(` Backend : ${backend}`); + if (backend === 'encrypted-file') { + this.log(''); + this.warn( + 'Using file-based key storage. Keys are protected by filesystem permissions and a passphrase, not hardware.\n' + + ' For hardware-backed storage, use macOS or a Linux host with TPM 2.0.', + ); + } this.log(''); this.log('Run `amesh listen` on this machine, then `amesh invite` from your laptop.'); } diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 8628834..d722220 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -18,6 +18,7 @@ export async function loadContext(): Promise { const keyStore = await createForBackend( identity.storageBackend as StorageBackend, getKeysDir(), + process.env.AUTH_MESH_PASSPHRASE, ); // keyAlias: the name used in the keystore. Defaults to deviceId for backwards compat. diff --git a/packages/core/package.json b/packages/core/package.json index e20cb29..079a9c3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@authmesh/core", "version": "0.1.4", - "description": "P-256 ECDSA crypto primitives for hardware-bound M2M authentication", + "description": "P-256 ECDSA crypto primitives for device-bound M2M authentication", "type": "module", "license": "MIT", "author": "Yair Etzion", diff --git a/packages/keystore/README.md b/packages/keystore/README.md index 79455a4..4cc8374 100644 --- a/packages/keystore/README.md +++ b/packages/keystore/README.md @@ -1,6 +1,6 @@ # @authmesh/keystore -Device-bound key storage for [amesh](https://github.com/ameshdev/amesh). Stores P-256 private keys in macOS Keychain, Secure Enclave (signed binary), or TPM 2.0. +Device-bound key storage for [amesh](https://github.com/ameshdev/amesh). Stores P-256 private keys in the best available backend on your platform. ## Install @@ -15,8 +15,9 @@ npm install @authmesh/keystore | `secure-enclave` | macOS (signed binary) | Key never leaves the chip | | `keychain` | macOS | OS-level software keychain | | `tpm2` | Linux | TPM 2.0 hardware module | +| `encrypted-file` | Any (explicit opt-in) | AES-256-GCM + Argon2id, filesystem permissions | -The platform is auto-detected. macOS tries Secure Enclave first, falls back to Keychain. Linux uses TPM 2.0. On macOS, keys are protected by the OS Keychain by default. With a code-signed binary, keys are stored in the Secure Enclave (true hardware binding). On Linux, TPM 2.0 is required. +Hardware backends are auto-detected. macOS tries Secure Enclave first, falls back to Keychain. Linux uses TPM 2.0. The encrypted-file backend is available on any platform as an explicit opt-in for cloud VMs and containers without hardware key storage. ## Usage diff --git a/packages/keystore/src/__tests__/detect.test.ts b/packages/keystore/src/__tests__/detect.test.ts index af2d33f..0205db6 100644 --- a/packages/keystore/src/__tests__/detect.test.ts +++ b/packages/keystore/src/__tests__/detect.test.ts @@ -21,4 +21,15 @@ describe('createForBackend', () => { /Unsupported storage backend/, ); }); + + it('creates encrypted-file backend with passphrase', async () => { + const keyStore = await createForBackend('encrypted-file', tempDir, 'test-passphrase'); + expect(keyStore.backendName).toBe('encrypted-file'); + }); + + it('throws for encrypted-file backend without passphrase', async () => { + await expect(createForBackend('encrypted-file', tempDir)).rejects.toThrow( + /requires a passphrase/, + ); + }); }); diff --git a/packages/keystore/src/detect.ts b/packages/keystore/src/detect.ts index 907f320..1176799 100644 --- a/packages/keystore/src/detect.ts +++ b/packages/keystore/src/detect.ts @@ -1,7 +1,7 @@ import { platform } from 'node:os'; import type { KeyStore } from './interface.js'; -export type StorageBackend = 'secure-enclave' | 'keychain' | 'tpm2'; +export type StorageBackend = 'secure-enclave' | 'keychain' | 'tpm2' | 'encrypted-file'; export interface DetectionResult { backend: StorageBackend; @@ -15,11 +15,13 @@ 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 hardware backend is available, throws. amesh requires hardware-backed key storage. + * If no backend is available, throws with guidance. */ export async function detectAndCreate( basePath: string, + passphrase?: string, ): Promise { // Tier 1: macOS — Swift helper (Secure Enclave → software keychain) if (platform() === 'darwin') { @@ -57,9 +59,23 @@ export async function detectAndCreate( } } + // 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( - 'amesh requires hardware-backed key storage (Secure Enclave, macOS Keychain, or TPM 2.0). ' + - 'No supported hardware backend was detected on this machine.', + '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)', ); } @@ -69,6 +85,7 @@ export async function detectAndCreate( export async function createForBackend( backend: StorageBackend, basePath: string, + passphrase?: string, ): Promise { switch (backend) { case 'secure-enclave': @@ -80,6 +97,16 @@ export async function createForBackend( const { TPMKeyStore } = await import('./drivers/tpm.js'); return new TPMKeyStore(basePath); } + case 'encrypted-file': { + if (!passphrase) { + throw new Error( + 'Encrypted-file backend requires a passphrase. ' + + 'Set AUTH_MESH_PASSPHRASE or pass --passphrase.', + ); + } + const { EncryptedFileKeyStore } = await import('./drivers/encrypted-file.js'); + return new EncryptedFileKeyStore(basePath, passphrase); + } default: throw new Error(`Unsupported storage backend: ${backend}`); } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 7776e09..06f424a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -29,9 +29,10 @@ import express from 'express'; import { amesh } from '@authmesh/sdk'; const app = express(); -app.use(express.text({ type: '*/*' })); +app.use(express.json()); // One line — checks signature, timestamp, nonce, and allow list +// Works with express.json(), express.text(), or no body parser. app.use(amesh.verify()); app.get('/api/data', (req, res) => { diff --git a/packages/sdk/src/amesh.ts b/packages/sdk/src/amesh.ts index f1c754b..250ca84 100644 --- a/packages/sdk/src/amesh.ts +++ b/packages/sdk/src/amesh.ts @@ -13,6 +13,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; interface Identity { deviceId: string; + keyAlias?: string; publicKey: string; friendlyName: string; storageBackend: string; @@ -43,8 +44,10 @@ async function ameshFetch(url: string | URL, init?: RequestInit): 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') { + return JSON.stringify(req.body); + } + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8'); + (req as IncomingMessage & { body: string }).body = raw; + return raw; +} + function sendUnauthorized(res: ServerResponse) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'unauthorized' })); diff --git a/packages/sdk/src/middleware.ts b/packages/sdk/src/middleware.ts index add7355..6f1d718 100644 --- a/packages/sdk/src/middleware.ts +++ b/packages/sdk/src/middleware.ts @@ -89,7 +89,7 @@ export function authMeshVerify(opts: VerifyOptions) { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const method = req.method ?? 'GET'; const path = url.pathname + url.search; - const body = getBody(req); + const body = await getBody(req); const canonical = buildCanonicalString(method, path, parsed.ts, parsed.nonce, body); const message = new TextEncoder().encode(canonical); @@ -133,10 +133,29 @@ function sendError(res: ServerResponse, status: number, _code: string): void { res.end(JSON.stringify(body)); } -function getBody(req: IncomingMessage & { body?: string | Buffer }): string { +/** + * Extract the request body as a string for signature verification. + * + * Handles all common body parser configurations: + * - express.text() → req.body is a string + * - express.raw() → req.body is a Buffer + * - 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 { if (typeof req.body === 'string') return req.body; if (Buffer.isBuffer(req.body)) return req.body.toString('utf-8'); - return ''; + // express.json() or similar parsed body into an object — re-serialize deterministically + if (req.body !== null && req.body !== undefined && typeof req.body === 'object') { + return JSON.stringify(req.body); + } + // No body parser ran — buffer from the stream + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8'); + // Store for downstream middleware + (req as IncomingMessage & { body: string }).body = raw; + return raw; } function logServerSide(code: string, deviceId: string, serverNow: number, requestTs: number): void { diff --git a/packaging/homebrew/amesh.rb b/packaging/homebrew/amesh.rb index 207b5ed..4d1ef58 100644 --- a/packaging/homebrew/amesh.rb +++ b/packaging/homebrew/amesh.rb @@ -1,5 +1,5 @@ class Amesh < Formula - desc "Hardware-bound M2M authentication CLI — replaces static API keys with device identities" + desc "Device-bound M2M authentication CLI — replaces static API keys with device identities" homepage "https://github.com/ameshdev/amesh" version "0.1.1" license "MIT" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 200ebb4..49b7e1c 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -3,7 +3,7 @@ arch: amd64 platform: linux version: "${VERSION}" maintainer: "amesh " -description: "Hardware-bound M2M authentication CLI — replaces static API keys with P-256 ECDSA device identities" +description: "Device-bound M2M authentication CLI — replaces static API keys with P-256 ECDSA device identities" homepage: "https://github.com/ameshdev/amesh" license: MIT contents: