diff --git a/README.md b/README.md index cca49fc..b30acc5 100644 --- a/README.md +++ b/README.md @@ -52,20 +52,23 @@ amesh init --name "prod-api" ### 2. Pair two machines -On the target machine: +On the target (server): ```bash amesh listen # Pairing code: 482916 +# ✔ "my-laptop" added as controller. ``` -On the controller: +On the controller (your laptop): ```bash amesh invite 482916 # Verification code: 847291 # Codes match? (Y/n): y -# "prod-api" added to allow list. +# ✔ "prod-api" added as target. ``` +Trust is one-way: the controller can authenticate to the target, but not the reverse. + ### 3. Sign requests (2 lines) ```typescript @@ -91,6 +94,7 @@ app.use(amesh.verify()); ## How It Works - **Device identity** --- each machine gets a unique P-256 ECDSA keypair. The private key is stored in Secure Enclave (macOS) or TPM 2.0 (Linux). Hardware-backed key storage is required. +- **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. - **No static secrets** --- there is no string to leak, rotate, or share. Revoke a compromised device instantly with `amesh revoke`. @@ -113,15 +117,15 @@ app.use(amesh.verify()); ``` [Pairing — one time] - Device A <--WebSocket--> Relay <--WebSocket--> Device B + Target (server) <--WebSocket--> Relay <--WebSocket--> Controller (laptop) (P-256 ECDH + ChaCha20-Poly1305 + SAS verification) [Runtime — every request] - Device A ----HTTP + AuthMesh header----> Device B - (no relay, no server, fully P2P) + Controller ----HTTP + AuthMesh header----> Target + (one-way trust, no relay, stateless) ``` -The relay is only needed for the initial pairing handshake. After devices exchange public keys, all authentication is stateless HTTP headers. The relay can be shut down and all existing pairings continue working. +The relay is only needed for the initial pairing handshake. After devices exchange public keys, all authentication is stateless HTTP headers. Trust is one-way: controllers authenticate to targets, but targets cannot authenticate back. The relay can be shut down and all existing pairings continue working. --- diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 7aa913f..36d53b0 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -42,10 +42,11 @@ Key decisions made during spec review and project bootstrap (March 2026). Each e **Approach by tier:** | Tier | Platform | Method | |------|----------|--------| -| 1 | macOS Secure Enclave | napi-rs → `SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave` | -| 2 | Linux TPM 2.0 | `tpm2-tools` subprocess via `execFile` (not `exec`) | -| 3 | OS keyring fallback | `security` CLI (macOS) / `secret-tool` (Linux libsecret) | -| 4 | Encrypted file | AES-256-GCM + Argon2id via `@noble/hashes/argon2` + `@noble/ciphers` | +| 1 | macOS Secure Enclave | Swift helper → `SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave` | +| 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. --- @@ -114,7 +115,7 @@ Both CLIs display this number; the developer confirms they match. Same approach **Why:** NIST SP 800-56A specifies extracting just the x-coordinate. The compressed point prefix byte (0x02/0x03) is not uniformly distributed and leaks information about y-coordinate parity. While HKDF hashes it away in practice, the standard extraction is correct. ### AllowList HMAC keyed from `getHmacKeyMaterial()`, not public key -**Decision:** Added `getHmacKeyMaterial(deviceId)` to the KeyStore interface. For encrypted-file, it decrypts the private key and derives via HKDF. For hardware keystores, a random 32-byte secret is stored in a file with 0600 permissions. +**Decision:** Added `getHmacKeyMaterial(deviceId)` to the KeyStore interface. A random 32-byte secret is stored in a file with 0600 permissions per device. **Why:** The AllowList constructor parameter was named `privateKeyMaterial` but all callers were passing the public key (from `getPublicKey()`). Since the public key is in `identity.json`, any attacker with filesystem access could derive the HMAC key and forge the allow list. This was the most critical finding. @@ -136,6 +137,28 @@ Both CLIs display this number; the developer confirms they match. Same approach **Why:** The relay was vulnerable to distributed OTC brute-force (per-IP limiting only), memory exhaustion via large payloads or unlimited connections, and stale bootstrap watcher leaks. ### deviceId path traversal prevention -**Decision:** Validate deviceId against `/^[a-zA-Z0-9_-]+$/` in encrypted-file driver. +**Decision:** Validate deviceId against `/^[a-zA-Z0-9_-]+$/` in all keystore drivers. **Why:** `path.join(basePath, deviceId + ".key.json")` does not prevent `../` traversal. A malicious deviceId could write outside the keys directory. + +--- + +## ADR-010: One-way trust directionality + +**Decision:** Trust between paired devices is one-way. A controller can authenticate to a target, but the target cannot authenticate back to the controller. By default, a target allows only one controller. + +**Why:** In the original symmetric design, both devices added each other's keys to their allow lists identically. This meant a compromised server could authenticate to the controller (e.g., the developer's laptop). One-way trust limits the blast radius: even if an attacker gains control of a target, they cannot use its identity to call back to controllers. + +**Implementation:** +- Each `AllowListDevice` entry has a `role` field: `"controller"` or `"target"` +- During handshake, the target (runs `amesh listen`) records the peer as `role: "controller"`, and the controller (runs `amesh invite`) records the peer as `role: "target"` +- The verification middleware rejects requests from devices with `role: "target"` — they are peers you can authenticate TO, not peers that can authenticate TO you +- The `role` field is covered by the HMAC seal, so an attacker cannot flip it without invalidating the integrity check +- `maxControllers` in `identity.json` (default: 1) limits how many controllers a target accepts. Configurable via `amesh init --max-controllers N` + +**Rejected alternatives:** +- One-way key exchange (target stores controller key only, controller stores nothing) — breaks `amesh list` and revocation on the controller side +- Role in `identity.json` (device is always controller or always target) — too rigid; the same device might be a controller for some peers and a target for others +- No enforcement (application-level convention) — convention is not security; the middleware must enforce it + +**Trade-offs:** Bidirectional auth between two services requires two separate pairings (each side runs `amesh listen` once and `amesh invite` once). This is intentional friction — bidirectional trust should be a conscious choice, not the default. diff --git a/docs/guide.md b/docs/guide.md index 148b448..7e9936c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -62,17 +62,20 @@ Output (empty initially): Your identity: am_cOixWcOdI8-pLh4P (My Laptop) ``` -After devices are paired, it shows: +After devices are paired, it shows each device's role (`[controller]` or `[target]`): ``` Trusted Devices (2) - ─────────────────────────────────────────────── - am_1a2b3c4d5e6f7a8b MacBook Pro — dev added 2026-03-28 - am_9f8e7d6c5b4a3210 staging-api added 2026-03-29 - ─────────────────────────────────────────────── + ────────────────────────────────────────────────────────── + 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 +- **[target]** — you can authenticate TO this device, but it cannot authenticate back to you + --- ## 4. CLI — Revoke a Device @@ -210,9 +213,10 @@ The client automatically: The server automatically: 1. Parses the header 2. Checks the device is in the allow list -3. Validates timestamp (±30s), nonce (replay prevention) -4. Verifies the ECDSA-P256-SHA256 signature -5. Attaches `req.authMesh` with the verified device identity +3. Checks the device's role is `controller` (targets are rejected) +4. Validates timestamp (±30s), nonce (replay prevention) +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.** @@ -222,17 +226,25 @@ The server automatically: The handshake establishes trust between two machines. Run it once per device pair — after that, all authentication is offline. +**Trust is one-way.** The controller (your laptop) can authenticate to the target (the server), but the target cannot authenticate back to the controller. This limits the blast radius of a compromised server. + On the **target** machine (the server being secured): ```bash amesh listen +# ✔ "Dev Laptop" added as controller. ``` On the **controller** machine (your laptop), using the 6-digit code displayed by the target: ```bash amesh invite 482916 +# ✔ "prod-api" added as target. ``` -Both sides display a verification code — confirm they match. After that, each machine has the other's public key in its allow list. +Both sides display a verification code — confirm they match. After that: +- The target's allow list has the controller's key with role `controller` (accepts auth from it) +- The controller's allow list has the target's key with role `target` (cannot auth from it) + +By default, a target allows only **one controller**. If you pair a second controller, the CLI prompts you to replace the existing one. To allow multiple controllers, use `amesh init --max-controllers N`. To run the handshake as an integration test: ```bash diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 725297a..dc83635 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -7,24 +7,24 @@ How to add amesh to your existing application. Each recipe is self-contained — ## Architecture Overview ``` -┌─────────────────────────────────────────────────────────────┐ -│ PAIRING (one-time) │ -│ │ -│ Your Server ◄──WebSocket──► Relay ◄──WebSocket──► Client Machine │ -│ amesh listen amesh invite 482916 │ -│ │ -│ Both sides verify a 6-digit code, then exchange public │ -│ keys. The relay can be shut down after this. │ -└─────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────┐ -│ RUNTIME (every request) │ -│ │ -│ Client Machine ────HTTP + AuthMesh header────► Your Server │ -│ amesh.fetch() amesh.verify() │ -│ │ -│ No relay. No server. Fully P2P. Stateless HTTP headers. │ -└─────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ PAIRING (one-time) │ +│ │ +│ Your Server (target) ◄──WS──► Relay ◄──WS──► Client (controller) │ +│ amesh listen amesh invite 482916 │ +│ │ +│ Both sides verify a 6-digit code, then exchange public keys. │ +│ Trust is one-way: controller → target. Relay can be shut down. │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ RUNTIME (every request) │ +│ │ +│ Controller ────HTTP + AuthMesh header────► Target │ +│ amesh.fetch() amesh.verify() │ +│ │ +│ One-way. No relay. Stateless headers. Target cannot call back. │ +└─────────────────────────────────────────────────────────────────────┘ ``` --- @@ -95,23 +95,24 @@ console.log(await res.json()); # Install the CLI npm install -g @authmesh/cli -# On the server machine: create identity +# On the server (target): create identity amesh init --name "prod-api" -# On the client machine: create identity +# On your laptop (controller): create identity amesh init --name "my-laptop" # Start the relay (needed only for pairing) bunx @authmesh/relay -# On the server: start listening for pairing +# On the server (target): start listening for pairing amesh listen +# ✔ "my-laptop" added as controller. -# On the client: pair with the server (use the 6-digit code from amesh listen) +# On your laptop (controller): pair with the server amesh invite 482916 +# ✔ "prod-api" added as target. -# Verify the 6-digit SAS code matches on both sides. Done. -# The relay can be stopped now. All future auth is P2P. +# Trust is one-way: laptop → server. The relay can be stopped now. ``` --- @@ -125,21 +126,21 @@ When your server is remote (cloud VM, EC2, etc.), both machines need to reach th amesh provides a free relay at `relay.authmesh.dev`: ```bash -# On the remote server (SSH in) +# On the remote server (target — SSH in) amesh listen --relay wss://relay.authmesh.dev/ws -# On your laptop +# On your laptop (controller) amesh invite 482916 --relay wss://relay.authmesh.dev/ws ``` ### Option B: Run the relay on the remote server ```bash -# On the remote server +# On the remote server (target) bunx @authmesh/relay # starts on port 3001 amesh listen --relay ws://localhost:3001/ws -# On your laptop (use the server's public IP or domain) +# On your laptop (controller — use the server's public IP or domain) amesh invite 482916 --relay ws://your-server:3001/ws ``` @@ -164,9 +165,9 @@ For production, you should host your own relay. See the [Self-Hosting Guide](./s ## Recipe 2: Microservices (Service A calls Service B) -Each service gets its own device identity. Services pair once, then authenticate every request. +Each service gets its own device identity. Services pair once, then authenticate every request. Trust is one-way: the caller (controller) authenticates to the API (target), not vice versa. -### Service B (the API being called) +### Service B — the target (the API being called) ```typescript import express from 'express'; @@ -184,7 +185,7 @@ app.get('/internal/users/:id', (req, res) => { app.listen(4000); ``` -### Service A (the caller) +### Service A — the controller (the caller) ```typescript import { amesh } from '@authmesh/sdk'; @@ -198,16 +199,21 @@ async function getUser(id: string) { ### Setup for each service ```bash -# On service-a machine: -amesh init --name "service-a" - -# On service-b machine: +# On service-b machine (target — the API): amesh init --name "service-b" +amesh listen -# Pair them (run relay, then listen + invite) -# After pairing, service-b's allow list contains service-a's public key +# On service-a machine (controller — the caller): +amesh init --name "service-a" +amesh invite 482916 + +# One-way trust: service-a → service-b. +# service-b's allow list has service-a as [controller]. +# service-b cannot authenticate back to service-a. ``` +> **Bidirectional auth:** If two services need to call each other, pair them twice — each side runs `amesh listen` once and `amesh invite` once. Each pairing creates a separate one-way trust relationship. + --- ## Recipe 3: Redis Nonce Store (Production Multi-Instance) diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index 2118305..08c62df 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -63,14 +63,14 @@ Every choice below is made for a reason. Do not substitute without understanding | Layer | Choice | Reason | |---|---|---| -| **Language** | TypeScript (Node.js 24 LTS) | Strong crypto ecosystem, fast prototyping, first-class async, runs natively on Lambda/Vercel | +| **Language** | TypeScript (Node.js 24 LTS) | Strong crypto ecosystem, fast prototyping, first-class async | | **CLI Framework** | `oclif` v4 | Industry-standard, supports plugin architecture, generates proper help docs, used by Heroku/Salesforce CLIs | | **Crypto — Curves** | `@noble/curves` (P-256 ECDSA + P-256 ECDH) | Audited, zero-dependency, constant-time, actively maintained by Paulmillr. P-256 chosen for universal hardware support (Secure Enclave, TPM 2.0). Replaces `@noble/ed25519` which is incompatible with hardware security modules. | -| **Crypto — Hashes** | `@noble/hashes` (SHA-256, HKDF, HMAC, Argon2id) | Same author, same audit lineage. Argon2id built-in — no extra dependency for encrypted-file fallback. | +| **Crypto — Hashes** | `@noble/hashes` (SHA-256, HKDF, HMAC) | Same author, same audit lineage. | | **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** | OS keyring via platform CLI (`security`/`secret-tool`), then encrypted file | See Section 11. Replaces `keytar` which is deprecated. | +| **Hardware — Fallback** | None — hardware-backed storage is required | amesh refuses to run without Secure Enclave, macOS Keychain, or TPM 2.0 | | **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 | @@ -102,8 +102,7 @@ amesh/ │ │ │ ├── drivers/ │ │ │ │ ├── secure-enclave.ts # macOS │ │ │ │ ├── tpm.ts # Linux -│ │ │ │ ├── os-keyring.ts # OS keyring fallback (platform CLI: security/secret-tool) -│ │ │ │ └── encrypted-file.ts # Last-resort fallback +│ │ │ │ └── tpm.ts # TPM 2.0 (Linux) │ │ └── package.json │ │ │ ├── cli/ # The `amesh` CLI tool @@ -166,7 +165,7 @@ After `amesh init`, the following are written to `~/.amesh/`: "version": "2.0.0", "deviceId": "am_8f3a...", "publicKey": "Base64EncodedCompressedP256PublicKey==", - "friendlyName": "prod-lambda-us-east-1", + "friendlyName": "prod-api-us-east-1", "createdAt": "2026-03-28T10:00:00Z", "storageBackend": "secure-enclave" } @@ -182,7 +181,7 @@ The prefix `am_` makes amesh IDs visually identifiable in logs. ``` $ amesh init -? What is this device's friendly name? prod-lambda-us-east-1 +? What is this device's friendly name? prod-api-us-east-1 ✔ Generating P-256 keypair... ✔ Storing private key in Secure Enclave (macOS) @@ -203,7 +202,7 @@ Run `amesh listen` on this machine, then `amesh invite` from your laptop. This is the "ceremony" that establishes trust between two devices. It runs **once** per device pair. After it completes, all future authentication is offline and peer-to-peer — the relay is never needed again. ### Roles -- **Target** (the server/Lambda being secured): runs `amesh listen` +- **Target** (the server being secured): runs `amesh listen` - **Controller** (the developer's laptop): runs `amesh invite --code XXXXXX` ### The Relay's Role @@ -264,7 +263,7 @@ Each side sends: ```json { "publicKey": "Base64EncodedPermanentPubKey==", - "friendlyName": "prod-lambda-us-east-1", + "friendlyName": "prod-api-us-east-1", "timestamp": "2026-03-28T10:05:00Z", "selfSig": "Base64EncodedSignature==" } @@ -282,8 +281,15 @@ SAS is displayed by default. Skippable with `--no-verify` flag for automated/hea > **Why SAS in addition to selfSig:** The `selfSig` alone does not prevent a relay MITM that performs separate ECDH with each side and substitutes its own permanent key with a valid selfSig. The SAS catches this because the ECDH shared secrets differ. -**Step 11 — Persistence:** -Each device writes the other's `publicKey` and `friendlyName` into its local `allow_list.json` and reseals the HMAC. See Section 9. +**Step 11 — Persistence with role assignment:** +Each device writes the other's `publicKey` and `friendlyName` into its local `allow_list.json` with a **role** field and reseals the HMAC. See Section 9. + +- The **target** (ran `amesh listen`) writes the controller's key with `role: "controller"` — this peer may authenticate to me. +- The **controller** (ran `amesh invite`) writes the target's key with `role: "target"` — this peer may NOT authenticate to me. + +This enforces **one-way trust**: controllers can authenticate to targets, but targets cannot authenticate back to controllers. + +**Single-controller default:** By default, a target allows only one controller (`maxControllers: 1` in `identity.json`). If a target already has a controller and a new handshake completes, the CLI prompts the operator to replace the existing controller. The `maxControllers` limit can be raised via `amesh init --max-controllers N`. ### CLI output (Target side) ``` @@ -402,10 +408,14 @@ If any field is missing: return `400 Bad Request`. If `v !== "1"`: return `400 Bad Request` with body `{"error": "unsupported_version"}`. **Step 3 — Identity lookup** -Load and verify the integrity of `allow_list.json` (see Section 9). -If `id` is not in the allow list: return `401 Unauthorized`. +Load and verify the integrity of `allow_list.json` (see Section 9). +If `id` is not in the allow list: return `401 Unauthorized`. Do not reveal *why* — the response body is always `{"error": "unauthorized"}` for 401s. +**Step 3b — Directionality check** +If the matched device has `role: "target"`: return `401 Unauthorized`. +A device marked as `target` in the allow list is a peer that this device can authenticate *to*, not a peer that may authenticate *to this device*. The response body is the same generic `{"error": "unauthorized"}` — the rejection reason is logged server-side only. + **Step 4 — Clock check** ``` serverNow = Math.floor(Date.now() / 1000) @@ -440,7 +450,7 @@ class NonceStore { } ``` -> **Note:** For multi-instance deployments (multiple Lambda instances), the nonce store must be shared via Redis or a similar fast store. Document this limitation explicitly in the SDK readme. +> **Note:** For multi-instance deployments, the nonce store must be shared via Redis or a similar fast store. Document this limitation explicitly in the SDK readme. **Step 6 — Reconstruct canonical string** Build `M` from the incoming request using the same rules as Section 7. @@ -457,7 +467,7 @@ On success, attach the verified device identity to the request object: ```typescript req.authMesh = { deviceId: 'am_8f3a9b2c1d4e5f6a', - friendlyName: 'prod-lambda-us-east-1', + friendlyName: 'prod-api-us-east-1', verifiedAt: serverNow, }; ``` @@ -486,7 +496,8 @@ The allow list is **sealed** with an HMAC keyed by the device's hardware-bound p "publicKey": "Base64EncodedPublicKey==", "friendlyName": "MacBook Pro — dev", "addedAt": "2026-03-28T10:05:00Z", - "addedBy": "handshake" + "addedBy": "handshake", + "role": "controller" } ], "updatedAt": "2026-03-28T10:05:00Z", @@ -494,26 +505,21 @@ The allow list is **sealed** with an HMAC keyed by the device's hardware-bound p } ``` +The `role` field enforces trust directionality: +- `"controller"` — this peer may authenticate to me (accepted by verification middleware) +- `"target"` — this peer is a target I can authenticate to, but it may NOT authenticate to me (rejected by verification middleware) + +Legacy allow lists without the `role` field are migrated on first read: missing roles default to `"controller"` (permissive, backwards-compatible). The HMAC is resealed after migration. + ### HMAC Key Derivation The HMAC key material is obtained via `KeyStore.getHmacKeyMaterial(deviceId)`: -**Software keystores (encrypted-file):** Derived from the permanent private key using HKDF: -``` -hmacKey = HKDF-SHA256( - ikm = permanentPrivateKey, - salt = "amesh-hmac-material-v1", - info = deviceId, - length = 32 -) -``` - -**Hardware keystores (Secure Enclave, TPM):** The private key cannot be exported. A random 32-byte secret is generated once per device and stored in `.hmac` (mode `0600`) alongside the key. This secret is generated on first call and reused thereafter. +The private key cannot be exported from hardware. A random 32-byte secret is generated once per device and stored in `.hmac` (mode `0600`) alongside the key. This secret is generated on first call and reused thereafter. This means: - The HMAC key never appears in the allow list file -- For software keystores: an attacker cannot forge a valid HMAC without the passphrase -- For hardware keystores: the HMAC secret is protected by file permissions (not hardware-bound; see Security Considerations) +- The HMAC secret is protected by file permissions (not hardware-bound; see Security Considerations) - Tampering with `allow_list.json` is immediately detected on next read ### Read/Write Protocol @@ -623,23 +629,11 @@ Every device goes through this decision tree at `amesh init`. The selected backe │ NO ▼ ┌──────────────────────────────────────────────────────┐ -│ Tier 3 — Is an OS keyring available? │ -│ (macOS: `security` CLI → Keychain Services) │ -│ (Linux: `secret-tool` → libsecret/GNOME Keyring) │ -│ → YES: Store encrypted P-256 private key in keyring │ -│ WARN: "No hardware security module found. │ -│ Using OS keyring. Security is reduced — │ -│ key is software-protected." │ -└──────────────────┬───────────────────────────────────┘ - │ NO (CI/CD headless environment) - ▼ -┌──────────────────────────────────────────────────────┐ -│ Tier 4 — Last resort: Encrypted file │ -│ Key encrypted with AES-256-GCM, passphrase derived │ -│ via Argon2id (using @noble/hashes/argon2) │ -│ WARN: "Running in degraded security mode. │ -│ Key is protected only by your passphrase. │ -│ Not recommended for production." │ +│ 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. │ └──────────────────────────────────────────────────────┘ ``` @@ -704,10 +698,10 @@ $ amesh list Trusted Devices (2) ─────────────────────────────────────────────── am_1a2b3c4d5e6f7a8b MacBook Pro — dev added 2026-03-28 - am_9f8e7d6c5b4a3210 prod-lambda-us-east added 2026-03-29 + am_9f8e7d6c5b4a3210 prod-api-us-east added 2026-03-29 ─────────────────────────────────────────────── - Your identity: am_8f3a9b2c1d4e5f6a (prod-lambda-us-east-1) + Your identity: am_8f3a9b2c1d4e5f6a (prod-api-us-east-1) ``` --- @@ -717,7 +711,7 @@ $ amesh list ### Clock Synchronization The `±30 second` window requires server clocks to be within 30 seconds of real time. This is satisfied by NTP, which is enabled by default on all major cloud providers. The middleware SHOULD emit a warning log when a valid timestamp is within 5 seconds of the boundary (potential drift indicator). -### Multi-Instance Deployments (Lambda / Kubernetes) +### Multi-Instance Deployments The in-memory nonce store does not survive process restarts and is not shared across instances. For multi-instance deployments: - Use Redis for the nonce store (TTL-keyed set, `SET nonce EX 60 NX`) - Document this requirement prominently in the SDK README @@ -736,6 +730,11 @@ The relay could theoretically swap ephemeral public keys during Step 5 to perfor 2. **SAS Verification** (Step 9): Even if the relay performs separate ECDH with each side and substitutes its own permanent key with a valid `selfSig`, the SAS codes will differ because the ECDH shared secrets differ. This is cryptographic proof of no MITM — not reliant on the developer recognizing an unfamiliar device name. Same approach as Signal, Matrix, and Bluetooth Secure Simple Pairing. +### One-Way Trust Directionality +Trust between devices is **one-directional** by default. A controller can authenticate to a target, but the target cannot authenticate back to the controller. This limits the blast radius of a compromised target — even if an attacker gains control of the server, they cannot use its amesh identity to authenticate to the controller. The `role` field in each allow list entry is HMAC-sealed, so an attacker cannot flip a `"target"` role to `"controller"` without invalidating the HMAC. + +By default, a target allows only **one controller** (`maxControllers: 1`). This can be increased via `amesh init --max-controllers N` for multi-operator environments. + ### Query String Canonicalization Sort query parameters alphabetically before including in canonical string `M`. This prevents the same request from having two valid signatures depending on parameter ordering. Use: `new URLSearchParams(url.search).sort().toString()`. @@ -817,7 +816,7 @@ The specific error code is logged server-side but never returned to the client ( - [ ] `@authmesh/sdk`: implement `authMeshVerify` Express middleware - [ ] `@authmesh/sdk`: implement `authMeshVerify` Fastify middleware - [ ] Write SDK README with "5-minute quickstart" guide -- [ ] Build demo: a simple Express API secured with amesh + a Lambda function calling it +- [ ] Build demo: a simple Express API secured with amesh + a client calling it - [ ] Recruit 5 developers. Give them only the README. Count how many complete the quickstart without asking for help. **Exit criteria:** 4 of 5 developers complete the quickstart independently. Zero static secrets appear anywhere in the demo codebase. @@ -833,7 +832,7 @@ The specific error code is logged server-side but never returned to the client ( - Allow list HMAC: test tamper detection, atomic write ### Integration Tests -- Full handshake between two local Node.js processes (no real hardware needed — use encrypted-file driver) +- Full handshake between two local processes (uses macOS Keychain on CI) - Full request → sign → verify cycle with Express middleware ### Hardware Tests (CI skip, run manually) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 44258f5..bd6800f 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -8,10 +8,12 @@ How to run your own amesh relay server. The relay is only needed for device pair ``` PAIRING (one-time, needs relay): - Device A <--WebSocket--> Your Relay <--WebSocket--> Device B + Target (server) <--WebSocket--> Your Relay <--WebSocket--> Controller (laptop) + amesh listen amesh invite 482916 RUNTIME (every request, no relay): - Device A ----HTTP + AuthMesh header----> Device B + Controller ----HTTP + AuthMesh header----> Target + (one-way trust: controller → target only) ``` The relay is stateless. It holds WebSocket connections in memory during a pairing session (typically 10-30 seconds) and forgets everything when the session ends. No database, no persistence. @@ -32,10 +34,10 @@ The relay starts on port 3001. Health check: `curl http://localhost:3001/health` To use it for pairing: ```bash -# On machine A +# On the target (server) amesh listen --relay ws://your-server:3001/ws -# On machine B +# On the controller (your laptop) amesh invite 482916 --relay ws://your-server:3001/ws ``` diff --git a/docs/why-amesh.md b/docs/why-amesh.md index 5865139..357d9c9 100644 --- a/docs/why-amesh.md +++ b/docs/why-amesh.md @@ -46,7 +46,7 @@ This is operationally expensive and error-prone. Teams delay rotation because th ### 3. No identity — only access -A Bearer token doesn't tell you *who* is calling. If three Lambda functions and a developer laptop all share the same API key, your server sees identical requests from all four. You can't: +A Bearer token doesn't tell you *who* is calling. If three servers and a developer laptop all share the same API key, your API sees identical requests from all four. You can't: - Audit which machine made a specific request - Rate-limit per device @@ -62,7 +62,7 @@ Services like AWS Secrets Manager, HashiCorp Vault, and Doppler improve *managem A secrets manager: - Adds a dependency (if Vault is down, your service can't authenticate) - Still delivers the secret as a string to your application -- Requires its own authentication (how does your Lambda authenticate to Secrets Manager? With... another secret) +- Requires its own authentication (how does your server authenticate to Secrets Manager? With... another secret) - Adds latency on every cold start - Costs money at scale @@ -90,7 +90,7 @@ This matters for: | **If compromised** | Attacker has full access until key is rotated | Attacker can't extract the key from hardware | | **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-lambda-east) called the API at 10:05:32" | +| **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 you store in git** | Nothing (and hope you never accidentally do) | Everything. There are no secrets in the codebase | @@ -99,13 +99,12 @@ This matters for: ## Who this is for -**Today:** Solo developers and small teams running APIs, Lambda functions, or microservices who currently manage secrets in `.env` files and want to stop worrying about leaks. +**Today:** Solo developers and small teams running APIs or microservices on hardware with Secure Enclave (macOS) or TPM (Linux) who currently manage secrets in `.env` files and want to stop worrying about leaks. **The use case:** Any time Machine A needs to prove to Machine B that it is authorized to call an API. Examples: -- A Lambda function calling your internal API +- A server calling your internal API - A cron job hitting a payment service -- A CI/CD pipeline deploying to production - Microservices authenticating to each other - A webhook sender proving its identity to a receiver @@ -115,6 +114,6 @@ This matters for: ## The security model in one sentence -**The private key never leaves the chip. The signature proves the device. The nonce prevents replay. The HMAC prevents tampering. The SAS prevents MITM.** +**The private key never leaves the chip. The signature proves the device. The nonce prevents replay. The HMAC prevents tampering. The SAS prevents MITM. One-way trust limits blast radius — a compromised target cannot authenticate back to its controller.** There is no string to steal. diff --git a/landpage/src/routes/+page.svelte b/landpage/src/routes/+page.svelte index b89f4bd..1fe398f 100644 --- a/landpage/src/routes/+page.svelte +++ b/landpage/src/routes/+page.svelte @@ -40,8 +40,8 @@ }, { n: '2', title: 'Pair two machines', - desc: 'One runs amesh listen, the other amesh invite. A 6-digit code confirms no one is in the middle.', - code: `$ amesh listen\n\n Pairing code: 482916\n\n Controller connected.\n Verification code: 847291\n Codes match? (Y/n): y\n "Dev Laptop" added to allow list.` + desc: 'The server runs amesh listen, your laptop runs amesh invite. Trust is one-way: your laptop controls the server, not the other way around.', + code: `$ amesh listen\n\n Pairing code: 482916\n\n Controller connected.\n Verification code: 847291\n Codes match? (Y/n): y\n "Dev Laptop" added as controller.` }, { n: '3', title: 'Sign requests — 2 lines', @@ -71,8 +71,8 @@ { icon: ShieldOff, title: 'Nothing to leak', desc: 'No .env file. No secret in CI. No token in Slack. The key is in silicon.' }, { 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: 'Tamper-proof trust store', desc: 'The allow list is HMAC-sealed. Any file edit is detected immediately.' }, - { icon: ShieldCheck, title: 'MITM-proof pairing', desc: 'Encrypted key exchange with 6-digit verification. Same as Signal and Bluetooth.' }, + { icon: FileLock2, title: 'One-way trust', desc: 'Controllers authenticate to targets, never the reverse. A compromised server can\'t call back to your laptop.' }, + { icon: ShieldCheck, title: 'MITM-proof pairing', desc: 'Encrypted key exchange with 6-digit verification and HMAC-sealed allow list. Same as Signal and Bluetooth.' }, { icon: Code, title: 'Open source', desc: 'MIT licensed. Audit the crypto, fork the relay, self-host everything.' }, ]; @@ -80,11 +80,11 @@ const cliTabs = [ { label: 'Device Management', - code: `$ amesh list\n\n Trusted Devices (2)\n ──────────────────────────────────────────\n am_1a2b3c4d Dev Laptop added 2026-03-28\n am_9f8e7d6c staging-api 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 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', - code: `$ amesh invite relay.authmesh.dev\n\n Pairing code: 482916\n Waiting for target device...\n\n Target connected.\n Verification code: 847291\n Codes match? (Y/n): y\n Paired. "prod-api" added to allow list.` + code: `$ amesh invite 482916\n\n Connecting to relay with code 482916...\n\n Peer found.\n Verification code: 847291\n Codes match? (Y/n): y\n "prod-api" added as target.` }, { label: 'Init', diff --git a/landpage/src/routes/docs/integration/+page.svelte b/landpage/src/routes/docs/integration/+page.svelte index 067027c..714fd33 100644 --- a/landpage/src/routes/docs/integration/+page.svelte +++ b/landpage/src/routes/docs/integration/+page.svelte @@ -27,14 +27,14 @@
Pairing (one-time)
-
Your Server  <--WebSocket-->  Relay  <--WebSocket-->  Client
+				
Your Server (target)  <--WebSocket-->  Relay  <--WebSocket-->  Client (controller)
 Both sides verify a 6-digit code, then exchange public keys.
-The relay can be shut down after this.
+Trust is one-way: controller → target. The relay can be shut down after this.
Runtime (every request)
-
Client  ----HTTP + AuthMesh header---->  Your Server
-No relay. No external server. Fully P2P. Stateless headers.
+
Controller  ----HTTP + AuthMesh header---->  Target
+One-way. No relay. Stateless headers. Target cannot call back.
@@ -88,11 +88,11 @@ npm install -g @authmesh/cli # Create identity on each machine amesh init --name "prod-api" -# Start relay, pair devices, verify 6-digit code -amesh listen # on server -amesh invite 482916 # on client (use code from listen) +# Pair: server is the target, your laptop is the controller +amesh listen # on server (target) +amesh invite 482916 # on laptop (controller — use code from listen) -# Done. Relay can be stopped. All future auth is P2P.`} /> +# Done. Trust is one-way: laptop → server. Relay can be stopped.`} /> diff --git a/landpage/src/routes/use-cases/microservices/+page.svelte b/landpage/src/routes/use-cases/microservices/+page.svelte index c41b433..bf5d7e9 100644 --- a/landpage/src/routes/use-cases/microservices/+page.svelte +++ b/landpage/src/routes/use-cases/microservices/+page.svelte @@ -40,13 +40,15 @@ app.post('/check', (req, res) => { console.log(\`Caller: \${req.authMesh.friendlyName}\`); // "Caller: orders-service" — not "someone with API_KEY" });` }, - { filename: 'Terminal', code: `# On the orders service machine: -$ amesh init --name "orders-service" + { filename: 'Terminal', code: `# On the API gateway (target): +$ amesh init --name "api-gateway" +$ amesh listen -# Pair with the API gateway: +# On the orders service (controller): +$ amesh init --name "orders-service" $ amesh invite 482916 -# Each service has its own identity. Revoke one without touching others.` }, +# One-way trust: orders-service → api-gateway. Revoke one without touching others.` }, ]} changes={[ { before: 'Shared API key across services', after: 'Unique identity per service' }, diff --git a/landpage/static/sitemap.xml b/landpage/static/sitemap.xml index 4b0fdcf..d8110f6 100644 --- a/landpage/static/sitemap.xml +++ b/landpage/static/sitemap.xml @@ -2,7 +2,7 @@ https://authmesh.dev/ - 2026-03-30 + 2026-03-31 1.0 @@ -12,7 +12,7 @@ https://authmesh.dev/docs/integration - 2026-03-30 + 2026-03-31 0.9 @@ -27,22 +27,12 @@ https://authmesh.dev/use-cases/microservices - 2026-03-30 - 0.7 - - - https://authmesh.dev/use-cases/ci-cd - 2026-03-30 + 2026-03-31 0.7 https://authmesh.dev/use-cases/webhooks - 2026-03-30 - 0.7 - - - https://authmesh.dev/use-cases/kubernetes - 2026-03-30 + 2026-03-31 0.7 diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index c22a0fd..e46da44 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -24,6 +24,11 @@ export default class Init extends Command { description: 'Overwrite existing identity', default: false, }), + 'max-controllers': Flags.integer({ + description: 'Maximum number of controllers allowed (default: 1)', + default: 1, + min: 1, + }), }; async run(): Promise { @@ -69,6 +74,7 @@ export default class Init extends Command { friendlyName: flags.name, createdAt: new Date().toISOString(), storageBackend: backend, + ...(flags['max-controllers'] > 1 ? { maxControllers: flags['max-controllers'] } : {}), }; await saveIdentity(identityPath, identity); diff --git a/packages/cli/src/commands/invite.ts b/packages/cli/src/commands/invite.ts index 9ba4643..461ef7f 100644 --- a/packages/cli/src/commands/invite.ts +++ b/packages/cli/src/commands/invite.ts @@ -79,10 +79,11 @@ export default class Invite extends Command { friendlyName: result.peerFriendlyName, addedAt: new Date().toISOString(), addedBy: 'handshake', + role: 'target', }); this.log(''); - this.log(` "${result.peerFriendlyName}" added to allow list.`); + this.log(` "${result.peerFriendlyName}" added as target.`); this.log(''); this.log(' Pairing complete. The relay connection is closed.'); this.log(''); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index edb5a10..ee2c39f 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -32,7 +32,8 @@ export default class List extends Command { this.log(' ' + '─'.repeat(55)); for (const device of data.devices) { const date = device.addedAt.split('T')[0]; - this.log(` ${device.deviceId} ${device.friendlyName.padEnd(25)} added ${date}`); + const roleTag = device.role === 'controller' ? '[controller]' : '[target]'; + this.log(` ${device.deviceId} ${device.friendlyName.padEnd(25)} ${roleTag.padEnd(14)} added ${date}`); } this.log(' ' + '─'.repeat(55)); } diff --git a/packages/cli/src/commands/listen.ts b/packages/cli/src/commands/listen.ts index d042346..3e04334 100644 --- a/packages/cli/src/commands/listen.ts +++ b/packages/cli/src/commands/listen.ts @@ -72,16 +72,34 @@ export default class Listen extends Command { return; } - await allowList.addDevice({ + const newDevice = { deviceId: `am_${Buffer.from(result.peerPublicKey).toString('base64url').slice(0, 16)}`, publicKey: Buffer.from(result.peerPublicKey).toString('base64'), friendlyName: result.peerFriendlyName, addedAt: new Date().toISOString(), - addedBy: 'handshake', - }); + addedBy: 'handshake' as const, + role: 'controller' as const, + }; + + // Enforce maxControllers limit (default: 1) + const maxControllers = (identity as typeof identity & { maxControllers?: number }).maxControllers ?? 1; + const currentControllers = await allowList.countByRole('controller'); + + if (currentControllers >= maxControllers) { + this.log(` This device already has ${currentControllers} controller(s) (max: ${maxControllers}).`); + const replace = await this.confirm(' Replace existing controller(s)? (Y/n): '); + if (!replace) { + this.log(''); + this.log(' Pairing cancelled. No changes made.'); + return; + } + await allowList.replaceByRole('controller', newDevice); + } else { + await allowList.addDevice(newDevice); + } this.log(''); - this.log(` "${result.peerFriendlyName}" added to allow list.`); + this.log(` "${result.peerFriendlyName}" added as controller.`); this.log(''); this.log(' You can now use amesh signing. The relay connection is closed.'); this.log(''); diff --git a/packages/cli/src/identity.ts b/packages/cli/src/identity.ts index 0362eae..e8d9ead 100644 --- a/packages/cli/src/identity.ts +++ b/packages/cli/src/identity.ts @@ -9,6 +9,7 @@ export interface Identity { friendlyName: string; createdAt: string; // ISO 8601 storageBackend: string; + maxControllers?: number; // default 1 — max controllers allowed on this target } /** diff --git a/packages/core/src/nonce.ts b/packages/core/src/nonce.ts index 65075f3..589438b 100644 --- a/packages/core/src/nonce.ts +++ b/packages/core/src/nonce.ts @@ -10,7 +10,7 @@ export interface NonceStore { /** * In-memory nonce store. Works for single-instance deployments. - * WARNING: Does NOT work for multi-instance (Lambda, K8s replicas, auto-scaling). + * WARNING: Does NOT work for multi-instance deployments (replicas, auto-scaling). */ export class InMemoryNonceStore implements NonceStore { private store = new Map(); diff --git a/packages/keystore/package.json b/packages/keystore/package.json index 094cf5c..49ae6a4 100644 --- a/packages/keystore/package.json +++ b/packages/keystore/package.json @@ -1,7 +1,7 @@ { "name": "@authmesh/keystore", "version": "0.1.3", - "description": "Secure Enclave, TPM 2.0, and encrypted-file key storage for amesh", + "description": "Secure Enclave, macOS Keychain, and TPM 2.0 key storage for amesh", "type": "module", "license": "MIT", "author": "Yair Etzion", diff --git a/packages/keystore/src/__tests__/allow-list.test.ts b/packages/keystore/src/__tests__/allow-list.test.ts index 940cc7c..45a956f 100644 --- a/packages/keystore/src/__tests__/allow-list.test.ts +++ b/packages/keystore/src/__tests__/allow-list.test.ts @@ -13,13 +13,14 @@ function filePath() { return join(tempDir, 'allow_list.json'); } -function makeDevice(id: string, name: string): 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'), friendlyName: name, addedAt: new Date().toISOString(), addedBy: 'handshake', + role, }; } @@ -144,6 +145,91 @@ describe('AllowList', () => { }); }); + describe('role field', () => { + it('stores and retrieves device role', async () => { + const al = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); + await al.addDevice(makeDevice('am_ctrl', 'Controller', 'controller')); + await al.addDevice(makeDevice('am_tgt', 'Target', 'target')); + + const data = await al.read(); + expect(data.devices[0].role).toBe('controller'); + expect(data.devices[1].role).toBe('target'); + }); + + it('countByRole returns correct counts', async () => { + const al = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); + await al.addDevice(makeDevice('am_c1', 'Ctrl 1', 'controller')); + await al.addDevice(makeDevice('am_c2', 'Ctrl 2', 'controller')); + await al.addDevice(makeDevice('am_t1', 'Target 1', 'target')); + + expect(await al.countByRole('controller')).toBe(2); + expect(await al.countByRole('target')).toBe(1); + }); + + it('replaceByRole removes old entries and adds new one', async () => { + const al = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); + await al.addDevice(makeDevice('am_old', 'Old Controller', 'controller')); + await al.addDevice(makeDevice('am_tgt', 'Target', 'target')); + + const newCtrl = makeDevice('am_new', 'New Controller', 'controller'); + await al.replaceByRole('controller', newCtrl); + + const data = await al.read(); + const controllers = data.devices.filter((d) => d.role === 'controller'); + expect(controllers).toHaveLength(1); + expect(controllers[0].deviceId).toBe('am_new'); + // Target should be untouched + expect(data.devices.find((d) => d.role === 'target')?.deviceId).toBe('am_tgt'); + }); + + it('migrates legacy entries without role to controller', async () => { + const al = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); + await al.addDevice(makeDevice('am_legacy', 'Legacy')); + + // Manually strip the role field from the file (simulate legacy data) + const content = JSON.parse(await readFile(filePath(), 'utf-8')); + delete content.devices[0].role; + // Reseal manually — need to write without role and fix HMAC + // Easier: write with the AllowList to get valid HMAC, then strip role and re-verify + // We'll use a fresh AllowList instance that writes without role by writing raw JSON + // Actually, we need to write valid HMAC. Let's use a workaround: + // Just write the stripped JSON and create a new AllowList to re-seal it + // The migration happens on read(), but read() first verifies HMAC. + // So legacy files would have been sealed without the role field. + // We need to simulate that by sealing content without role. + + // Create a brand new file with a device that has no role field, + // 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 legacyData = { + version: '2.0.0', + 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({ + version: legacyData.version, + devices: legacyData.devices, + updatedAt: legacyData.updatedAt, + }); + const hmac = computeHmac(hmacKey, new TextEncoder().encode(canonical)); + const fileData = { ...legacyData, hmac: Buffer.from(hmac).toString('base64') }; + await writeFile(filePath(), JSON.stringify(fileData, null, 2)); + + // Now read — should migrate and add role: 'controller' + const al2 = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); + const data = await al2.read(); + expect(data.devices[0].role).toBe('controller'); + }); + }); + describe('HMAC integrity — adversarial', () => { it('rejects file with manually added device', async () => { const al = new AllowList(filePath(), PRIVATE_KEY_MATERIAL, DEVICE_ID); diff --git a/packages/keystore/src/allow-list.ts b/packages/keystore/src/allow-list.ts index be16675..e1e4f79 100644 --- a/packages/keystore/src/allow-list.ts +++ b/packages/keystore/src/allow-list.ts @@ -8,6 +8,7 @@ export interface AllowListDevice { friendlyName: string; addedAt: string; // ISO 8601 addedBy: 'handshake' | 'manual'; + role: 'controller' | 'target'; } export interface AllowListData { @@ -71,6 +72,20 @@ export class AllowList { const data = JSON.parse(content) as AllowListData; this.verifyIntegrity(data); + + // Migrate legacy entries without role field (default to 'controller' — permissive) + let needsReseal = false; + for (const device of data.devices) { + if (!device.role) { + (device as AllowListDevice).role = 'controller'; + needsReseal = true; + } + } + if (needsReseal) { + data.updatedAt = new Date().toISOString(); + await this.writeSealed(data); + } + return data; } @@ -117,6 +132,27 @@ export class AllowList { return data.devices.find((d) => d.publicKey === publicKeyBase64); } + /** + * Count devices by role. + */ + async countByRole(role: 'controller' | 'target'): Promise { + const data = await this.read(); + return data.devices.filter((d) => d.role === role).length; + } + + /** + * 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 { + const data = await this.read(); + data.devices = data.devices.filter((d) => d.role !== role); + data.devices.push(device); + data.updatedAt = new Date().toISOString(); + await this.writeSealed(data); + return data; + } + /** * Verify HMAC integrity. Throws on failure — never silently continue. */ diff --git a/packages/sdk/src/__tests__/middleware.test.ts b/packages/sdk/src/__tests__/middleware.test.ts index 2b389df..a251dd9 100644 --- a/packages/sdk/src/__tests__/middleware.test.ts +++ b/packages/sdk/src/__tests__/middleware.test.ts @@ -26,13 +26,14 @@ beforeAll(async () => { tempDir = await mkdtemp(join(tmpdir(), 'amesh-sdk-')); allowList = new AllowList(join(tempDir, 'allow_list.json'), hmacKeyMaterial, 'am_server'); - // Add our test device to the allow list + // Add our test device to the allow list as a controller (can authenticate) await allowList.addDevice({ deviceId, publicKey: publicKeyBase64, friendlyName: 'Test Device', addedAt: new Date().toISOString(), addedBy: 'handshake', + role: 'controller', }); const middleware = authMeshVerify({ allowList, clockSkewSeconds: 30, nonceWindowSeconds: 60 }); @@ -225,6 +226,39 @@ describe('authMeshVerify middleware', () => { expect(res.status).toBe(401); }); + // Step 3b — Directionality: target role cannot authenticate + it('rejects request from device with role "target" (401)', async () => { + // Create a separate allow list with a target-role device + const targetPriv = p256.utils.randomSecretKey(); + const targetPub = p256.getPublicKey(targetPriv, true); + const targetPubB64 = Buffer.from(targetPub).toString('base64'); + + // Add the target device to the server's allow list + await allowList.addDevice({ + deviceId: 'am_targetdev', + publicKey: targetPubB64, + friendlyName: 'Target Device', + addedAt: new Date().toISOString(), + addedBy: 'handshake', + role: 'target', + }); + + const ts = Math.floor(Date.now() / 1000).toString(); + const nonce = Buffer.from(randomBytes(16)).toString('base64url'); + const canonical = buildCanonicalString('GET', '/api', ts, nonce, ''); + const sig = signMessage(targetPriv, new TextEncoder().encode(canonical)); + const auth = buildAuthHeader({ + v: '1', + id: targetPubB64, + ts, + nonce, + sig: Buffer.from(sig).toString('base64url'), + }); + const res = await request('GET', '/api', { auth }); + expect(res.status).toBe(401); + expect(res.body.error).toBe('unauthorized'); + }); + // Step 7 — Swapped ID (wrong key signs, header claims another key) it('rejects when id and signature mismatch (401)', async () => { const attackerPriv = p256.utils.randomSecretKey(); diff --git a/packages/sdk/src/amesh.ts b/packages/sdk/src/amesh.ts index 9576cf0..f1c754b 100644 --- a/packages/sdk/src/amesh.ts +++ b/packages/sdk/src/amesh.ts @@ -126,6 +126,9 @@ function ameshVerify(opts?: { clockSkewSeconds?: number; nonceWindowSeconds?: nu const device = await al.findByPublicKey(parsed.id); if (!device) { sendUnauthorized(res); return; } + // Directionality check: targets cannot authenticate + if (device.role === 'target') { sendUnauthorized(res); return; } + const serverNow = Math.floor(Date.now() / 1000); const requestTs = parseInt(parsed.ts, 10); if (isNaN(requestTs) || Math.abs(serverNow - requestTs) > clockSkew) { sendUnauthorized(res); return; } diff --git a/packages/sdk/src/bootstrap.ts b/packages/sdk/src/bootstrap.ts index 1d7b645..9ca54b2 100644 --- a/packages/sdk/src/bootstrap.ts +++ b/packages/sdk/src/bootstrap.ts @@ -179,6 +179,7 @@ export async function bootstrapIfNeeded(opts?: BootstrapOptions): Promise friendlyName: 'controller', addedAt: new Date().toISOString(), addedBy: 'handshake', + role: 'controller', }); // Clear token from environment diff --git a/packages/sdk/src/middleware.ts b/packages/sdk/src/middleware.ts index ed25b11..add7355 100644 --- a/packages/sdk/src/middleware.ts +++ b/packages/sdk/src/middleware.ts @@ -56,6 +56,13 @@ export function authMeshVerify(opts: VerifyOptions) { return; } + // Step 3b — Directionality check: targets cannot authenticate + if (device.role === 'target') { + sendError(res, 401, 'unauthorized'); + logServerSide('role_rejected', device.deviceId, Math.floor(Date.now() / 1000), 0); + return; + } + // Step 4 — Clock check const serverNow = Math.floor(Date.now() / 1000); const requestTs = parseInt(parsed.ts, 10);