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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A Hermes Agent plugin that turns any Hermes profile into a “brain” to comman
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Core Features](#core-features)
- [End-to-End Encryption](#end-to-end-encryption)
- [Usage](#usage)
- [Contributing](#contributing)
- [FAQ](#faq)
Expand Down Expand Up @@ -33,6 +34,19 @@ plugins:
- `node_write(target, path, content, mode="overwrite")`: write a file on a paired node (auto-retries on disconnect).
- `node_list()`: list paired nodes and their connection state.

## End-to-End Encryption

After pairing, all operational messages (`exec`, `read`, `write`, and their results) are encrypted with a per-session AES-256-GCM key derived from an X25519 ECDH handshake. The pairing token is **never transmitted on the wire** after initial pairing.

| Property | How |
|---|---|
| **Token never on wire** | Only HMAC proofs are exchanged — the token is mixed into key derivation via HKDF |
| **Forward secrecy** | New ephemeral X25519 keys per session — old sessions can't be retroactively decrypted |
| **MITM resistance** | Without the token, an attacker can't forge the HMAC proof → `auth_err` (4001) |
| **Backward compatible** | Nodes that don't send `e2e: true` in `hello` fall back to legacy plaintext auth automatically |

See [`docs/e2e-spec.md`](docs/e2e-spec.md) for the full handshake protocol and cryptographic details.

## Usage

### 1. Configure
Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def _log(msg: str, *, to_stdout: bool = False) -> None:
_auto_logger.info(msg)
if to_stdout:
print(
f"[{_datetime.datetime.now():%Y-%m-%d %H:%M:%S}] {msg}",
f"[{_datetime.datetime.now(tz=_datetime.UTC):%Y-%m-%d %H:%M:%S}] {msg}",
flush=True,
)

Expand Down
13 changes: 7 additions & 6 deletions audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,11 @@
import os
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Final, Mapping
from typing import Any, Final

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -346,7 +347,7 @@ def record(
# with microsecond precision + explicit UTC offset; ``+00:00``
# is identical to ``Z`` semantically and easier to read in
# tooling that doesn't know the ``Z`` alias.
when = ts if ts is not None else datetime.now(timezone.utc)
when = ts if ts is not None else datetime.now(UTC)
row: dict[str, Any] = {
"ts": when.isoformat(),
"node": node,
Expand Down Expand Up @@ -708,9 +709,6 @@ def reset_default_audit_writer() -> None:


__all__ = [
"AuditConfig",
"AuditError",
"AuditWriter",
"DEFAULT_AUDIT_LOG_PATH",
"DEFAULT_KEEP",
"DEFAULT_MAX_BYTES",
Expand All @@ -721,6 +719,9 @@ def reset_default_audit_writer() -> None:
"STATUS_NOT_CONNECTED",
"STATUS_OK",
"STATUS_TIMEOUT",
"AuditConfig",
"AuditError",
"AuditWriter",
"default_audit_writer",
"reset_default_audit_writer",
]
12 changes: 5 additions & 7 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
token_store_from_config,
)


# Status strings used by ``hermes node list``. Kept module-level so
# tests and downstream tools can import them without re-deriving the
# vocabulary.
Expand Down Expand Up @@ -328,7 +327,7 @@ def _cmd_pair(args: argparse.Namespace) -> int:
# line and discard the rest on stderr (or `2>/dev/null` it).
print(f"token: {token}")
print(f"name: {name}", file=sys.stderr)
print("", file=sys.stderr)
print(file=sys.stderr)
print("Run this on the laptop:", file=sys.stderr)
print(
f" hermes-node pair --server <host:port> --token {token} --name {name}",
Expand Down Expand Up @@ -441,7 +440,7 @@ def _cmd_status() -> int:
s.close()
print(f"hermes-node server: listening on {config.connect_host}:{config.port}")
return 0
except (OSError, socket.timeout):
except (TimeoutError, OSError):
print("hermes-node server: not running")
return 1

Expand Down Expand Up @@ -581,13 +580,12 @@ def main() -> None:
sys.exit(node_command(args) or 0)

__all__ = [
"setup_node_cli",
"node_command",
"STATE_CONNECTED",
"STATE_DISCONNECTED",
"STATE_NEVER_SEEN",
"STATE_REVOKED",
# Internal helpers exported for unit tests:
"_format_row",
"_connected_names",
"_format_row",
"node_command",
"setup_node_cli",
]
9 changes: 5 additions & 4 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@

import os
import socket
from collections.abc import Mapping
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Mapping
from typing import Any

import yaml

Expand Down Expand Up @@ -114,7 +115,7 @@ def probe_connect_host(candidate: str, port: int, timeout: float = 1.0) -> str |
sock.connect((candidate, port))
sock.close()
return candidate
except (OSError, socket.timeout):
except (TimeoutError, OSError):
return None


Expand Down Expand Up @@ -659,9 +660,9 @@ def load_config(
# ---------------------------------------------------------------------------

__all__ = [
"NodeServerConfig",
"load_config",
"DEFAULT_CONFIG_PATH",
"DEFAULT_TOKEN_STORE_PATH",
"ConfigError",
"NodeServerConfig",
"load_config",
]
232 changes: 232 additions & 0 deletions docs/e2e-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# E2E Encryption for Hermes Nodes — PAKE Design

## Problem

The WebSocket connection carries operational messages in plain JSON. Transport
TLS encrypts the wire but breaks with proxies in the path (nginx, corporate).
Worse: if someone captures the `auth` message (which contains the pairing
token), they can decrypt all future traffic.

## Solution: ECDH + token-based key agreement

After the `hello ↔ hello_ack` exchange, both sides have established an
ephemeral ECDH shared secret. The pairing token (known to both sides from
initial pairing, **never sent on the wire**) is mixed into the session key
derivation. Both sides prove they derived the same key with an HMAC challenge.
The result is a **per-session** AES-256-GCM key with forward secrecy — even
if an attacker records the entire handshake, they cannot derive the session
key without the pairing token.

## Cryptographic primitives

| Primitive | Choice |
|---|---|
| Key agreement | X25519 (Curve25519 ECDH) |
| Key derivation | HKDF-SHA256 |
| AEAD | AES-256-GCM |
| Mutual authentication | HMAC-SHA256 |
| Nonce | 12 random bytes per message |

## Handshake (replaces current hello/auth flow)

```
Client Server
│ │
│ ─── Step 1: ECDH exchange ─── │
│ │
│ hello { │
│ version: 1, │
│ e2e: true, │
│ ecdh_pub: "<base64url X25519 pub>" │
│ } │
│ ──────────────────────────────────────► │ server generates ephemeral keypair
│ │
│ hello_ack { │
│ session_id: "abc123", │
│ ecdh_pub: "<base64url X25519 pub>", │
│ salt: "<base64url 32 random bytes>" │
│ } │
│ ◄────────────────────────────────────── │
│ │
│ ─── Both sides independently compute: │
│ │
│ shared_secret = X25519(my_priv, peer_pub)
│ handshake_key = HKDF-SHA256( │
│ ikm = shared_secret, │
│ salt = salt, │
│ info = "hermes-node-e2e-v1" │
│ || pairing_token │ ← never sent, known to both
│ ) │
│ │
│ ─── Step 2: mutual auth ─── │
│ │
│ auth { │
│ node_name: "workmac", │
│ proof: HMAC-SHA256(handshake_key, │
│ "client-auth") │
│ } │
│ ──────────────────────────────────────► │ verifies proof
│ │
│ auth_ok { │
│ session_id: "abc123", │
│ proof: HMAC-SHA256(handshake_key, │
│ "server-auth") │
│ } │
│ ◄────────────────────────────────────── │ verifies proof
│ │
│ ─── Step 3: derive session key ─── │
│ │
│ session_key = HKDF-SHA256( │
│ ikm = handshake_key, │
│ salt = session_id, │
│ info = "hermes-node-session-v1" │
│ ) │
│ │
│ ─── E2E ACTIVE ─── │
│ │
```

### Why this is secure

| Attack | Defense |
|---|---|
| Record entire handshake, replay later | Ephemeral ECDH keys → new shared secret each session. Old `proof` values don't match. |
| Record entire handshake, know token | Still can't derive key — `shared_secret` requires one party's private key (never transmitted) |
| Man-in-the-middle | Attacker can ECDH with both sides, but without token their `handshake_key` differs → `proof` fails → connection closed at auth step |
| Compromise old session | Forward secrecy: ephemeral keys discarded after session. Old ciphertext can't be retroactively decrypted. |

## Encrypted frame (unchanged from v1)

After the handshake, messages use:

```
{"type": "enc", "data": "<base64url(IV || ciphertext || tag)>"}
```

| Offset | Bytes | Content |
|---|---|---|
| 0 | 12 | Random IV |
| 12 | N | AES-256-GCM ciphertext |
| 12+N | 16 | GCM authentication tag |

## What is encrypted

**Encrypted:** `exec`, `exec_result`, `read`, `read_result`, `write`, `write_result`,
and any future operational types.

**Plaintext:** `hello`, `hello_ack`, `auth`, `auth_ok`, `auth_err`, `ping`, `pong`,
`rate_limit`.

## Backward compatibility

- Client sends `e2e: true` in `hello`. Server that doesn't support it omits
`ecdh_pub` from `hello_ack` → client falls back to legacy plaintext auth.
- Server that supports E2E sends `ecdh_pub`. Client without E2E capability
doesn't include `e2e: true` in `hello` → server skips ECDH and falls back.

## Message structures

### `hello` (client → server)

```json
{
"type": "hello",
"version": 1,
"e2e": true,
"ecdh_pub": "hJ2kM9xPqR..."
}
```

### `hello_ack` (server → client)

```json
{
"type": "hello_ack",
"session_id": "abc123-def456",
"ecdh_pub": "qW7nB3xLzF...",
"salt": "Rj8mK2pQw..."
}
```

### `auth` (client → server)

```json
{
"type": "auth",
"node_name": "workmac",
"proof": "c2VydmVyLWF1dGg..."
}
```

### `auth_ok` (server → client)

```json
{
"type": "auth_ok",
"session_id": "abc123-def456",
"proof": "Y2xpZW50LWF1dGg..."
}
```

### `auth_err` (server → client)

```json
{
"type": "auth_err",
"reason": "verification failed",
"code": 4003
}
```

### `enc` (both directions, post-handshake)

```json
{
"type": "enc",
"data": "ZzdDazlN..."
}
```

## Error codes

| Code | Label | Meaning |
|---|---|---|
| 4000 | `E2E_DECRYPT_FAIL` | GCM tag verification failed — wrong key or tampered data |
| 4001 | `E2E_PROOF_MISMATCH` | HMAC proof mismatch — token differs or MITM |
| 4002 | `E2E_KEYPAIR_FAIL` | Failed to generate ephemeral X25519 keypair |
| 4003 | `E2E_PROTOCOL_ERROR` | Missing or malformed E2E fields in handshake |

## Implementation plan (~300 lines total)

### Go (`internal/wire/`)

| File | Change |
|---|---|
| `messages.go` | Add `E2E` / `ECDHPub` / `Proof` fields to handshake structs |
| `e2e.go` (new) | X25519 keygen, ECDH, HKDF derivation, encrypt/decrypt helpers |
| `handshake.go` | ECDH exchange in `hello`/`hello_ack`, proof exchange in `auth`/`auth_ok` |
| `client.go` | After auth_ok, wrap read/write with encrypt/decrypt |

### Python (`wsserver/`)

| File | Change |
|---|---|
| `e2e.py` (new) | X25519 keygen, ECDH, HKDF derivation, encrypt/decrypt helpers |
| `server.py` | ECDH exchange during hello handshake, proof verification, encrypt/decrypt wrappers |

### Tests (`e2e_test.go` + `test_e2e.py`)

- `TestFullHandshake` — happy path: connect, handshake, encrypt/decrypt roundtrip
- `TestWrongToken` — client with wrong token fails at proof verification (4001)
- `TestMITM` — attacker relays ECDH but can't forge proof → auth fails
- `TestBackwardCompat` — client without `e2e: true` gets plaintext legacy auth
- `TestKeyIsolation` — two sessions produce different session keys
- `TestDecryptFailure` — tampered `enc` data → close code 4000

## Dependency changes

**Go:** `golang.org/x/crypto` (already in go.mod — used for SSH terminal)

**Python:** `cryptography` (already in pyproject.toml — used by token encryption)

No new dependencies. X25519 and HKDF are in both existing crypto packages.
Loading
Loading