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