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.
This commit is contained in:
wweir
2026-09-18 18:05:02 +08:00
parent b2b344e948
commit 5dc57bc234
5 changed files with 124 additions and 52 deletions
+2
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -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
@@ -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"。
+115
View File
@@ -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.