feat(auth): add HMAC-SHA256 authenticated ingest channel (Issue #8) - #11
Conversation
β¦install - Add --force flag to allow reinstall when service already exists - Add validateDataDir() to check data directory is writable - Add validateBinary() to verify binary location - Add validateBinaryPath() as alias for compatibility - Add checkPortConflict() to detect port 8844 conflicts - Add validatePermissions() to verify service installation permissions - Add getDefaultPort() returning 8844 - Fix race condition: wait 2s after Stop() and 2s after Uninstall() before proceeding This enables 'metronous install --force' for reinstall scenarios on Windows.
- Add logging to mcp_shim_windows.go ensureDaemonRunning function - Add logging to server.go to show serve mode selection - Add detailed logging to metronous-plugin.ts readPortFile and waitForServer This helps diagnose why the OpenCode plugin cannot connect to the MCP server.
- TestShimPortFilePath: verifies correct port file path format - TestReadShimPortSuccess: tests reading a valid port file - TestReadShimPortInvalid: tests error on invalid port content - TestReadShimPortOutOfRange: tests port validation (1-65535) - TestReadShimPortMissing: tests error on missing file
- Replace fixed 2s sleeps with polling for service status (waitForServiceStop, waitForServiceUninstalled)
- Replace checkPortConflict() (useless - checked hardcoded port 8844 that daemon never uses) with checkDaemonRunning() that reads mcp.port file and performs real health check
- Add handleServiceExists() for idempotent install without --force flag
- Add cleanupServiceFiles() to remove mcp.port, daemon.lock, daemon.pid after uninstall
- Daemon uses dynamic ports via net.Listen('tcp', '127.0.0.1:0'), not fixed 8844
- These are user-specific development rules - Not all developers share the same environment (Windows/PowerShell vs Linux) - Each developer can have their own AGENTS.md without affecting the repo
β¦antra#8) - Add HMAC-SHA256 authentication to /ingest endpoint - Plugin generates and signs shared secret from {dataDir}/ingest.key - Server supports both plain token and HMAC signature verification - Use hmac.Equal() for timing-safe comparison to prevent attacks - Add tests for 1MB payload limit and file-based token loading - Two-pass review with Judgment Day
There was a problem hiding this comment.
Pull request overview
Adds an HMAC-SHA256 authenticated channel for the /ingest HTTP endpoint so telemetry submissions can be verified and spoofing reduced.
Changes:
- Plugin generates/loads a shared secret from
{dataDir}/ingest.keyand signs ingest request bodies withX-Metronous-Auth: sha256:<signature>. - Server verifies ingest auth using either a plain token or HMAC signature, and can load the secret from env or
ingest.key. - Adds/updates tests for auth behaviors, 1MB payload rejection, and file-based token loading.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| metronous-plugin.ts | Generates/loads shared secret and signs /ingest requests via HMAC-SHA256 header. |
| internal/mcp/server.go | Implements token/file loading and HMAC/legacy token verification for /ingest. |
| internal/mcp/server_test.go | Adds subtests for auth modes, payload-size rejection, and ingest.key-based token loading. |
| .gitignore | Ignores user-specific rule files. |
π‘ Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *Server) ingestHandler(ctx context.Context) http.HandlerFunc { | ||
| // Try to get token from env, fall back to reading from ingest.key file | ||
| expectedToken := os.Getenv(ingestAuthEnvVar) | ||
| if expectedToken == "" { | ||
| // Try to read from ingest.key file | ||
| keyPath := s.ingestKeyPath() | ||
| if data, err := os.ReadFile(keyPath); err == nil { | ||
| expectedToken = strings.TrimSpace(string(data)) | ||
| } | ||
| } | ||
|
|
||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodPost { | ||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
expectedToken is captured once when the handler is registered. If METRONOUS_INGEST_TOKEN is unset and {dataDir}/ingest.key is created/rotated after server start (which is likely since the plugin lazily creates it on first request), the server will continue running with expectedToken == "" (auth bypass) or a stale secret (auth failures). Resolve the token inside the returned handler per-request (or implement safe caching with reload) and consider validating the file format (e.g., 64 hex chars) before enabling auth.
| func (s *Server) ingestHandler(ctx context.Context) http.HandlerFunc { | |
| // Try to get token from env, fall back to reading from ingest.key file | |
| expectedToken := os.Getenv(ingestAuthEnvVar) | |
| if expectedToken == "" { | |
| // Try to read from ingest.key file | |
| keyPath := s.ingestKeyPath() | |
| if data, err := os.ReadFile(keyPath); err == nil { | |
| expectedToken = strings.TrimSpace(string(data)) | |
| } | |
| } | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| func (s *Server) resolveIngestToken() string { | |
| expectedToken := os.Getenv(ingestAuthEnvVar) | |
| if expectedToken != "" { | |
| return expectedToken | |
| } | |
| keyPath := s.ingestKeyPath() | |
| data, err := os.ReadFile(keyPath) | |
| if err != nil { | |
| return "" | |
| } | |
| expectedToken = strings.TrimSpace(string(data)) | |
| if expectedToken == "" { | |
| return "" | |
| } | |
| if len(expectedToken) != 64 { | |
| s.logger.Warn("ignoring invalid ingest key file", | |
| zap.String("path", keyPath), | |
| zap.Int("length", len(expectedToken))) | |
| return "" | |
| } | |
| if _, err := hex.DecodeString(expectedToken); err != nil { | |
| s.logger.Warn("ignoring invalid ingest key file", | |
| zap.String("path", keyPath), | |
| zap.Error(err)) | |
| return "" | |
| } | |
| return expectedToken | |
| } | |
| func (s *Server) ingestHandler(ctx context.Context) http.HandlerFunc { | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| expectedToken := s.resolveIngestToken() |
| // token in the X-Metronous-Auth header (exact match). | ||
| // - If the token is not set, requests are accepted without authentication (legacy mode). | ||
| // | ||
| // The header value can be either: | ||
| // - Plain token (backward compatibility): exact match against METRONOUS_INGEST_TOKEN |
There was a problem hiding this comment.
The authentication doc comment says auth is only required when METRONOUS_INGEST_TOKEN is set, but the implementation also enables auth when {dataDir}/ingest.key exists. Update the comment to describe the file-based secret behavior so it matches the actual logic.
| // token in the X-Metronous-Auth header (exact match). | |
| // - If the token is not set, requests are accepted without authentication (legacy mode). | |
| // | |
| // The header value can be either: | |
| // - Plain token (backward compatibility): exact match against METRONOUS_INGEST_TOKEN | |
| // token in the X-Metronous-Auth header. | |
| // - Otherwise, if {dataDir}/ingest.key exists and contains a token, requests MUST | |
| // include that matching token in the X-Metronous-Auth header. | |
| // - If neither source provides a token, requests are accepted without authentication | |
| // (legacy mode). | |
| // | |
| // The header value can be either: | |
| // - Plain token (backward compatibility): exact match against the configured secret |
| // Compute expected HMAC | ||
| mac := hmac.New(sha256.New, []byte(expectedToken)) | ||
| mac.Write([]byte(bodyStr)) | ||
| expectedSig := hex.EncodeToString(mac.Sum(nil)) | ||
| if hmac.Equal([]byte(sig), []byte(expectedSig)) { |
There was a problem hiding this comment.
HMAC verification compares hex strings via hmac.Equal([]byte(sig), []byte(expectedSig)) without validating/decoding sig. This rejects equivalent uppercase hex and doesnβt ensure the signature is the correct length/hex. Decode sig with hex.DecodeString, verify itβs 32 bytes, and compare raw MAC bytes with hmac.Equal.
| // Compute expected HMAC | |
| mac := hmac.New(sha256.New, []byte(expectedToken)) | |
| mac.Write([]byte(bodyStr)) | |
| expectedSig := hex.EncodeToString(mac.Sum(nil)) | |
| if hmac.Equal([]byte(sig), []byte(expectedSig)) { | |
| providedSig, err := hex.DecodeString(sig) | |
| if err != nil || len(providedSig) != sha256.Size { | |
| s.logger.Warn("invalid HMAC signature in ingest request", | |
| zap.String("remote_addr", r.RemoteAddr)) | |
| http.Error(w, "invalid authentication signature", http.StatusUnauthorized) | |
| return | |
| } | |
| // Compute expected HMAC | |
| mac := hmac.New(sha256.New, []byte(expectedToken)) | |
| mac.Write([]byte(bodyStr)) | |
| expectedSig := mac.Sum(nil) | |
| if hmac.Equal(providedSig, expectedSig) { |
| bodyBytes, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024)) | ||
| if err != nil { | ||
| http.Error(w, "failed to read request body", http.StatusBadRequest) | ||
| return |
There was a problem hiding this comment.
When http.MaxBytesReader hits the limit, it returns *http.MaxBytesError. Right now this path returns 400, but callers/tests expect a 413 for oversized payloads (especially for chunked requests where ContentLength is -1). Detect *http.MaxBytesError here and return StatusRequestEntityTooLarge.
| // Set token via file | ||
| tokenPath := dataDir + "/ingest.key" | ||
| token := "test-token-64-chars-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx" | ||
| _ = os.WriteFile(tokenPath, []byte(token), 0600) | ||
|
|
There was a problem hiding this comment.
Avoid ignoring the os.WriteFile error in tests; if it fails, the test may pass/fail for the wrong reason. Also prefer filepath.Join(dataDir, "ingest.key") over string concatenation for portability.
| // Write token to file instead of env var | ||
| tokenPath := dataDir + "/ingest.key" | ||
| token := "file-based-token-64-chars-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx" | ||
| _ = os.WriteFile(tokenPath, []byte(token), 0600) | ||
|
|
There was a problem hiding this comment.
Avoid ignoring the os.WriteFile error in tests; if it fails, the test may pass/fail for the wrong reason. Also prefer filepath.Join(dataDir, "ingest.key") over string concatenation for portability.
| if err != context.Canceled { | ||
| t.Logf("ServeWithHealth error: %v", err) |
There was a problem hiding this comment.
This test now logs unexpected ServeWithHealth errors instead of failing. That can mask real regressions where the server exits early or returns a non-cancelation error. Keep the assertion strict (fail on unexpected errors) as before, or at least fail on any non-nil error other than context.Canceled.
| if err != context.Canceled { | |
| t.Logf("ServeWithHealth error: %v", err) | |
| if err != nil && !errors.Is(err, context.Canceled) { | |
| t.Fatalf("ServeWithHealth returned unexpected error: %v", err) |
kiosvantra
left a comment
There was a problem hiding this comment.
Blocking issue: the server resolves METRONOUS_INGEST_TOKEN / ingest.key once when ingestHandler is created, not per request. The plugin creates ingest.key lazily on first HTTP send, so a fresh daemon starts with expectedToken == "" and will keep accepting unauthenticated requests for the lifetime of that process. Authentication only starts working after a daemon restart, which defeats the security goal of this PR. Please reload the secret at request time or guarantee the key exists before the handler is registered.
The plugin creates ingest.key lazily on first HTTP send. The daemon was caching the token at handler registration time, so a fresh daemon would accept unauthenticated requests until restart. Now reads token at request time (env first, then file) to properly handle the lazy key creation pattern.
kiosvantra
left a comment
There was a problem hiding this comment.
The per-request token reload fixes the lazy ingest.key bootstrap issue I flagged earlier. My blocking concern is resolved. I still agree with the non-blocking follow-ups about returning 413 on MaxBytesError in the HMAC path and keeping the ServeWithHealth test assertions strict, but those do not block this PR for me.
- Extract resolveIngestToken() method: per-request token resolution with validation (64 hex chars, valid hex decode) - Decode HMAC signature to raw bytes before comparison instead of comparing hex strings, verify sig is exactly 32 bytes (sha256.Size) - Return 413 for MaxBytesError in both HMAC and JSON decode paths - Update doc comment to document file-based auth source - Fix tests: check os.WriteFile errors, use filepath.Join, strict ServeWithHealth assertions (fail on non-canceled errors) - Use valid 64-char hex tokens in tests
|
Thanks for the thorough review! All feedback addressed in commit 13843a4:
All tests passing, go vet clean. |
Summary
/ingestendpoint to prevent telemetry spoofing{dataDir}/ingest.keyhmac.Equal()for timing-safe comparison to prevent timing attacksChanges
metronous-plugin.ts
{dataDir}/ingest.key(64-char hex)X-Metronous-Auth: sha256:<signature>headerinternal/mcp/server.go
METRONOUS_INGEST_TOKENenv or{dataDir}/ingest.keyfilesha256:...format)hmac.Equal()) to prevent attacksinternal/mcp/server_test.go
Testing