- POST /v1/echo: returns the received request head + body (base64) and the observed TLS parameters (version, cipher, SNI, ALPN, resumed). The client diffs against what it sent to detect header injection/stripping, transparent proxying, or TLS interception (sec.http_echo). http-echo added to the capability set. - GET /v1/tls-reference: the served leaf-first DER chain + pin, so the app can compare an out-of-band copy against its own handshake (sec.tls_reference). Always available, no auth — public handshake info. - Optional CLEARTEXT http-echo listener (ECHOLOT_HTTP_ECHO_LISTEN, default off) exposing only /v1/echo for the plaintext-path tampering test. Live-smoke-tested (HTTPS echo reflected an injected header + observed TLS1.3; cleartext variant reports tls:none); httptest unit tests added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95 lines
2.9 KiB
Go
95 lines
2.9 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package control
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// httpEcho implements spec §4 HTTP echo: return the exact received request
|
|
// (request line + headers + body, base64) plus the TLS parameters the server
|
|
// observed. The client diffs this against what it sent to detect header
|
|
// injection/stripping, transparent proxying, or TLS interception
|
|
// (sec.http_echo). Served on the control HTTPS listener and, optionally, on a
|
|
// cleartext listener to test plaintext-path tampering.
|
|
func (s *Server) httpEcho(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
|
|
// Reconstruct the received request head verbatim (as close as net/http
|
|
// exposes it — header order is lost, but names/values and the request
|
|
// line survive, which is what tampering changes).
|
|
var head strings.Builder
|
|
head.WriteString(r.Method + " " + r.RequestURI + " " + r.Proto + "\r\n")
|
|
head.WriteString("Host: " + r.Host + "\r\n")
|
|
for name, vals := range r.Header {
|
|
for _, v := range vals {
|
|
head.WriteString(name + ": " + v + "\r\n")
|
|
}
|
|
}
|
|
head.WriteString("\r\n")
|
|
|
|
resp := map[string]any{
|
|
"observed_src": r.RemoteAddr,
|
|
"request_head_b64": base64.StdEncoding.EncodeToString([]byte(head.String())),
|
|
"body_b64": base64.StdEncoding.EncodeToString(body),
|
|
"body_len": len(body),
|
|
"scheme": schemeOf(r),
|
|
}
|
|
if r.TLS != nil {
|
|
resp["tls"] = tlsParams(r.TLS)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func schemeOf(r *http.Request) string {
|
|
if r.TLS != nil {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
func tlsParams(cs *tls.ConnectionState) map[string]any {
|
|
return map[string]any{
|
|
"version": tlsVersionName(cs.Version),
|
|
"cipher": tls.CipherSuiteName(cs.CipherSuite),
|
|
"sni": cs.ServerName,
|
|
"alpn": cs.NegotiatedProtocol,
|
|
"resumed": cs.DidResume,
|
|
}
|
|
}
|
|
|
|
func tlsVersionName(v uint16) string {
|
|
switch v {
|
|
case tls.VersionTLS13:
|
|
return "TLS1.3"
|
|
case tls.VersionTLS12:
|
|
return "TLS1.2"
|
|
case tls.VersionTLS11:
|
|
return "TLS1.1"
|
|
case tls.VersionTLS10:
|
|
return "TLS1.0"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// tlsReference implements spec §4: return the exact certificate chain this
|
|
// server serves (DER, base64), so the app can compare it against a copy it
|
|
// obtained out-of-band and against what its own direct handshake yielded
|
|
// (sec.tls_reference). Not a capability — always available on the control
|
|
// plane. No auth: the chain is public information a handshake already reveals.
|
|
func (s *Server) tlsReference(w http.ResponseWriter, r *http.Request) {
|
|
chain := make([]string, 0, len(s.CertChain))
|
|
for _, der := range s.CertChain {
|
|
chain = append(chain, base64.StdEncoding.EncodeToString(der))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"pin_sha256": s.PinB64,
|
|
"chain_der": chain, // leaf first, as served
|
|
})
|
|
}
|