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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Build artifacts
dist/
hermes-node
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Standalone Go binary that pairs a remote laptop with a Hermes Agent brain over W
- [Subcommands](#subcommands)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [End-to-End Encryption](#end-to-end-encryption)
- [Security](#security)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
Expand Down Expand Up @@ -277,6 +278,7 @@ Same protocol on both sides — see [`PROTOCOL.md`](./PROTOCOL.md).
- **Full audit log** — every call is recorded in append-only JSONL with automatic rotation at 50 MB (keeps 5 files).
- **TLS 1.3 required** — public CAs work out of the box; custom CA and cert pinning supported for self-signed deployments.
- **Deny-by-default security** — empty `allowed_paths` rejects all paths. The allowlist is enforced on the laptop — the server cannot bypass it.
- **End-to-end encryption** — X25519 ECDH + AES-256-GCM encrypts all operational messages. The pairing token is never transmitted on the wire after initial pairing. See [End-to-End Encryption](#end-to-end-encryption).

### What it cannot do (v0.2, by design)
- No camera, screen, browser, mic, push notifications, or location
Expand All @@ -286,9 +288,54 @@ Same protocol on both sides — see [`PROTOCOL.md`](./PROTOCOL.md).
- No GUI pairing flow (text token only)
- No cross-platform state sync (cwd/env is per-laptop)

## 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. The pairing token is **never transmitted on the wire** — it's only used as an input to key derivation, where both sides independently mix it with an ephemeral ECDH shared secret.

### Handshake

```
Client Server
│ hello { e2e: true, ecdh_pub } │
│ ────────────────────────────────────► │
│ hello_ack { ecdh_pub, salt } │
│ ◄──────────────────────────────────── │
│ Both sides compute:
│ shared_secret = X25519(my_priv, peer_pub)
│ handshake_key = HKDF-SHA256(shared_secret, salt, info || token)
│ auth { proof: HMAC(handshake_key) } │
│ ────────────────────────────────────► │
│ auth_ok { proof: HMAC(handshake_key) }│
│ ◄──────────────────────────────────── │
│ session_key = HKDF-SHA256(handshake_key)
│ ─── AES-256-GCM ACTIVE ───
```

### Security properties

| Property | How |
|---|---|
| **Token never on wire** | Token mixed into HKDF during key derivation — the `auth` message carries only an HMAC proof, not the token itself |
| **Forward secrecy** | Ephemeral X25519 keys generated per session, discarded after disconnect. Old sessions can't be retroactively decrypted. |
| **MITM resistance** | Without the pairing token, an attacker can ECDH with both sides but can't forge the HMAC proof → `auth_err` (4001) at the handshake step |
| **Replay immunity** | New ephemeral keys per session → old proofs don't match |

### What is encrypted

**Encrypted:** `exec`, `exec_result`, `read`, `read_result`, `write`, `write_result`, ping/pong responses.

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

### Backward compatibility

The client sends `e2e: true` in the `hello` message. If the server doesn't support E2E, it omits `ecdh_pub` from `hello_ack` — the client falls back to legacy plaintext auth automatically.

## Security

- **Token** is stored in plaintext in `config.toml` (mode 0600). Revoke it on the server with `hermes node revoke --name <name>` — revocation is immediate.
- **Token** is stored in plaintext in `config.toml` (mode 0600). It is never transmitted on the wire after initial pairing — the E2E handshake uses HMAC proofs instead. Revoke it on the server with `hermes node revoke --name <name>` — revocation is immediate.
- **Path allowlist** is enforced on the laptop. Each path is symlink-resolved before the check, so symlinks escaping the allowlist are rejected.
- **TLS 1.3** required for all connections. Custom CA and cert pinning are supported.
- **Audit log** every call with action, target, duration, exit code, and status.
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
module github.com/blaspat/hermes-node

go 1.22
go 1.25.0

require (
github.com/BurntSushi/toml v1.4.0
github.com/gorilla/websocket v1.5.3
)

require golang.org/x/crypto v0.54.0 // indirect
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
192 changes: 182 additions & 10 deletions internal/wire/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package wire
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -131,6 +132,10 @@ type Client struct {
// auth_ok. Useful for log correlation; not part of the
// connection state machine.
sessionID string

// e2eKey is the AES-256-GCM session key derived after the E2E
// handshake. When nil, E2E is not active and messages are plaintext.
e2eKey []byte
}

// Conn returns the underlying *websocket.Conn. Reserved for the
Expand All @@ -143,6 +148,21 @@ func (c *Client) SessionID() string { return c.sessionID }
// NodeName returns the name this client authenticated as.
func (c *Client) NodeName() string { return c.nodeName }

// E2EActive returns true when E2E encryption is enabled for this session.
func (c *Client) E2EActive() bool { return len(c.e2eKey) > 0 }

// WriteE2E sends an envelope. If E2E is active, the payload is encrypted
// and wrapped in an `enc` envelope. Otherwise, sends plaintext JSON.
func (c *Client) WriteE2E(ctx context.Context, env Envelope) error {
return c.writeE2E(ctx, env)
}

// ReadE2E reads one envelope. If E2E is active, expects an `enc` frame
// and decrypts the payload. Otherwise, reads plaintext JSON.
func (c *Client) ReadE2E(ctx context.Context) (Envelope, error) {
return c.readE2E(ctx)
}

// Connect dials the server, performs the full hello \u2192 hello_ack \u2192
// auth \u2192 auth_ok handshake, and returns a ready-to-use Client. On
// auth_err or hello_err it returns a non-nil error wrapping the
Expand Down Expand Up @@ -199,7 +219,12 @@ func Connect(ctx context.Context, opts DialOptions) (*Client, error) {
// connection. It is split out from Connect so tests can drive the
// state machine directly with a pre-built *websocket.Conn if needed.
func (c *Client) handshake(ctx context.Context, opts DialOptions) error {
// 1. Send hello.
// ── 1. Generate E2E keypair and send hello ──
e2eKP, err := GenerateE2EKeyPair()
if err != nil {
return fmt.Errorf("wire: e2e keypair: %w", err)
}
e2e := true
hello := NewHelloEnvelope(
ProtocolVersion,
opts.NodeName,
Expand All @@ -208,11 +233,23 @@ func (c *Client) handshake(ctx context.Context, opts DialOptions) error {
opts.Arch,
opts.Capabilities,
)
hello.Payload = HelloPayload{
ProtocolVersion: ProtocolVersion,
NodeName: opts.NodeName,
NodeVersion: opts.NodeVersion,
Platform: opts.Platform,
Arch: opts.Arch,
Capabilities: opts.Capabilities,
E2E: &e2e,
ECDHPub: EncodeBase64(e2eKP.Public),
}
hello.TS = nowRFC3339()

if err := c.writeJSON(ctx, opts.HandshakeTimeout, hello); err != nil {
return fmt.Errorf("wire: send hello: %w", err)
}

// 2. Await hello_ack or hello_err.
// ── 2. Await hello_ack (with ECDH pub + salt) ──
ackEnv, err := c.readTyped(ctx, opts.HandshakeTimeout)
if err != nil {
return fmt.Errorf("wire: read hello_ack: %w", err)
Expand All @@ -224,6 +261,85 @@ func (c *Client) handshake(ctx context.Context, opts DialOptions) error {
return fmt.Errorf("wire: decode hello_ack: %w", err)
}
c.sessionID = ack.SessionID

// If the server returned ECDH pub, do E2E handshake.
if ack.ECDHPub == "" {
// Legacy server — fall through to plaintext auth.
return c.legacyAuth(ctx, opts)
}

// ── 3. E2E: derive handshake key and compute proof ──
serverPub, err := DecodeBase64(ack.ECDHPub)
if err != nil {
return fmt.Errorf("wire: decode server ecdh_pub: %w", err)
}
saltBytes, err := DecodeBase64(ack.Salt)
if err != nil {
return fmt.Errorf("wire: decode salt: %w", err)
}
ecdhShared, err := ECDH(e2eKP.Private, serverPub)
if err != nil {
return fmt.Errorf("wire: ecdh: %w", err)
}
handshakeKey, err := deriveHandshakeKey(ecdhShared, saltBytes, []byte(opts.Token))
if err != nil {
return fmt.Errorf("wire: derive handshake key: %w", err)
}
clientProof := ComputeClientProof(handshakeKey)

// ── 4. Send auth with proof (no token on wire) ──
auth := Envelope{
Type: TypeAuth,
TS: nowRFC3339(),
Payload: AuthPayload{
NodeName: opts.NodeName,
Proof: EncodeBase64(clientProof),
},
}
if err := c.writeJSON(ctx, opts.HandshakeTimeout, auth); err != nil {
return fmt.Errorf("wire: send auth: %w", err)
}

// ── 5. Await auth_ok and verify server proof ──
authEnv, err := c.readTyped(ctx, opts.HandshakeTimeout)
if err != nil {
return fmt.Errorf("wire: read auth_ok: %w", err)
}
switch authEnv.Type {
case TypeAuthOK:
var ok AuthOKPayload
if err := reMarshalInto(authEnv.Payload, &ok); err != nil {
return fmt.Errorf("wire: decode auth_ok: %w", err)
}
if ok.Proof != "" {
serverProof, err := DecodeBase64(ok.Proof)
if err != nil {
return fmt.Errorf("wire: decode server proof: %w", err)
}
if err := VerifyServerProof(handshakeKey, serverProof); err != nil {
return fmt.Errorf("wire: %w", err)
}
}
if c.sessionID == "" {
c.sessionID = ok.SessionID
}

// ── 6. Derive session key for encrypt/decrypt ──
c.e2eKey, err = DeriveSessionKey(handshakeKey, c.sessionID)
if err != nil {
return fmt.Errorf("wire: derive session key: %w", err)
}
return nil
case TypeAuthErr:
var ae AuthErrPayload
if err := reMarshalInto(authEnv.Payload, &ae); err != nil {
return fmt.Errorf("wire: decode auth_err: %w", err)
}
return fmt.Errorf("%w: reason=%q code=%d", ErrAuthFailed, ae.Reason, ae.Code)
default:
return fmt.Errorf("%w: got %q, want auth_ok or auth_err",
ErrUnexpectedMessage, authEnv.Type)
}
case TypeHelloErr:
var he HelloErrPayload
if err := reMarshalInto(ackEnv.Payload, &he); err != nil {
Expand All @@ -235,14 +351,15 @@ func (c *Client) handshake(ctx context.Context, opts DialOptions) error {
return fmt.Errorf("%w: got %q, want hello_ack or hello_err",
ErrUnexpectedMessage, ackEnv.Type)
}
}

// 3. Send auth.
// legacyAuth performs the old token-based auth when the server doesn't
// support E2E (no ecdh_pub in hello_ack).
func (c *Client) legacyAuth(ctx context.Context, opts DialOptions) error {
auth := NewAuthEnvelope(opts.NodeName, opts.Token)
if err := c.writeJSON(ctx, opts.HandshakeTimeout, auth); err != nil {
return fmt.Errorf("wire: send auth: %w", err)
}

// 4. Await auth_ok or auth_err.
authEnv, err := c.readTyped(ctx, opts.HandshakeTimeout)
if err != nil {
return fmt.Errorf("wire: read auth_ok: %w", err)
Expand All @@ -253,11 +370,6 @@ func (c *Client) handshake(ctx context.Context, opts DialOptions) error {
if err := reMarshalInto(authEnv.Payload, &ok); err != nil {
return fmt.Errorf("wire: decode auth_ok: %w", err)
}
// The server's auth_ok session_id should match
// hello_ack's; if it doesn't, something is off, but
// PROTOCOL.md \u00a73.5 doesn't require us to enforce
// it. We surface the auth_ok value if hello_ack
// didn't have one (defensive).
if c.sessionID == "" {
c.sessionID = ok.SessionID
}
Expand Down Expand Up @@ -336,3 +448,63 @@ func normaliseServerURL(serverURL string) string {
}
return u.String()
}

// writeE2E sends an envelope. If E2E is active, encrypts and wraps in `enc`.
func (c *Client) writeE2E(ctx context.Context, env Envelope) error {
if c.e2eKey != nil {
plain, err := json.Marshal(env.Payload)
if err != nil {
return fmt.Errorf("wire: marshal payload: %w", err)
}
ct, err := EncryptE2E(c.e2eKey, plain)
if err != nil {
return fmt.Errorf("wire: encrypt: %w", err)
}
env = Envelope{
Type: TypeEnc,
TS: nowRFC3339(),
Payload: EncPayload{
Data: EncodeBase64(ct),
},
}
}
if err := c.conn.SetWriteDeadline(deadlineFromCtx(ctx, 10*time.Second)); err != nil {
return fmt.Errorf("wire: set write deadline: %w", err)
}
return c.conn.WriteJSON(env)
}

// readE2E reads one envelope. If E2E is active, expects an `enc` frame.
func (c *Client) readE2E(ctx context.Context) (Envelope, error) {
if err := c.conn.SetReadDeadline(deadlineFromCtx(ctx, 10*time.Second)); err != nil {
return Envelope{}, fmt.Errorf("wire: set read deadline: %w", err)
}
_, raw, err := c.conn.ReadMessage()
if err != nil {
return Envelope{}, fmt.Errorf("wire: read: %w", err)
}
var env Envelope
if err := decodeEnvelope(raw, &env); err != nil {
return Envelope{}, fmt.Errorf("wire: decode envelope: %w", err)
}
if c.e2eKey != nil && env.Type == TypeEnc {
var ep EncPayload
if err := reMarshalInto(env.Payload, &ep); err != nil {
return Envelope{}, fmt.Errorf("wire: decode enc payload: %w", err)
}
ct, err := DecodeBase64(ep.Data)
if err != nil {
return Envelope{}, fmt.Errorf("wire: decode enc data: %w", err)
}
plain, err := DecryptE2E(c.e2eKey, ct)
if err != nil {
return Envelope{}, err
}
var result Envelope
if err := decodeEnvelope(plain, &result); err != nil {
return Envelope{}, fmt.Errorf("wire: decode decrypted envelope: %w", err)
}
return result, nil
}
return env, nil
}
Loading
Loading