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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **File permissions**: all sensitive files (keys, identity, allow list) written with `0o600`/`0o700`.
- **deviceId** validated against path traversal (`/^[a-zA-Z0-9_-]+$/`).
- **Bootstrap jti** entropy increased from 32 to 128 bits.
- **Relay hardening**: per-OTC brute-force tracking (max 10 per OTC), `maxPayload` 64KB, connection limit 10K, bootstrap watcher TTL + cleanup, message field whitelisting.
- **Relay hardening**: per-OTC brute-force tracking (max 5 per OTC), `maxPayload` 64KB, connection limit 10K, bootstrap watcher TTL + cleanup, message field whitelisting.
- **Nonce store** bounded at 1M entries to prevent memory exhaustion.
- **Base64URL** decoding fix in SDK middleware (was using plain base64).
- **Canonical string** rejects newlines in fields to prevent injection.
Expand Down
11 changes: 9 additions & 2 deletions Dockerfile.relay
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ WORKDIR /app
# Copy workspace config
COPY package.json bun.lock turbo.json tsconfig.base.json ./

# Copy only the packages needed for the relay
# Copy all package.json files so bun workspace resolution works
COPY packages/core/package.json packages/core/
COPY packages/keystore/package.json packages/keystore/
COPY packages/sdk/package.json packages/sdk/
COPY packages/cli/package.json packages/cli/
COPY packages/relay/package.json packages/relay/
COPY packages/shell/package.json packages/shell/

# Stub missing workspace dirs to satisfy bun install
RUN mkdir -p demo landpage && echo '{"name":"demo","private":true}' > demo/package.json && echo '{"name":"landpage","private":true}' > landpage/package.json

# Install dependencies
RUN bun install --frozen-lockfile
RUN bun install

# Copy source
COPY packages/core/src packages/core/src
Expand Down
35 changes: 30 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ The controller CLI displays this code; the target CLI prompts the operator to en
**Why:** Default umask (typically 0644) makes encrypted key files world-readable. Defense-in-depth even when encryption is strong.

### Relay hardening
**Decision:** Added per-OTC attempt tracking (max 10), WebSocket `maxPayload` (64KB), connection limit (10K), bootstrap watcher TTL and cleanup, message field whitelisting.
**Decision:** Added per-OTC attempt tracking (max 5), WebSocket `maxPayload` (64KB), connection limit (10K), bootstrap watcher TTL and cleanup, message field whitelisting. OTC sessions expire after 60 seconds.

**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.

Expand Down Expand Up @@ -162,3 +162,33 @@ The controller CLI displays this code; the target CLI prompts the operator to en
- 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.

---

## ADR-011: Remote shell as separate package with explicit shell permission

**Decision:** The remote shell feature ships as `@authmesh/shell`, a separate npm package with separate binaries (`amesh-agent`, `amesh-shell`). Shell access requires explicit `amesh grant --shell` after pairing.

**Why:**

1. **Security boundary:** Installing `@authmesh/sdk` for HTTP API auth must never pull in PTY code or an agent daemon. The attack surface for API signing and shell access are fundamentally different.

2. **Explicit consent:** Pairing for API authentication (`amesh invite`) does not grant shell access. A `permissions.shell` flag in the allow list defaults to `false`. The target admin must explicitly run `amesh grant <device-id> --shell`. This prevents implicit privilege escalation.

3. **Separate binaries:** `amesh-agent` and `amesh-shell` are distinct from `amesh` (the CLI). Users opt into shell capability by installing a separate package.

**Security design choices:**

- **Incrementing nonce counters** (not random) for shell encryption — eliminates birthday-bound collision risk over long sessions
- **Device-ID-bound HKDF** (`amesh-shell-v1` salt + both device IDs) — cryptographic separation from pairing sessions
- **No session resumption** — dropped connection = full new ECDH handshake
- **Authenticated agent registration** — relay stores public key, controllers must match it (prevents squatting)
- **Uniform relay responses** — no `agent_not_found` message (prevents device enumeration)
- **Root guard** — agent refuses `root` without `--allow-root`
- **Per-controller session limits** — prevents DoS by authorized-but-misbehaving peers

**Rejected alternatives:**
- Bundling in `@authmesh/cli` — mixes API auth tooling with shell daemon, implicit capability creep
- Auto-granting shell on pairing — violates principle of least privilege
- Reusing pairing handshake's random-nonce encryption — birthday-bound risk over long sessions
- Session resumption — complexity and nonce-reuse risk outweigh the latency benefit
11 changes: 6 additions & 5 deletions docs/protocol-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ TARGET RELAY CONTROLLER
```
OTC = crypto.randomInt(100000, 999999).toString()
```
6 digits. Valid for **120 seconds**. Displayed prominently in the terminal.
6 digits. Valid for **60 seconds**. Displayed prominently in the terminal.

**Step 5 — ECDH Ephemeral Exchange:**
Both sides generate a **throwaway** P-256 keypair for this session only. They exchange public halves through the relay. The shared secret derived via ECDH never touches the relay. This ephemeral keypair is discarded after the ceremony.
Expand Down Expand Up @@ -299,7 +299,7 @@ $ amesh listen

┌─────────────────────────────┐
│ Your pairing code: 482916 │
│ Expires in: 120 seconds │
│ Expires in: 60 seconds
└─────────────────────────────┘

Share this code with your Controller device.
Expand Down Expand Up @@ -566,13 +566,13 @@ The relay is a **dumb, stateless, ephemeral message bus**. It exists only to sol
- Store any message to disk
- Log public keys or device IDs
- Inspect message contents (all payloads are encrypted by Step 6 of the handshake)
- Maintain sessions longer than the handshake window (120 seconds max)
- Maintain sessions longer than the handshake window (60 seconds max)

### Session Lifecycle

```
1. TARGET connects with OTC "482916"
→ Relay creates in-memory session: { otc: "482916", target: ws1, controller: null, expiresAt: now+120s }
→ Relay creates in-memory session: { otc: "482916", target: ws1, controller: null, expiresAt: now+60s }

2. CONTROLLER connects with OTC "482916"
→ Relay finds session, sets controller: ws2
Expand Down Expand Up @@ -612,8 +612,9 @@ Termination:
### Relay Rate Limiting (mandatory)
The relay MUST enforce:
- Max 5 failed OTC attempts per IP per minute
- Max 5 failed attempts per OTC (then OTC is burned)
- Max 1 active connection per OTC
- OTC session destroyed immediately on mismatch
- OTC session expires after 60 seconds

### Relay Deployment
- Deploy as a Docker container on Fly.io for MVP (~$2/month, standard Node.js, easy deploy)
Expand Down
Loading
Loading