mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"。
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user