From a8b8606ee96346274668c362c59c1e4ba3db5be7 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Mon, 3 Aug 2026 23:55:22 +0700 Subject: [PATCH 1/2] feat(wire): E2E PAKE handshake with X25519 ECDH + AES-256-GCM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds forward-secret encrypted messaging between node and plugin: - New e2e.go: X25519 keygen, ECDH, HKDF-SHA256 key derivation, HMAC-SHA256 mutual auth proofs, AES-256-GCM encrypt/decrypt - Handshake: hello(ecdh_pub) → hello_ack(ecdh_pub+salt) → auth(proof, no token on wire) → auth_ok(server proof) - Session key derived from ECDH shared secret + pairing token (token never transmitted after initial pairing) - Encrypted envelop: {type: enc, data: base64url(IV||ct||tag)} - Dispatcher delegates to Client.WriteE2E/ReadE2E - Backward compatible: legacy plaintext auth when server lacks ECDH Signed-off-by: Blasius Patrick --- .gitignore | 1 + go.mod | 4 +- go.sum | 2 + internal/wire/client.go | 192 ++++++++++++++++++++++++++++++++++-- internal/wire/dispatch.go | 28 +----- internal/wire/e2e.go | 199 ++++++++++++++++++++++++++++++++++++++ internal/wire/messages.go | 30 ++++-- 7 files changed, 414 insertions(+), 42 deletions(-) create mode 100644 internal/wire/e2e.go diff --git a/.gitignore b/.gitignore index a82c21e..6af6f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Build artifacts dist/ +hermes-node diff --git a/go.mod b/go.mod index 45a9c94..41e6dd4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b856c77..d02bda3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/wire/client.go b/internal/wire/client.go index 09312f7..6659735 100644 --- a/internal/wire/client.go +++ b/internal/wire/client.go @@ -8,6 +8,7 @@ package wire import ( "context" "crypto/tls" + "encoding/json" "errors" "fmt" "net/http" @@ -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 @@ -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 @@ -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, @@ -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) @@ -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 { @@ -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) @@ -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 } @@ -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 +} diff --git a/internal/wire/dispatch.go b/internal/wire/dispatch.go index d9a79ba..63fd258 100644 --- a/internal/wire/dispatch.go +++ b/internal/wire/dispatch.go @@ -41,7 +41,8 @@ type Handler func(ctx context.Context, requestID string, payload map[string]any) // registered with Register. The reserved types (ping / pong / bye / // error) are handled in-line by Run and cannot be overridden. type Dispatcher struct { - conn *websocket.Conn + conn *websocket.Conn + client *Client // for E2E read/write wrappers // handlers maps server-originated call types to their // handlers. The lookup uses MessageType equality. @@ -92,6 +93,7 @@ type Dispatcher struct { func NewDispatcher(c *Client) *Dispatcher { return &Dispatcher{ conn: c.Conn(), + client: c, handlers: make(map[MessageType]Handler), ReadTimeout: DefaultPongTimeout + 30*time.Second, WriteTimeout: 10 * time.Second, @@ -188,34 +190,14 @@ func (d *Dispatcher) Run(ctx context.Context) error { // liveness clock can be bumped — PROTOCOL.md §6 says any received // message counts as liveness, not just pong. func (d *Dispatcher) readOne(ctx context.Context) (Envelope, error) { - if err := d.conn.SetReadDeadline(deadlineFromCtx(ctx, d.ReadTimeout)); err != nil { - return Envelope{}, fmt.Errorf("wire: set read deadline: %w", err) - } - _, raw, err := d.conn.ReadMessage() - if err != nil { - return Envelope{}, fmt.Errorf("wire: read: %w", err) - } - if d.OnRead != nil { - d.OnRead() - } - var env Envelope - if err := decodeEnvelope(raw, &env); err != nil { - return Envelope{}, fmt.Errorf("wire: decode envelope: %w", err) - } - return env, nil + return d.client.ReadE2E(ctx) } // writeOne sends one envelope with the configured write deadline. // The envelope's MarshalJSON flattens its typed payload into the // top-level wire shape (see messages.go). func (d *Dispatcher) writeOne(ctx context.Context, env Envelope) error { - if err := d.conn.SetWriteDeadline(deadlineFromCtx(ctx, d.WriteTimeout)); err != nil { - return fmt.Errorf("wire: set write deadline: %w", err) - } - if err := d.conn.WriteJSON(env); err != nil { - return fmt.Errorf("wire: write: %w", err) - } - return nil + return d.client.WriteE2E(ctx, env) } // WriteEnvelope sends one envelope on the connection from outside diff --git a/internal/wire/e2e.go b/internal/wire/e2e.go new file mode 100644 index 0000000..b2429d6 --- /dev/null +++ b/internal/wire/e2e.go @@ -0,0 +1,199 @@ +// Package wire — E2E encryption primitives. +// +// Implements the PAKE-style handshake from docs/e2e-spec.md: +// X25519 ECDH → HKDF with pairing token → HMAC mutual auth → AES-256-GCM. +package wire + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" +) + +// e2eKeySize is AES-256. +const e2eKeySize = 32 + +// e2eNonceSize is 12 bytes (AES-GCM standard). +const e2eNonceSize = 12 + +// e2eTagSize is GCM's 16-byte authentication tag. +const e2eTagSize = 16 + +// e2eSaltSize is the random salt we put in hello_ack. +const e2eSaltSize = 32 + +// e2eInfoHandshake is the HKDF info string for the handshake key. +const e2eInfoHandshake = "hermes-node-e2e-v1" + +// e2eInfoSession is the HKDF info string for the session key. +const e2eInfoSession = "hermes-node-session-v1" + +// e2eProofClient is the HMAC message for the client proof. +const e2eProofClient = "client-auth" + +// e2eProofServer is the HMAC message for the server proof. +const e2eProofServer = "server-auth" + +// ErrE2EDecryptFailed is returned when GCM tag verification fails. +var ErrE2EDecryptFailed = errors.New("e2e: decrypt failed — wrong key or tampered data") + +// ErrE2EProofMismatch is returned when the HMAC proof doesn't verify. +var ErrE2EProofMismatch = errors.New("e2e: proof mismatch — token differs or MITM") + +// --- X25519 key generation & ECDH ------------------------------------------- + +// E2EKeyPair is an ephemeral X25519 keypair. +type E2EKeyPair struct { + Public []byte // 32 bytes + Private []byte // 32 bytes +} + +// GenerateE2EKeyPair creates a fresh X25519 keypair using crypto/rand. +func GenerateE2EKeyPair() (*E2EKeyPair, error) { + priv := make([]byte, curve25519.ScalarSize) + if _, err := io.ReadFull(rand.Reader, priv); err != nil { + return nil, fmt.Errorf("e2e: gen keypair: %w", err) + } + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("e2e: compute public: %w", err) + } + return &E2EKeyPair{Public: pub, Private: priv}, nil +} + +// ECDH performs X25519 scalar multiplication: our_priv × peer_pub. +func ECDH(ourPriv, peerPub []byte) ([]byte, error) { + shared, err := curve25519.X25519(ourPriv, peerPub) + if err != nil { + return nil, fmt.Errorf("e2e: ecdh: %w", err) + } + return shared, nil +} + +// --- HKDF key derivation ---------------------------------------------------- + +// deriveHandshakeKey produces the handshake key from the ECDH shared secret +// and the pairing token. This key is used ONLY for the HMAC proof exchange. +func deriveHandshakeKey(ecdhShared, salt, token []byte) ([]byte, error) { + info := append([]byte(e2eInfoHandshake), token...) + r := hkdf.New(sha256.New, ecdhShared, salt, info) + key := make([]byte, e2eKeySize) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("e2e: hkdf handshake: %w", err) + } + return key, nil +} + +// DeriveSessionKey produces the session key from the handshake key. +// The session key is used for AES-256-GCM encryption of operational messages. +func DeriveSessionKey(handshakeKey []byte, sessionID string) ([]byte, error) { + r := hkdf.New(sha256.New, handshakeKey, []byte(sessionID), + []byte(e2eInfoSession)) + key := make([]byte, e2eKeySize) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("e2e: hkdf session: %w", err) + } + return key, nil +} + +// --- HMAC mutual authentication --------------------------------------------- + +// ComputeClientProof returns HMAC-SHA256(handshakeKey, "client-auth"). +func ComputeClientProof(handshakeKey []byte) []byte { + mac := hmac.New(sha256.New, handshakeKey) + mac.Write([]byte(e2eProofClient)) + return mac.Sum(nil) +} + +// ComputeServerProof returns HMAC-SHA256(handshakeKey, "server-auth"). +func ComputeServerProof(handshakeKey []byte) []byte { + mac := hmac.New(sha256.New, handshakeKey) + mac.Write([]byte(e2eProofServer)) + return mac.Sum(nil) +} + +// VerifyServerProof checks that the server's proof matches our computed one. +func VerifyServerProof(handshakeKey, proof []byte) error { + expected := ComputeServerProof(handshakeKey) + if !hmac.Equal(expected, proof) { + return ErrE2EProofMismatch + } + return nil +} + +// --- AES-256-GCM encryption ------------------------------------------------- + +// EncryptE2E encrypts plaintext with AES-256-GCM. +// Returns IV || ciphertext || tag. +// The IV is 12 random bytes prepended to the output. +func EncryptE2E(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("e2e: aes cipher: %w", err) + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("e2e: gcm: %w", err) + } + nonce := make([]byte, e2eNonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("e2e: nonce: %w", err) + } + // Seal appends the tag to ciphertext. Prepend the nonce. + out := aesgcm.Seal(nonce, nonce, plaintext, nil) + return out, nil +} + +// DecryptE2E decrypts IV || ciphertext || tag with AES-256-GCM. +func DecryptE2E(key, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < e2eNonceSize+e2eTagSize { + return nil, fmt.Errorf("%w: ciphertext too short (%d bytes)", ErrE2EDecryptFailed, len(ciphertext)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("e2e: aes cipher: %w", err) + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("e2e: gcm: %w", err) + } + nonce, ct := ciphertext[:e2eNonceSize], ciphertext[e2eNonceSize:] + plain, err := aesgcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrE2EDecryptFailed, err) + } + return plain, nil +} + +// --- Encoding helpers ------------------------------------------------------- + +// EncodeBase64 returns the base64url (no padding) encoding of raw bytes. +// Used for ECDH public keys, salt, and proof values in JSON. +func EncodeBase64(raw []byte) string { + return base64.RawURLEncoding.EncodeToString(raw) +} + +// DecodeBase64 decodes a base64url string (with or without padding). +func DecodeBase64(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// --- Salt generation -------------------------------------------------------- + +// GenerateSalt returns e2eSaltSize (32) random bytes. +func GenerateSalt() ([]byte, error) { + salt := make([]byte, e2eSaltSize) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("e2e: salt: %w", err) + } + return salt, nil +} diff --git a/internal/wire/messages.go b/internal/wire/messages.go index 8ab378b..5e2084e 100644 --- a/internal/wire/messages.go +++ b/internal/wire/messages.go @@ -24,6 +24,9 @@ const ( TypeAuthOK MessageType = "auth_ok" TypeAuthErr MessageType = "auth_err" + // E2E encrypted envelope (post-handshake). + TypeEnc MessageType = "enc" + // Server-initiated calls (Task 1.6 dispatch loop). TypeExec MessageType = "exec" TypeExecResult MessageType = "exec_result" @@ -100,6 +103,8 @@ type HelloPayload struct { Platform string `json:"platform"` Arch string `json:"arch"` Capabilities []string `json:"capabilities"` + E2E *bool `json:"e2e,omitempty"` + ECDHPub string `json:"ecdh_pub,omitempty"` } // NewHelloEnvelope builds a `hello` envelope ready to send. ts is @@ -127,6 +132,8 @@ type HelloAckPayload struct { ProtocolVersion string `json:"protocol_version"` SessionID string `json:"session_id"` ServerTime string `json:"server_time"` + ECDHPub string `json:"ecdh_pub,omitempty"` + Salt string `json:"salt,omitempty"` } // HelloErrPayload is the body of a `hello_err` message (PROTOCOL.md @@ -137,10 +144,12 @@ type HelloErrPayload struct { ServerMaxVersion string `json:"server_max_version,omitempty"` } -// AuthPayload is the body of an `auth` message (PROTOCOL.md \u00a73.4). +// AuthPayload is the body of an `auth` message. When E2E is active, +// Token is empty and Proof contains the HMAC client proof.\n// In legacy (plaintext) mode, Token is present and Proof is empty. type AuthPayload struct { NodeName string `json:"node_name"` - Token string `json:"token"` + Token string `json:"token,omitempty"` + Proof string `json:"proof,omitempty"` } // NewAuthEnvelope builds an `auth` envelope. The server validates @@ -156,21 +165,26 @@ func NewAuthEnvelope(nodeName, token string) Envelope { } } -// AuthOKPayload is the body of an `auth_ok` message (PROTOCOL.md -// \u00a73.5). The session_id is informational on the client side \u2014 -// the server uses it to correlate logs. +// AuthOKPayload is the body of an `auth_ok` message. In E2E mode, +// Proof contains the HMAC server proof so the client can verify +// the server knows the pairing token. type AuthOKPayload struct { SessionID string `json:"session_id"` + Proof string `json:"proof,omitempty"` } -// AuthErrPayload is the body of an `auth_err` message (PROTOCOL.md -// \u00a73.5). The client treats this as a fatal handshake failure and -// surfaces the reason to the operator. +// AuthErrPayload is the body of an `auth_err` message. type AuthErrPayload struct { Reason string `json:"reason"` Code int `json:"code"` } +// EncPayload is the body of an `enc` message. Data is the base64url-encoded +// ciphertext (nonce || ct || tag) for E2E-encrypted operational messages. +type EncPayload struct { + Data string `json:"data"` +} + // reMarshalInto takes whatever was in the envelope's payload slot // (a map[string]any after the round-trip, or a typed struct if we // built the envelope locally) and decodes it into the target typed From 91109ca1caf3132994c2e8040f4cd983d5bc2d0b Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Tue, 4 Aug 2026 07:55:26 +0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(readme):=20document=20E2E=20PAKE=20han?= =?UTF-8?q?dshake=20=E2=80=94=20X25519=20+=20AES-256-GCM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a full End-to-End Encryption section covering the handshake flow, security properties (forward secrecy, MITM resistance, token never on wire), encrypted vs plaintext message types, and backward compatibility. Update Architecture and Security sections to reference E2E encryption. Signed-off-by: Blasius Patrick --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb14aa9..08b93d9 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 ` — 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 ` — 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.