From 5dc57bc2349f039fcffaf72cfaa7c16ea02fd269 Mon Sep 17 00:00:00 2001 From: wweir Date: Fri, 18 Sep 2026 18:04:44 +0800 Subject: [PATCH] docs: security model for the sowerd edge; document the unknown-SNI cert fallback Review of the anti-probing hardening found no code defects; the fixes are documentation: - docs/security.md: authoritative threat model, the three camouflage layers, unknown-SNI certificate fallback rules (whitelist source, error surfacing, capability propagation, concrete first entry), the client_hello fingerprint knob, rejected directions (QUIC, h2) with evidence, and residual accepted signals. - Promote the plan doc to a non-authoritative historical record per AGENTS.md docs rules; index security.md from docs/README.md and ARCHITECTURE.md. - config/sowerd.toml: document cert.domains; the first entry answers unknown-SNI handshakes. --- ARCHITECTURE.md | 2 + config/sowerd.toml | 1 + docs/README.md | 1 + .../2026-09-18-sowerd-antiprobe-hardening.md | 57 +-------- docs/security.md | 115 ++++++++++++++++++ 5 files changed, 124 insertions(+), 52 deletions(-) create mode 100644 docs/security.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b805dc4..53da8d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -155,8 +155,10 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre - ACME mode requires port `80` to be reachable from the public internet. - Remote rule download failures stop startup after bounded retries before local listeners are exposed. - `sowerd` custom certificate mode must use a certificate whose SANs cover all configured `site_routes` domains. +- In ACME mode the handshake is answered for arbitrary SNI with the first whitelist domain's certificate (unknown-SNI fallback); keep that entry concrete. See [docs/security.md](docs/security.md). ## Related Documents - [README.md](README.md) - [docs/README.md](docs/README.md) — documentation index +- [docs/security.md](docs/security.md) — sowerd edge threat model and anti-probing rules diff --git a/config/sowerd.toml b/config/sowerd.toml index fc2f995..ba6f72d 100644 --- a/config/sowerd.toml +++ b/config/sowerd.toml @@ -25,5 +25,6 @@ fake_site = "/var/www" # Fallback fake site directory or address (def # SSL/TLS certificate configuration [cert] email = "your-email@example.com" # Email for Let's Encrypt certificate +domains = ["proxy.example.com"] # Issuance whitelist beyond site_routes. The FIRST entry answers TLS handshakes for unknown/empty SNI (keep it a concrete domain, not a wildcard); see docs/security.md cert = "" # Path to custom certificate file (optional) key = "" # Path to custom private key file (optional) diff --git a/docs/README.md b/docs/README.md index d8eb4d4..7373d40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Cross-document references must point to root-level design documents only. Never | Document | Description | |----------|-------------| | [ARCHITECTURE.md](../ARCHITECTURE.md) | System boundary, layer responsibilities, data flows, design decisions | +| [security.md](security.md) | sowerd edge threat model, anti-probing layers, unknown-SNI certificate fallback, rejected directions | ## Plans diff --git a/docs/plans/2026-09-18-sowerd-antiprobe-hardening.md b/docs/plans/2026-09-18-sowerd-antiprobe-hardening.md index d955e17..606a04d 100644 --- a/docs/plans/2026-09-18-sowerd-antiprobe-hardening.md +++ b/docs/plans/2026-09-18-sowerd-antiprobe-hardening.md @@ -1,55 +1,8 @@ -# sowerd 主动探测加固:未知 SNI 默认证书 + fallback 路径 h2 +# Plan: sowerd 主动探测加固(未知 SNI 默认证书 + h2) -状态:实施中(未定稿,定稿后回写 `docs/` 主设计文档) +状态:已定稿并回写(2026-09-18)。本文为非权威历史记录,权威安全规则见 [security.md](../security.md)。 -日期:2026-09-18 -决策背景:用户明确「抗探测优先」,否决 QUIC/HTTP3(自定义 ALPN 会向主动探测者暴露私有协议存在)。 +实施结果摘要: -## 问题分析 - -sowerd 的 443 端口当前有两处与真实网站不符的行为,构成主动/被动探测特征: - -1. **未知 SNI 在 TLS 握手阶段失败**。autocert 的 `HostPolicy` 白名单(site_routes 域名 + `cert.domains`)之外的名字拿不到证书,握手直接失败。真实网站普遍回退默认 vhost 证书完成握手。探测者用任意 SNI 一探便知「这个 443 对未知域名不正常」。 -2. **ALPN 只声明 `http/1.1`**。2026 年真实网站几乎都会协商 h2;ServerHello 中选中的 ALPN 是明文可见的指纹。sowerd 的 fake-site / site-routes 反代却只走 HTTP/1.1。 - -作为对比,已被否决的方向:QUIC/HTTP3(`quic-go` + 自定义 ALPN)会把「存在非标准协议」这一事实直接交给 ALPN 枚举,比现状更差。 - -## 方案 - -### (b) 未知 SNI 回退默认证书 - -`buildTLSConfig` 中用公开 API 包装 `autocert.Manager.GetCertificate`:白名单内正常签发;白名单外/无 SNI 时,改用主域名(`certIssueDomains` 第一个)再取一次证书作为回退。主域名证书已缓存在内存(或触发一次签发),失败则返回原始错误。静态证书模式(`cert.cert` 已配置)本来就对所有 SNI 回同一张证书,无需改动。 - -效果:探测者对任意 SNI 都能完成握手并看到一张真实证书,随后进入与正常访客完全相同的 fake-site/site-routes 路径。 - -### (a) fallback 路径支持 h2 —— 已否决(2026-09-18 核查后撤回) - -初稿计划 advertise `h2` 并在每连接 fallback 上用 `x/net/http2` 服务,实施前两项核查均不通过: - -1. **stdlib `httputil.ReverseProxy` 不支持 RFC 8441**:`upgradeType()` 只认 `Connection: Upgrade`/`Upgrade` 头;extended CONNECT(`:protocol: websocket`,无 Upgrade 头)会被当普通 CONNECT 透传,hub 不是 CONNECT 代理,WSS 必断。 -2. **x/net 到 v0.59 仍无程序化开关**:extended CONNECT 只能 `GODEBUG=http2xconnect=1` 进程级打开,且默认禁用的官方理由正是服务端 websocket 栈不支持会出问题(golang/go#71128)。 - -剩余唯一不破坏 WSS 的 h2 方案是按 SNI 区分 ALPN(WingGate 域名保持 http/1.1),但它把「新增 WS 路由必须记得设 h1_only」变成静默失效点,抗审查边缘组件不可接受;且「ALPN 只协商 http/1.1」本身是弱信号(大量真实小站如此),收益不值得。`NextProtos` 维持 `["http/1.1"]`。 - -### 实施结果 - -仅实施 (b):`buildTLSConfig` 在 autocert 模式下用 `getCertificateWithFallback` 包装 `GetCertificate`——白名单内域名走原路径(错误原样暴露,绝不拿错名证书掩盖);白名单外/空 SNI 回退到主域名证书,**透传原 hello 的客户端能力**(否则 autocert 会按非 ECDSA 客户端走 `domain+rsa` 缓存键,触发多余的 RSA 签发)。单测覆盖:未知/空/白名单 SNI 的回退与正常路径、签发错误不被掩盖。 - -## 风险与缓解 - -| 风险 | 缓解 | -|---|---| -| 回退证书掩盖白名单域名的真实错误(ACME/缓存失败) | 只对白名单外 SNI 回退;白名单内错误原样暴露(单测 `TestGetCertificateFallbackNeverMasksIssuanceErrors`) | -| 回退 hello 丢失客户端能力 → autocert 误判非 ECDSA → 多余 RSA 签发 | 透传原 hello 只改 ServerName | -| 探测者现在总能完成 TLS 握手,扫描流量增加 | 与真实站点行为一致;fallback 内容本身就是真网站 | -| h2 若未来重提,WSS-over-h2 链路(ReverseProxy 无 RFC 8441)断裂 | 已记录否决证据,重提前必须解决 stdlib 缺口 | - -## 验证 - -单测:未知/空/白名单 SNI 的证书回退与正常路径、签发错误不被掩盖;既有 0x80/fake-site 测试全绿(`go test ./...` exit 0,13 包)。 -部署 kr 后:`curl` h1 WSS 握手 101;`openssl s_client -servername <随机域名>` 完成握手并看到回退证书;`journalctl -u sowerd` 无新增 `proxy upstream error`;r6c sower e2e 不回归。 - -## 明确不做 - -- QUIC / HTTP3 传输(理由见上)。 -- 连接复用/多路复用以降低统计特征(收益不确定,复杂度高,与 KISS 冲突)。 +- (b) 未知 SNI 回退默认证书:已实现并部署(commit `b2b344e`)。白名单内错误原样暴露;白名单外/空 SNI 回退主域名证书并透传客户端能力(避免多余的 RSA 签发)。 +- (a) fallback 路径 h2:实施前核查否决——stdlib `httputil.ReverseProxy` 无 RFC 8441 支持,x/net(≤v0.59)仅以 `GODEBUG=http2xconnect=1` 提供 extended CONNECT 且默认禁用(golang/go#71128);按 SNI 区分 ALPN 的替代方案因静默失效风险被拒。详细证据见 [security.md](../security.md) 的 "Rejected Directions"。 diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..8fbf80d --- /dev/null +++ b/docs/security.md @@ -0,0 +1,115 @@ +# Security: sowerd Edge Threat Model and Anti-Probing Rules + +Authoritative security design for the `sowerd` TLS edge. Rules here are +normative; historical analysis lives in `docs/plans/` (non-authoritative). + +## Threat Model + +Two adversaries are in scope: + +- **Passive DPI observer** — sees wire bytes and flow metadata; does not + complete handshakes. +- **Active prober** — completes TLS handshakes, sends arbitrary application + bytes, observes responses. Does not know the sower password. + +Goal: keep the `443` edge behaviorally consistent with an ordinary TLS +website for both adversaries, so that no single observation — wire bytes, +handshake behavior, or application responses — is by itself proof that the +sower transport exists. This is a design goal, not a guarantee: known +residual signals that fall short of full indistinguishability are listed +under "Residual Accepted Signals" and drive the rejected-directions +recorded below. + +## Layer 1 — Wire Format Is Ordinary HTTPS + +- Real TLS 1.2+ with publicly trusted certificates: autocert/Let's Encrypt, + or a configured static pair. All sower metadata (the transport `Head`) + rides inside TLS; there is no protocol-specific plaintext. +- ALPN is `http/1.1` only. This is a residual but accepted signal (see + "Rejected directions" for why h2 is not served). + +## Layer 2 — Protocol Identification Happens After the Handshake + +- The sower transport is recognized by the first application byte `0x80`. + `~0x80 == 0x7F` is the highest printable code point, so no valid HTTP/1.1 + plaintext can begin with `0x80`; probe dispatch is unambiguous. +- The `Head` checksum (HMAC-SHA256 over cmd/port/target keyed by the + password) authenticates the frame and prevents MITM retargeting of + captured frames. It is not an encryption layer; confidentiality and + integrity come from TLS. +- **ALPN is never trusted for dispatch.** A sower client may offer `h2` + (e.g. uTLS Chrome fingerprint); the post-handshake sniff routes it to the + transport path regardless of the negotiated protocol. + +## Layer 3 — Everything Else Is a Website + +Dispatch order for non-transport traffic (`cmd/sowerd/main.go:handleConn`): + +1. TLS SNI hit in `site_routes` → reverse proxy to the route's upstream. +2. Otherwise, a valid HTTP/1.1 request line → reverse proxy to `fake_site`. +3. Otherwise → raw TCP relay to `fake_site`. + +Transport auth failures fall back to the same website paths (never RST, never +hang), so a prober without the password observes ordinary site behavior on +every probe. + +## Unknown-SNI Certificate Fallback + +**Rule: the TLS handshake must complete for arbitrary SNI.** A handshake +failure on unknown names is an active-probing signal — real sites present +their default vhost certificate. + +Implementation rules (`cmd/sowerd/main.go:getCertificateWithFallback`, +autocert mode only): + +- The issuance whitelist is `cert.domains` + every `site_routes` domain — + the same source as the autocert `HostPolicy`, so the membership decision + cannot drift from issuance behavior. +- **Whitelisted names** take the plain autocert path. Failures (ACME + issuance, cache) surface as-is and are never masked by a wrong-name + certificate. +- **Unknown or empty SNI** is answered with the primary domain's + certificate. "Primary" is the first entry of the issuance whitelist, so + keep `cert.domains[0]` a concrete domain (a leading wildcard entry would + break the fallback lookup). +- The client's capabilities (signature schemes, curves, cipher suites) are + carried over to the fallback lookup so autocert resolves its usual + ECDSA/RSA cache key; a bare hello would count as non-ECDSA and force a + spurious RSA issuance. +- Static certificate mode (`cert.cert` configured) already answers every + SNI with the configured pair; the wrapper is autocert-mode only. + +## Client-Side Fingerprint + +The default Go TLS `ClientHello` is an uncommon fingerprint for a "browser". +sower clients should set `[remote.tls] client_hello = "chrome"` (uTLS) in +deployment configs. This is a client configuration knob, not a protocol +property, and has no server-side effect. + +## Rejected Directions + +Decisions recorded here so they are not re-litigated without new evidence: + +- **QUIC / HTTP3 transport** — requires either a dedicated UDP endpoint or a + custom ALPN. Both expose nonstandard-protocol existence to an active + prober (port scanning / ALPN enumeration), which is strictly weaker than + the current byte sniff that reveals nothing. Would also require a TCP + fallback path everywhere. Experimented with on 2026-09-17/18 and removed. +- **h2 on the fallback path** — once `h2` is advertised, browsers negotiate + it and WebSockets require RFC 8441 extended CONNECT. The Go standard + library `httputil.ReverseProxy` has no extended-CONNECT support (it treats + the request as a plain CONNECT), and `x/net/http2` (through v0.59) gates + extended CONNECT behind `GODEBUG=http2xconnect=1`, default off + (golang/go#71128). The per-SNI ALPN workaround (h1-only for WS-bearing + routes) was declined: it makes "new WS routes must opt out of h2" a silent + failure mode on an anti-censorship edge. + +## Residual Accepted Signals + +- ALPN `http/1.1`-only: plausible for small self-hosted sites; h2 is + blocked by the rejection above. +- Flow statistics (many short-lived TLS connections, no HTTP semantics on + transport connections): common to all TLS-in-TLS proxies; connection reuse + is deferred. +- The `Head` HMAC key is the raw password without a KDF: only relevant to an + observer who can already capture a `Head`, which requires defeating TLS.