Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand All @@ -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.

---

Expand Down
35 changes: 29 additions & 6 deletions docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

Expand All @@ -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.
30 changes: 21 additions & 9 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.**

Expand All @@ -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
Expand Down
80 changes: 43 additions & 37 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
└─────────────────────────────────────────────────────────────────────
```

---
Expand Down Expand Up @@ -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.
```

---
Expand All @@ -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
```

Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -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)
Expand Down
Loading
Loading