威胁模型
本文点名 URnetwork 的各类对手,并说明系统针对每一个对手做了什么、没做什么。它是写来被攻击的。凡是某项性质带条件的地方,条件就写在同一句话里;凡是某件事只是设计意图而现有发布代码尚未强制执行的地方,它会被标为设计意图,而不是一项性质。第 1 节之前有一段摘要,供不会读完整份记录的读者阅读。
基准是 Tor 自己那份关于洋葱路由无法击败哪些攻击的说明:一个同时观察一条线路两端的对手可以靠时序把两端关联起来,而 Tor 公开这样说。URnetwork 不提供掩护流量,也不提供填充,所以这里同样如此,而第 8 节把这一点说出来,不加软化。
保证状态,只说一次,适用于下面的一切。没有对协议、连接引擎或运营方服务器代码的独立审计,而那正是隐私宣称所依托的那一层。2025 年的两项第三方评估覆盖的是其他层面:一次由独立第三方对 Web 应用和 API 所做的渗透测试(2025 年 4 月 25 日至 5 月 5 日,带凭证、手工、OWASP Top 10 外加一次 ASVS 控制项复查),以及一次 Leviathan Security Group 对 Android 应用的 MASA AL2 评估(2025 年 5 月 23 日完成),结果通过,而 Leviathan 自己把它的范围界定为"不是一次整体性的安全评估或全面的渗透测试"。两者都没有检查日志、留存、数据路径或协议。因此本文中的每一条主张都是关于可读源代码的主张,任何人都能核查,而项目之外还没有人整体核查过它。
引用怎么读
各项主张按 path:line 的形式,引用截至 2026-08-07 位于 /Users/brien/urnetwork/{connect,sdk,server,proxy,extension} 的工作树。行号会漂移;标识符不会。凡是某项发现是在更早一轮代码核实中确立的,它引用的是 review/verified/ARCHITECTURE.md 或 review/verified/PRIVACY-ENFORCEMENT.md,它们采用同样的约定。
有三个标签是刻意使用的:
- 性质 — 代码强制执行它,而且一个控制了其他各方的攻击者仍然无法违反它。
- 纪律 — 代码选择不去做一件它有能力做的事。一次部署变更或一行补丁就能把它反转。
- 设计意图 — 设计说这是目标;现有发布代码尚未强制执行它。
摘要
本节是给不会读完整份记录的读者的。其中每一句话,都由下面某个带编号的小节或上面那段保证声明支撑。
这个系统是什么。URnetwork 是一个隐私网络,其中成员为其他成员中继流量。路径是:你 → 扩展器 → 运营方 → 提供者 → 互联网。信任的分割在两个中继方之间:运营方,它知道你是谁;以及提供者,它看得到你的流量去哪里。
两条性质,各自限定范围。在中继路径上,提供者永远收不到你真实的来源 IP;没有任何设置或提供者构建能改变这一点(§2)。运营方读不了密封的客户端到提供者会话,而该会话在五个原生应用(Android、iOS、macOS、Windows 和 Linux)上以后量子加密之名默认开启发布(§2.1)。浏览器扩展没有密封会话 — 它的客户端设备运行在运营方内部,运营方在那里充当协议转换点,所以那里不可能存在密封(§2.4)。
这些完整性缺陷 — 一处已修复,一处仍未修复。这个密封过去在每一种模式下都会静默地失效开放。2026-08-10 已修复:在后量子加密开启的情况下,客户端以失效关闭的方式运行,宁可拒绝发送或接受明文的应用数据,也不降级到明文(§2.2)。在开关关闭的情况下,旧的机会性行为仍然适用;而在两种模式下,都没有任何应用报告发生的是哪一种情况。仍未修复:运营方能不能替换一把提供者密钥?今天:能(§7.4),所以这个密封的机密性挡得住一个被动的运营方和一个在路径上剥除封装的攻击者,挡不住一个愿意签发一把假密钥的运营方。verify/BEFORELAUNCH.md 的第 8 和第 9 项对两者都在跟踪;§2.2 和 §7.4 描述的是当前状态。
这个系统不防御什么。一个同时观察你的接入网络和承载你流量的那些提供者的对手,可以靠大小和时序把两端关联起来(§8.1)。一个全局被动观察者完全不在范围之内(§8.3)。URnetwork 不提供填充、不提供掩护流量、也不做混淆;第 11 节是完整清单。
保证。没有对协议、连接引擎或运营方服务器代码的独立审计;2025 年的两项第三方评估覆盖的是其他层面:一次对 Web 应用和 API 的渗透测试,以及一次 Leviathan Security Group 对 Android 应用的 MASA AL2 评估,结果通过。
细节住在哪里。第 1 和第 2 节定义各方、三种连接模式,以及每一方在每种模式下看到什么。第 3 到第 5 节逐个处理各类对手:一个恶意提供者、一个网络观察者,以及运营方。第 6 和第 7 节覆盖运营方自运行的提供者、合谋,以及提供者密钥替换。第 8 到第 12 节覆盖流量关联、设备标识符、法律要求、系统不防御什么的清单,以及本文无法确立的东西。第 13 节说明去哪里报告漏洞和更正。
1. 各方
| 方 | 由谁运行 | 位置 |
|---|---|---|
| 客户端 | 用户 | 应用或 SDK 实例;在浏览器和代理路径上,它改为运行在运营方的服务器上(§2.4) |
| 入口负载均衡器 | 运营方 | nginx;把观察到的客户端地址盖进 X-UR-Forwarded-For(xops/gitops-unused/gitops-prod/urnetwork/connect/ingress.yaml:9 — 树里唯一的入口清单,而它所在的目录名叫 gitops-unused,所以它可能并不描述生产环境;§12) |
| connect 服务 | 运营方 | 位于不同主机上的一到两个进程,由一条内部交换连接连起来(server/connect/resident.go:2310-2327)。不是"一台中继服务器" |
| API / 控制面 | 运营方 | 认证、提供者发现与排名、合同、公钥查询(server/api/api.go) |
| 提供者 | 任何成员 | 接收被中继的数据包,并从它自己的连接拨向目的地(connect/ip.go:3932 RemoteUserNatProvider → LocalUserNat) |
| 扩展器 | 任何志愿者 | 路径的第一段:一个位于独立地址上的 TLS 转发中继。它承载客户端↔平台会话而不终止它,所以它看得到连接方的 IP,也无法解密(connect/net_extender.go:51-55、connect/extender/extender.go:57-61) |
| DoH 解析器 | Cloudflare、Google、Quad9、OpenDNS | 应用路径把 DNS 经隧道以 DoH 解析到这四家之一(connect/net_http_doh.go:150-155) |
| 支付处理商 | Stripe、Apple、Google、Solana/Circle | 为付费账户持有真实身份;运营方存储那些连接键(server/db_migrations.go:1712-1719、:4640、:2350-2356) |
运营方是 BringYour, Inc.,一家特拉华州公司,通信地址在旧金山(docs/legal/ur.xyz/terms.md:16,25;docs/legal/terms.md:269-271)。第 10 节讲这一点对法律程序意味着什么。
2. 三种模式,以及每一方看到什么
这张表是权威版本,并与 OVERVIEW.md 一致。本文其余的一切都是它背后的机制。
| 模式 | 运营方看到 | 提供者看到 | 默认与可用性 |
|---|---|---|---|
| 中继密封 | 账户/来源连接、提供者关联、密文及时序/数据量 | 目的地流量、设备/合同 id,不含你的真实来源 IP | 原生应用的默认状态,开箱即用;仅限原生应用;见 §2.2 |
| 中继标准 | 账户/来源连接、提供者关联、内层目的地和数据包字节 | 目的地流量、设备/合同 id,不含你的真实来源 IP | 浏览器和代理路径(§2.4),或密封被关掉时;密封开着时,无法完成密封的提供者会被跳过,而不是改走这里(§2.2) |
| 直连 | 更少的中继参与 | 你的真实来源 IP及目的地流量 | 自行选择开启,做法是关闭强匿名化(§2.3) |
由此得出两句不对称的陈述,而它们的强度并不相等:
提供者对身份的盲视是一项性质,而且在两条中继路径上都是无条件的。提供者的入站入口点接收的是 id,从来不是地址:RemoteUserNatProvider.Receive 的参数是一个由三个 16 字节 id 组成的 TransferPath(connect/ip.go:4298-4303;connect/connect.go:45-49)。没有任何传输协议缓冲区携带客户端地址、城市或国家 — 对 connect/protocol/*.proto 做一次 location|city|country|region 的 grep 什么也返回不了(review/verified/ARCHITECTURE.md §1.3)。在中继路径上没有任何设置能关掉这一点,也没有任何提供者构建能获得一个从来没有发给它的地址。
运营方对内容的盲视是一个默认设置,不是一个开关 — 而它所建立于其上的那个机制,还剩一处缺陷。这个机制有一个名字:后量子加密,它是 Android、iOS、macOS、Windows 和 Linux 应用连接抽屉里的一个控制项,用来密封你的客户端与提供者之间的一条端到端会话(§2.1)。有条件的东西不再是用户能不能找到那个开关,而且自 2026-08-10 起,也不再是这个密封有没有悄悄地没能建立起来:在开关开启的情况下,一条无法被密封的连接根本不承载任何应用流量,而不是把它明着承载过去(§2.2)。仍然有条件的,是那个为密封提供校验所用提供者密钥的运营方,是不是在诚实地提供它们(§7.4)。在依赖这项性质之前,请把那一节读一遍;这个条件对用户不可见。
发布时的默认:开启。产品负责人在 2026-08-07 裁定,密封会话以启用状态发布,所以运营方的盲视是默认状态,而不是一个用户必须自己做出的选择。本文是对照那个发布状态写的;在撰写本文时,代码里的默认仍然是关闭(verify/BEFORELAUNCH.md 第 7 项)。
这道落差如今在某一个方向上比过去更要紧。因为那道失效关闭的闸门就绑在同一个开关上,现在把默认打开,是把一项真实的保证推送给每一个用户,而不再只是把一次静默失败的波及面扩大 — 与本节在密封还会在每一种模式下失效开放时所警告的,正好相反。默认仍然修不了的是 §7.4,那是密封本身的性质:一个运营方能够对其实施中间人攻击的密封,就算默认开启,也只挡得住一个被动的运营方。
浏览器扩展根本没有密封会话(extension/src/utils/auth-params.ts:33,performance_profile: null)。那是范围问题,不是默认设置问题,而那次发布裁定并不改变它。
凡是密封缺席的地方 — 浏览器和代理端点(§2.4),以及关掉了密封的原生应用(§2.2) — 运营方就会终止客户端的 TLS/QUIC 并中继明文 IP 数据包(connect/protocol/ip.proto:9-16)。它选择只解析路由头 — FilteredTransferFrame 除了传输路径之外什么都不包含(connect/protocol/transfer.proto:85-87;connect/transfer.go:1500-1509) — 而那是纪律,不是一道密码学屏障。
2.1 这个密封究竟是什么
一条直接位于客户端与提供者之间的 TLS 1.3 会话,以普通控制帧的形式穿过同一个运营方承载(connect/transfer_encrypt.go:44-51)。密钥交换是混合的 X25519MLKEM768,以常规 X25519 作为后备(connect/transfer_encrypt.go:373-381,406);AEAD 是在一个 32 字节导出密钥之上的 AES-256-GCM(:285-297);身份是 Ed25519(:892-897),所以"后量子"只限定于密钥交换。双向 TLS 是必须的,而对端证书是在序列层对照合同的承诺值来校验的,不是由 TLS 栈来校验(:388-406)。
2.2 你要求时失效关闭,你不要求时失效开放
2026-08-10 更新。本节此前说这个密封会静默地失效开放,而且不存在失效关闭的选项。那已经不再属实,而这项变更是本文历史上最重要的一次加固 — 它把双向的降级通道都关上了。下面写的是当前的行为,读自源代码。
加密仍然是 Cipher() != nil 的一个二元性质,而 cipher 为空仍然意味着握手没有完成:一次失败的握手、一次失败的身份证明,或者一份从来没有携带对端公钥的合同,都会让它保持为空。身份证明失败在会话层仍然是非致命的 — 会话被"留作未认证"(connect/transfer_encrypt.go:1977),而不是被拆掉。
变的是接下来会发生什么。现在有三种模式(connect/transfer_encrypt.go,EncryptionMode):
| 模式 | 行为 |
|---|---|
EncryptionModeOff | 零值。会话层不起作用,一切都走明文。 |
EncryptionModeOpportunistic | 一旦会话建立起来就密封,在那之前走明文 — 如果会话始终没有建立起来,就永远走明文。这是历史上的行为。 |
EncryptionModeRequired | 对于一个本应建立会话的对端,绝不以明文暴露发往它或来自它的应用数据。 |
在 EncryptionModeRequired 之下,这项保证在四个点上被强制执行:
- 发送入口闸门(
connect/transfer.go:2796)。一个应用 pack 会在调用方的超时之内等待 cipher,随后被拒绝、不发出 — 绝不降级 — 并带上一个带类型的ErrEncryptionRequiredNotEstablished。 - 发送兜底(
connect/transfer.go:4125)。一个在没有 cipher 的情况下到达写入器的帧会被拒绝,而不是被写出,这覆盖了会话在入队与写出之间被拆掉的那一小段竞态。 - 接收闸门(
connect/transfer.go:5844)。来自一个本应建立会话的对端的明文应用帧会被丢弃并记入审计 — 这是更要紧的那一半,因为它关掉了这样一种降级:攻击者剥除封装,而接收方本来会接受那份明文。那个帧是先被确认(ack)再丢弃的,而不是被搁着不确认,因为扣住确认会在有序序列里留下一个缺口,把两端都卡死。 - 候选者预筛(
connect/ip_remote_multi_client.go,EncryptionCapabilityPrefilter)。如果平台的带外密钥 API 表明某个窗口候选者从来没有发布过身份密钥,它会被立即判为失败,因为它永远无法完成握手。它只是让必定的失败来得更快;它绝不会放进一个候选者。
握手控制帧、确认(ack)以及控制面对端按设计豁免 — 这道闸门覆盖的是应用载荷,不是把它引导起来的那些脚手架。
你拿到哪一种模式,取决于后量子加密这个开关。当它开启时,消费者客户端运行 EncryptionModeRequired(connect/ip_remote_multi_client.go:9186-9197):一个建立不起会话的提供者,根本不会为你承载任何应用流量,而不是把它明着承载过去。写明的代价是可用性,而这是刻意接受的。提供者运行 EncryptionModeOpportunistic(sdk/device_local_provider.go:98),这样一个提供者就能同时服务已密封和未密封的消费者;那是响应方一侧的兼容性选择,并不削弱发起方的保证。
这对那个开关意味着什么。在后量子加密开启的情况下,密封不再失效开放:它在发送和接收两侧都失效关闭,而且是响亮地关闭。在后量子加密关闭的情况下,本节早先版本所描述的机会性行为仍然完整适用 — 如果会话始终没有建立起来,流量可以以明文流动,而且没有任何东西告诉用户。所以诚实的说法是带条件的,而默认状态要紧:见 §2 里发布时的默认那一段。
这个行为由测试覆盖,不只是靠注释 — TestRequiredEncryptionFailsClosedAgainstPlaintextPeer、TestRequiredGateNonBlockingSendRefusesPreCipher、TestRequiredGateBoundedBudgetRefusesUnsent、TestRequiredSendRefusalTypedErrorAndEvent 和 TestRequiredContractFreeWithoutKeySourceFailsClosed。
审计时有一条过时的注释可以忽略:completeHandshake 在 connect/transfer_encrypt.go:1650-1654 处的头部注释仍然不加限定地断言"随后的流量以明文流动"。那只在 Off 和 Opportunistic 模式下成立;在 Required 之下,上面那几道闸门推翻了它。这条注释早于那次修复。
仍然未解决:用户看不到某条连接拿到的是哪一种模式。任何应用里都不存在逐连接的"密封与否"指示。唯一的信号是设备日志行 — 成功时的 peer identity proof verified — cipher is now usable(connect/transfer_encrypt.go:1954)、失败时的一次 Errorf,以及闸门拒绝时的一个 NotifyRequiredSendBlocked 事件。SDK 暴露了一个变更钩子,所以缺的那一块是 UI,不是管路。
2.3 直连模式
关闭强匿名化会设置 AllowDirect,而它自己的字段注释写着:// setting this to true exposes the real source IP to the provider(sdk/sdk.go:710-711)。数据流被强制走上一条 P2P 流(connect/ip_remote_multi_client_probe.go:1176-1178),跑在一条 WebRTC/ICE 数据通道上(connect/transport_p2p_webrtc.go:986),而 ICE 意味着两个端点都会得知对方的地址。默认是关闭 — 一个空的性能配置产生 false(connect/ip_remote_multi_client.go:1861-1863;sdk/local_state.go:607-616)。
有两种被强制的情形:同网络对端(你自己的设备)总是允许直连(connect/ip_remote_multi_client.go:1822-1825),而托管设备则强制关闭它(sdk/device_local.go:1516-1533)。请注意,直连模式只把运营方从数据路径中移除。合同、提供者选择和控制消息仍然经过平台,所以运营方仍然得知你用了哪个提供者、移动了多少字节。
2.4 这张表没有覆盖的那条路径:浏览器和代理端点
在浏览器扩展、Web 应用以及 HTTPS/SOCKS5/WireGuard 端点上,是运营方在运行那个客户端。扩展会开通一个服务器端的代理设备(extension/src/utils/auth-params.ts:22-36,enable_socks/enable_http),而浏览器被指向运营方的代理端点 — 默认是 HTTPS CONNECT(extension/src/bridge/background.ts:220;extension/src/utils/proxy-manager.ts:264)。那个开启合同并持有提供者窗口的 SDK 实例,跑在 server/proxy 里,不在用户的机器上。
这就是为什么在这些平台上不可能存在密封会话,而这是架构问题,不是还没投入的工程量问题。密封会话是客户端↔提供者的,而它的保证取决于客户端那一端住在用户自己的设备上。一个浏览器或一个普通代理客户端说的是它自己的协议(HTTPS CONNECT、SOCKS5、WireGuard),跑不了这个网络的引擎,所以运营方远程运行客户端的设备,并在两者之间充当协议转换点。从那台远程设备协商出来的密封,会始于运营方 — 也就是密封之所以存在、要去蒙住的那一方。实现方是刻意接受这个取舍的:这些接口存在,是为了在无法承载完整客户端的平台和协议上提供便利,而文档绝不能把它们描述为已密封。
后果,直说:
- 提供者对身份的盲视仍然成立 — 提供者收到的仍然只有 id。
- 运营方的盲视在这条路径上以任何形式都不存在。运营方终止那条代理连接、从 CONNECT 收到目的主机,并持有客户端的密钥。
- 那台托管设备不安装 DNS 升级复用器(
server/proxy/proxy_device.go:556,SetUpgradeMuxSettings(nil)),所以应用路径上隧道内的 DoH(§3.2)在这里不适用。
在这条路径上替代加密的是存储纪律:代理数据路径在客户端可驱动的路径上不记录任何内容,并由一个回归测试钉住 — proxy/socks5_nolog_test.go 里的 TestClientDrivenTrafficNeverLogs 断言在格式错误、超大和无法拨号的各种情形下日志行数为零。有两条诚实的注意事项:这个测试自述的动机是日志放大式 DoS 而不是隐私,而且那个 panic 恢复处理器确实会记录一个客户端地址(proxy/socks5_server.go:128-131)。见 review/verified/PRIVACY-ENFORCEMENT.md §2.2。
3. 对手:一个恶意提供者
在范围之内,而且被假定存在。任何人都可以提供。对提供者的参与不做任何证明、质押、身份核验或抗女巫(Sybil)措施 — 在 server、connect 和 sdk 三棵树中搜索 sybil|kyc|attestation 返回不了任何相关内容。一个提供者在它自己控制的硬件上运行开源代码,所以要假定它跑的是一个被修改过的构建。
它看到什么。它承载的那部分流量里,一个 ISP 能看到的一切:目的 IP 和端口、数据包大小和时序,以及客户端明文发出时的 TLS SNI。它接收原始 IP 数据包并自己拨出去(connect/protocol/ip.proto:9-16;connect/ip.go 中的 LocalUserNat)。它还看得到作为合同 SourceId 携带的那个 client_id(connect/protocol/transfer.pb.go:1779;server/controller/connect_controller.go:461-463) — 那是一个逐窗口槽位的标识符,只有运营方能把它解析为一个账户;而它永远看不到 device_id,后者在线路上根本没有字段。它的生命周期,以及那几样确实跨会话留存的东西,在 §9。
它能做什么。
- 明文 HTTP:读取并修改它。80 端口按默认原样透传到出网点 —
HttpUpgradeUnencrypted是默认模式(connect/ip_mux_upgrade.go:38-47)。对于任何本身没有加密的流量,一个提供者所处的位置与一个怀有敌意的 Wi-Fi 热点相同。HTTPS 保护应用内容不被提供者看到;URnetwork 里没有任何东西在目的地自己的 TLS 之上再加一层。 - 有选择地丢弃、延迟或拖住流量。一个确认收到流量却什么都不返回的提供者会被标记为黑洞并被移除(
connect/ip_remote_multi_client.go:37-54),所以拒绝服务会被检测到并被绕开 — 但检测是统计性的,而它在被移除之前所看到的流量,仍然是被它看到了。 - 谎报性能以吸引流量。排名使用实测的延迟和吞吐(
server/model/network_client_location_model.go:2430-2447),而测量是运营方做的,不是提供者自报的,所以这一点是有界的 — 但它是一项排名输入,不是一道完整性控制。 - 在它自己的视野内关联各条流。按站点固定把一个站点钉在一个提供者上(
connect/ip_remote_multi_client.go:1234-1240),所以对于它所持有的那些站点,单个提供者看到的是一个客户端浏览活动里连贯的一片。
没有帮助它做不到什么。在中继路径上得知你的真实 IP、得知你的账户或邮箱,或者把一个 client_id 归到某个人头上。那些都需要运营方(§5)。
3.1 ip_security 约束什么、不约束什么
连接引擎的 ip_security 层运行在提供者自己的出网点上,以及用户自己的设备上,从不集中运行在运营方那里 — 运营方的 IngressSecurityPolicyGenerator/EgressSecurityPolicyGenerator 字段是存在的,但从未被赋值或读取(server/connect/resident.go:263-264;review/verified/PRIVACY-ENFORCEMENT.md §4.1)。它拦截 BitTorrent 和文件共享特征、不透明的非标准协议、被信誉库列出的目的地,以及攻击模式流量,而且它是在一个刻意做得很薄的基础上这么做的:5 元组,外加一段被限制在 8 个数据包 / 512 字节以内的负载前缀。
那些信誉表是生成出来的,不是手工维护的。connect/security 和 connect/blocker 把公开的威胁情报源 — abuse.ch Feodo Tracker 僵尸网络 C2、Spamhaus DROP 和 DROPv6、Emerging Threats 被攻陷 IP、Blocklist.de、CINS Army、TweetFeed、ViriBack、BruteForceBlocker — 聚合成打包的范围表(截至 2026-08-05 的快照有 46,789 个 IPv4 范围;connect/ip_security_cfaa_block.go 的头部携带着情报源清单和署名)。发布流水线会从实时的情报源重新生成这两张表(build/all/run.sh 里的 CONNECT_IP_UPDATE 步骤),所以每一次发布都带着一份当时的快照,而一个保持更新的客户端会拦截最新被列出的地址空间。这份快照会随着已安装的构建一起变旧;这些清单按发布更新,不是通过空中下发更新。
负载前缀的界限在 connect/ip_security_dmca.go:117-119,而服务器名被从流键中清除(:366,386,424),计数器以 (version, protocol, port) 为键,IP 字段从未被启用(connect/ip_security.go:554,604-624)。
它是给诚实提供者用的安全控制,不是对付敌意提供者的安全控制。它约束的是一个提供者的连接会向外承载什么。对于一个提供者拿它确实承载的流量做了什么,它什么也管不了;它跑在一个由提供者拥有的进程里,而一个被修改过的构建可以把它关掉。不要把它读作对一个恶意提供者的限制。
有一处上报细节属于这里,因为它是一条元数据通道。一次 BitTorrent 特征命中会返回 Incident,后者调用 ReportAbuse(connect/ip.go:4481-4487),向运营方发送一个 PeerAudit 控制帧,其中携带对端的设备 id 和一个布尔值 — 没有目的地、没有域名、没有内容(connect/transfer.go:870-876,6653-6683)。运营方今天没有针对该消息类型的处理器,所以收到时什么都不会被存储(server/controller/connect_controller.go:236-250;在 server 中对 PeerAudit 做一次全仓库 grep 返回零个命中)。那是一项距离改变只有一条 case 语句的纪律。不透明加密流量的丢弃是静默的,没有任何上报(connect/ip_security_dmca.go:483-486)。
3.2 DNS
在应用和 SDK 路径上,UDP/TCP 53 端口上的普通 DNS 会被拦截并经隧道以 DoH 解析(connect/ip_mux_upgrade.go:120-148,在 sdk/device_local.go:1120 默认安装),发往 Cloudflare、Google、Quad9 或 OpenDNS(connect/net_http_doh.go:150-155)。因此提供者看到的是一条通往公共解析器的 HTTPS 连接,不是那次查询。有两条诚实的限制:
- 源代码里记载着一个启动期的 DNS 泄露窗口。在隧道内的 DoH 还在建立的时候,一次查询会与一个经本机出网的解析器竞速,"代价是启动期一次短暂的 DNS 泄露"(
connect/ip_mux_upgrade.go:120-126,78-92)。你的本地网络和 ISP 看得到那些查询。 - WireGuard 端点会下发给客户端一个固定的公共解析器,
DNS = 1.1.1.1(server/model/network_client_proxy_model.go:760),而 53 端口被安全策略未加检查地放行(connect/ip_security_cfaa.go:113)。在那条路径上,DNS 查询以普通 DNS 的形式穿过提供者,而提供者可以读取并应答它们。
那四个 DoH 解析器是第三方,它们看到的是从某个提供者出网地址到来的查询,与你的账户没有关联。那是一项真实的依赖,而且它不可能被配置为零:DNS 总得有人来应答。
4. 对手:一个网络观察者
四个位置,从最弱到最强。
你的本地网络和 ISP。它们看到你经 TLS/QUIC 连接到 connect.bringyour.com,或者连接到一个扩展器,外加大小和时序。它们看不到隧道内部的目的地,除了上面那个有记载的启动期 DNS 窗口。在平台被封锁的地方,扩展器以可信端口上的普通服务形态出现,而客户端会轮换伪装身份,配合随机化的分片(connect/net_extender_profiles.go:14-30,50-57),另有一种 DNS 形态的传输,供只有 DNS 能出去的网络使用(connect/transport_pt.go:18-45)。这些是用于穿过本地和区域防火墙的机制,排在直连传输之下(connect/transport.go:541-546)。它们不是流量分析防御,也绝不应被如此描述。
一位扩展器运营者。任何人都能运行一个,应用接受手动输入的 IP,而且默认不要求任何签名(connect/extender/extender.go:57-61)。扩展器是那个 TCP 对端,所以它看得到连接用户的 IP;而它无法解密自己所转发的内容,因为那条 TLS 会话是穿过它通到平台的,而不是终止在它身上(connect/net_extender.go:51-55)。因此扩展器这一段在路径上放进了一个观察你地址的第一跳观察者,而这个人不是运营方。那是这套设计为了带你穿过封锁所做的交换,而把它的大小说准是值得的:扩展器这一段不增加任何加密,也不增加任何匿名性 — 它得知的是你的 ISP 早已知道的东西,仅此而已。在发布的客户端里,直连传输会先被竞速,而扩展器拨号器在那些失败之后才展开(connect/net_http.go:675);一个配置了自定义扩展器的客户端会把它们当作唯一的路线(connect/net_http.go:357,469)。
一位只观察一端的观察者。只盯着客户端一侧,得到的是"这个用户连接到了 URnetwork"以及一份字节/时序画像。只盯着某个提供者的出网点,得到的是目的地和一份字节/时序画像,上面没有附着任何身份。
一位同时观察两端的观察者。见 §8。这就是 URnetwork 击败不了的那个对手。
5. 对手:运营方
运营方为客户端做身份验证、挑选并排名提供者、编写合同、分发公钥,并在中继路径上承载数据包。本节假定它怀有敌意或受到强制。
它不需要任何额外努力就看到什么:你的账户;你连接那一刻的地址;由那个地址经一次本地数据库查询推导的城市/地区/国家 — server/ip.go:201 从磁盘打开一份内置的 mmdb/ip-ipinfo.mmdb,而 ip.go 不发起任何形式的网络调用,所以地址从不会被交给任何地理定位服务 — 并按连接持久化(server/model/network_client_location_model.go:1392-1414);哪些提供者服务过你;字节数;以及在标准路径上,隧道内部的目的地址和数据包字节 — 尽管那些通常已经在目的地自己的 HTTPS 里面了。
它存储什么。连接和认证记录保存的是外围 /29(IPv4)或 /56(IPv6)地址块的带密钥单向哈希,而不是那个地址(server/ip.go:46-67)。一处对审计者要紧的精确说明:那是一个来自保险库、进程级、只记忆化一次的 pepper,没有逐行的盐,而且整个仓库里任何地方都没有轮换机制(server/ip.go:40-44);IPv4 的键空间是 2^29 个地址块,所以任何持有该 pepper 的人都可以用暴力枚举把它反解出来。这个 pepper 的保密性就是全部的安全性质。/verify 子系统对 IPv6 用的是 /48,不是 /56(server/ip.go:74-85;server/model/verify_model.go:111)。来源端口以明文形式与哈希并排存储(server/db_migrations.go:1934)。账户创建的审计行记录的是加了 pepper 的哈希和端口,绝不是原始的 ip:port(server/model/network_model.go:961-975),而审计行在 180 天后被移除(server/model/audit_model.go:984,在 server/taskworker/taskworker.go:57,253 排期)。
什么东西根本不存在,所以无从存储。传输账本里任何地方都不出现目的地、主机、URL、SNI、端口或域名 — 对整份迁移文件就那些词做一次 grep 返回零个命中(review/verified/PRIVACY-ENFORCEMENT.md §1.2)。HTTP 日志放行的是一份恰好五个请求头的白名单(server/http_log.go:15-29),由测试钉住。没有任何 Prometheus 指标携带 IP、主机、目的地、端口或客户端 id;在 39 个已声明的指标里只有三个带标签,而且每个标签都是一个有界的枚举(review/verified/PRIVACY-ENFORCEMENT.md §2.5)。支持标签页上传的日志被排空到 io.Discard(server/controller/log_file_controller.go:13-16,76);只有元数据留存。
5.1 这一切各保留多久,以及去向哪里
留存是由 server/taskworker 里按排期运行的清扫来强制执行的,不是由政策。下面的窗口读自那些清扫所调用的常量,而它们比隐私政策 — 后者根本没有写出任何留存期 — 会让读者预期的要短。
| 数据 | 保留时长 | 强制执行位置 |
|---|---|---|
| 连接行,以及挂在其上的逐连接城市/地区/国家、延迟和速度 | 8 小时 | taskworker/work/network_client_work.go:84;删除在同一条语句里级联到 network_client_location(model/network_client_model.go:2295-2320) |
| 一个顶层客户端闲置(无认证、无连接) → 标记为停用 | 30 天 | TopLevelClientIdleExpiration(model/network_client_model.go:2289) |
| 一个已停用的客户端 → 硬删除,并带级联 | +30 天 | NetworkClientReapAfterDeactivate(:2269) |
| 因此一台不再使用的设备,端到端 | 约 60 天 | 前两者串联 |
| 已完成的合同 | 7 天 | taskworker/work/subscription_work.go:161 |
| 审计行 | 180 天 | model/audit_model.go:984 |
有两条说明,是审计者应当掌握的。闲置窗口已于 2026-07-18 从 90 天收紧到 30 天;那个常量带着它自己的理由说明,而 taskworker/work/network_client_work.go:83 处一条过时的注释仍然写着 90 — 如果你在别处遇到那个数字,这条注释就是它最可能的来源。另外,RemoveLocationLookupResults 每个周期都被排期,但它是一个空操作(no-op) — 它的函数体和它的模型调用都被注释掉了,而且那张表根本不存在。它是一个残留的桩,不是未被清扫的数据;逐连接的位置会随上面的连接行一起被删除。
地理定位从不离开这台机器。城市/地区/国家查询读取的是本地磁盘上一份内置的 mmdb/ip-ipinfo.mmdb(server/ip.go:201),而 ip.go 里不含任何 HTTP 客户端,也不发起任何网络调用。没有任何地址被发送给地理定位服务,也没有任何个人信息与任何第三方共享。本文各方表格里仅有的那些第三方,持有的都是用户因选择那条路径而亲手交给它们的数据:用户自己的 DNS 查询经隧道抵达的那些 DoH 解析器,以及用户为接受计费而注册的那些支付处理商。
“我们不记录你的地址”这一姿态上的已知缺口,而且与日志详细程度无关。server/http.go:415,455 在构造 http.Server 时没有设置 ErrorLog,所以每一次半开连接,Go 标准库都会把 http: TLS handshake error from 写到生产环境的 stderr — 而这正是 proxy/http.go:176,250 用 ErrorLog: discardLog 堵上的那个窟窿。客户端提供的 SNI 被以 ERROR 级别记录(server/tls.go:98,195),一个原始的调用方地址在 server/proxy/proxy_device.go:274,而 WireGuard 对端身份在 server/proxy/server.go:699。在那些被修复之前,"我们从不记录你的 IP"不是 URnetwork 能做出的主张,而本文也不做这个主张。完整细节:review/verified/PRIVACY-ENFORCEMENT.md §2.4。
运营方所控制的、容易被忽略的东西。提供者发现完全在运营方一侧:FindProviders2 是唯一在线的端点(server/api/api.go:67),平台为候选者打分并排名(server/model/network_client_location_model.go:2250-2254,2430-2447),而客户端接受它被给到的那个排好序的集合。消费者一侧唯一的控制是一份位置封锁清单(server/api/api.go:73-75)。客户端没有任何办法核实被提供给它的那些提供者彼此独立、或者独立于运营方。
6. 运营方自运行的提供者,以及运营方与提供者的合谋
6.1 运营方能运行提供者吗?
能,而且系统里没有任何东西标记或阻止这件事。提供对任何人开放,不需要任何证明或质押,而且代码里任何地方都不存在第一方、官方或运营方自有的提供者概念 — 在 server、connect 和 sdk 中搜索这样一个概念什么也返回不了。一个运营方自运行的提供者,与一个成员的提供者无法区分。
结合 §5 那一点,即运营方为提供者集合排名并把它返回,这意味着运营方在原则上可以把它自己的提供者放进一位用户的窗口里。不存在客户端一侧的多样性检查、不存在关于独立性的证明,也不存在任何外部方对这支队伍的公开测量。确实存在的制衡是真实的,但只是部分的:窗口同时持有若干个提供者 — 质量窗口 2–6 个(硬上限 12),速度窗口 1–2 个(硬上限 4),两者都活在默认配置里(connect/ip_remote_multi_client.go:138-158) — 而且提供者会因统计不健康、黑洞检测、ping 失败和逐通道的生命周期轮换而被移除(:37-54、:610-616)。那些限定了任何单个提供者能看到多少。它们并不限定一个由同一方挑选出来的提供者集合。
6.2 运营方和提供者合在一起能重建出什么,按模式分
假定运营方和你窗口里的一个或多个提供者共享它们各自持有的东西。
| 中继标准 | 中继密封 | 直连 | |
|---|---|---|---|
| 你的身份(账户、邮箱/钱包/支付) | 运营方 | 运营方 | 运营方 |
| 你连接时的真实 IP | 运营方 | 运营方 | 运营方,以及直接得知的提供者 |
| 你访问过的目的地 | 运营方(内层数据包)以及提供者 | 仅提供者 | 提供者 |
| 两者之间的联结 | 微不足道 — 一方本来就两者都持有 | 那份合同:audit_contract_event 按合同把客户端与提供者身份配成对(server/db_migrations.go:234-251) | 微不足道 |
| 结果 | 对那些合谋提供者所承载的一切实现完整的浏览归属 | 对那些合谋提供者所承载的一切实现完整的浏览归属 | 完整的浏览归属,外加提供者不靠别人就知道你的地址 |
诚实的结论:密封会话不防御运营方与提供者的合谋。它移除的是运营方独立地读取你流量的能力;它并不能阻止一个本来就看得到你目的地的提供者,把那些目的地交给一个本来就知道你是谁的运营方。那份把两者接起来的合同记录不是泄露 — 它是这个网络赖以运转的记账。
密封针对这个对手确实买到的,是一个范围上的限制:在它开启时,运营方自己的视野里不含任何目的地,所以合谋需要那些在承载那些具体流量的时刻承载了它们的具体提供者的配合,而不是对运营方独自持有的记录做一次查询。那在所需的力气上、以及在被强制的披露能够回溯到什么这一点上,是一个有意义的差别(§10)。它不是免疫,而且任何由其中一方挑选其他方的三方安排,都不提供免疫。
这套设计在结构上的答案,而它并未部署。线路协议和客户端支持串接更多的提供者中间节点 — MaxMultihopLength = 8(connect/connect.go:13,214-233)、CreateContract 上的 IntermediaryIds(connect/protocol/transfer.pb.go:1516)、客户端在 connect/ip_remote_multi_client_api.go:296-312 的接受逻辑、服务器在 server/controller/connect_controller.go:560 的管路。而线上的发现端点从不填充那个字段 — FindProvidersProvider 恰好在两处被构造,而两处都没有设置它(server/model/network_client_location_model.go:3186-3190,3296-3303) — 所以已发布的网络分配的是长度为一的链条。提供者串接是协议的一项能力,不是这次部署的一项性质,而拿数字 8 当跳数来引用会造成误导。
7. 提供者身份密钥,以及运营方能不能替换一把
这是最值得攻击的一节,因为答案让人不舒服,而源代码自己就这么说。
7.1 签发与绑定
每一个 connect.Client — 一个提供者客户端,以及用户的每一个窗口客户端 — 都持有一对由 ClientKeyManager 在进程内生成的 Ed25519 密钥对(connect/transfer_key.go:76-100,三棵树中唯一的 ed25519.GenerateKey 调用)。在有种子被持久化并重新载入的地方它是长期的,那就是提供者客户端;在没有种子的地方它是每进程新生成的,那就是窗口客户端;§9 讲这两者的差别以及为什么它要紧。私钥那一半从不离开进程;一个尺寸错误的种子会导致一次硬性的构造错误,而不是静默地生成一个新身份(:84-94)。公钥那一半以一条 ClientKey 控制消息发布给平台(connect/protocol/transfer.proto:501-513),并在服务器一侧存进 Redis 的 ckey_,没有过期时间 — Redis 就是事实来源,不存在 SQL 表(server/model/network_client_key_model.go:14-59)。
因此绑定关系是 client_id → public key,而运营方签发那个 client_id 并持有这份映射。不存在任何外部锚:id 不是从密钥派生出来的,也不存在透明日志或 DHT。这两个替代方案在设计里都被记录为已考虑并搁置(connect/DESIGNNOTES.md §3.7,"Deferred alternatives")。
7.2 轮换与吊销
重新发布就是覆盖 — SetClientKey 自己的注释写着"以 client_id 为键(轮换即覆盖)"(server/controller/connect_controller.go:810-813)。当客户端 id 被回收时该条目会被删除(network_client_key_model.go:61-71,从 RemoveDisconnectedNetworkClients 调用)。没有定期轮换、没有过期、也没有吊销列表。一个提供者客户端会持久化它的密钥材料并重新载入,所以它的身份跨重启是稳定的(sdk/device_local.go:2874-2889,2926-2935;sdk/local_state.go:418-461) — 而且在 Android 上,还刻意在一次自动登出清理中保持稳定(§9.2)。一个没有持久化种子的客户端会在每次进程启动时生成一个新身份,而用户的窗口客户端就是这样。会话中途的密钥变更会被拒绝:SetPeerClientPublicKey 是首次写入者胜出,而一把后来出现的不同密钥会被记录并忽略(connect/transfer_encrypt.go:1787-1816)。
7.3 验证,以及那两道防御
- 防御 1,签名的证书绑定。提供者用它的 Ed25519 密钥签署自己临时的 TLS 证书链,并把两者一起发布(
connect/protocol/transfer.proto:486-498)。平台把这条链、那个签名和提供者的公钥附加到每一份点名该提供者的合同上(:340-368、:388-406)。客户端只有在签名能用提供者的公钥验证通过时才接纳这条链,随后再把握手中出示的证书与已接纳的链做核对(connect/transfer.go:3930-3934,4006-4018)。 - 防御 2,握手内的身份证明。在 TLS 握手之后,双方各自在标签
urnetwork-sequence-identity-proof之下签署 RFC 5705 的导出器输出,并把它作为一条EncryptedControl发出(connect/protocol/transfer.proto:460-476)。在对端的证明验证通过之前,AEAD 不会交给封装路径(connect/transfer_encrypt.go:1492-1498、:1719-1756),所以一个在一段上终止 TLS、又在另一段上重新握手的中间人会产生不匹配的导出器,并且无法伪造那个签名。
两道防御都是对照 peerClientPublicKey 验证的 — 而那个值取自平台编写的合同(connect/transfer.go:6119-6129;connect/transfer_encrypt.go:1780-1791)。
7.4 运营方能替换一把提供者密钥吗?今天:能。
直说:一个把证书、证书签名和 destination_client_public_key 三者同步替换掉的运营方,能击败这两道防御,并对一条密封会话实施中间人攻击。设计笔记用仓库自己的话就是这么说的(connect/DESIGNNOTES.md §3.7):"Residual hole: a platform that substitutes cert + signature + destination_client_public_key in lockstep still wins on the cert-binding side; the closing move is to feed SetPeerClientPublicKey from the OOB lookup rather than the contract. That cross-check is currently log-only."
那个本该收口的动作是存在的,也已经接好了线,但它不起作用。一条免认证的 GET /key/ 路由提供已发布的密钥(server/api/api.go:123-124;server/controller/connect_controller.go:826-849),而发布的 SDK 为每一个窗口客户端和每一个提供者客户端都装上了一个针对它的逐会话抓取器(sdk/device_local_provider.go:398-420,从 sdk/device_local.go:3434 和 :90 到达)。在首次设置密钥时,客户端会带外抓取对端的密钥并做比对。在不匹配时它记录:
CONTRACT vs FETCHED peer client public key MISMATCH for <peerId>
— possible platform MITM (today: log only, contract value still trusted)(connect/transfer_encrypt.go:1871-1876)。而合同提供的那把密钥继续被信任(:520-525、:1839-1849)。把"记录"提升为"拒绝",在设置项注释和设计笔记里都被描述为下一步加固动作 — 那是设计意图,不是一项已发布的性质。
还有三处精确说明,是审计者应当抓住不放的:
- 那条带外通道是相对于合同流水线而言的带外,不是相对于运营方而言的带外。那次抓取发往同一个运营方 API 主机(
sdk/device_local_provider.go:404),读的是合同作者写入的同一个 Redis。即便在"记录"变成"拒绝"之后,这项核对抓住的也是一个在自己的两条通道之间不一致的运营方,而不是一个前后一致的运营方。要把这一点收口,需要一个运营方所不控制的锚 — 由密钥派生的 id,或者一份透明日志,两者都被记录为已搁置。 - 省略与替换同样有效,而且更安静。当受信任集合为空时,证书验证会被跳过且不锁存,包括合同携带一个空的
ProvideTlsCertificate的情形(connect/transfer.go:4010-4016);而当对端的公钥缺席时,身份证明根本无法被验证(connect/transfer_encrypt.go:1706-1708)。无论哪一种,cipher 都永远不会变得可用,而流量以明文运行(§2.2),且没有任何用户可见的信号。一个想在开关开着的情况下读取某个特定用户流量的运营方,不需要伪造任何东西;它需要的是省略一个字段。 - 合同的真实性同样扎根在运营方那里。提供者用一把由提供者自己生成并发布给平台的提供密钥来验证合同的 HMAC(
server/controller/connect_controller.go:768-779;sdk/device_local.go:2866-2872)。运营方持有一份副本,而那正是它能够编写合同的原因。那对一个记账权威来说是意料之中的,但它意味着"这份合同是真的"并不是一句独立于运营方的陈述。
这削弱了什么、没削弱什么。它不触及提供者对身份的盲视,后者不依赖任何密钥分发。它确实意味着,在密封路径上,运营方对内容的盲视目前建立在运营方诚实地分发密钥之上 — 那是一项政策性质,背后有一套大部分已经建好的密码学机制,但它还不是一项能在面对一个敌意运营方时仍然成立的性质。任何把密封会话描述为让运营方盲视成为无条件的 URnetwork 文档都是在夸大它,而本文取代那样的措辞。
8. 时序与流量关联,以及全局被动观察者
8.1 不存在任何流量分析防御。一个都没有。
URnetwork 对用户数据不提供掩护流量、不提供填充、不做混淆、不做批处理延迟、也不做流量整形。这一点在 connect、sdk 和 server 上被穷尽检查过:
- 任何地方都不存在干扰包、诱饵、哑包或掩护流量生成器。
- AEAD 在构造上是保长的:密文长度等于 nonce + 明文 + 标签(
connect/transfer_encrypt.go:305,340)。connect/protocol/里没有任何 protobuf 携带填充字段,而分帧是一个光秃秃的 4 字节长度前缀(connect/message_framer.go:28-31)。 - 发送侧的合并被明确设定为零延迟:"There is no batching wait: the sequence only takes a second Pack when it is already queued"(
connect/transfer.go:387-392)。这几棵树里每一处jitter都是重连退避或通道生命周期的错开,不是流量整形。ACK 压缩(connect/transfer.go:272)只延迟确认,目的是削减中继的消息量。 - 用户流量唯一被限速的地方,是 DNS 形态传输的
WritePacketsPerSecond上限(connect/transport_pt.go:63,232-246),它的存在是为了对解析器友好,而且只适用于最不被优先选用的那种传输模式。它那些空闲的"泵"查询与承载数据的查询长度可区分,所以它既不掩盖大小,也不掩盖速率。
因此:一个既观察进入你设备的流量、又观察离开承载它的那些提供者的流量的对手,可以靠大小和时序把两端关联起来。URnetwork 不防御那个对手。这与 Tor 关于端到端关联所做的陈述是同一句话,而且它在这里适用时余量更小,因为 URnetwork 的设计目标是低延迟 — 而那恰恰是让关联更容易的那项性质。一条延迟受限的四段路径,是相对于混合网络所增加的延迟而言的一次刻意交换,而这就是那次交换中由用户来付的那一面。把扩展器那一段也数进去并不改变这一点:那一跳把加密会话原样转发给运营方而不终止它,所以它既没有为观察者添上一层可剥的东西,也没有添上任何能把观察者甩掉的延迟。
有两样东西有时会被误认为是防御,而它们不是。扩展器和那些形态化传输(connect/net_resilient.go:112-215、connect/transport_pt.go:18-45)针对的是基于 DPI 的封锁,而那个弹性层在流建立之后会把自己禁用(net_resilient.go:102,146-161) — 它从不触及数据阶段的记录。TLS 会话票据缓存被刻意不在各条出网路径之间共享,好让服务器无法通过一张被兑换的票据把它们关联起来(connect/net_tls.go:38-42,99-103);那是票据不可关联性,不是流量分析。
源代码里没有任何地方承认流量关联是一项局限:在三棵树里搜索 traffic analysis、timing correlation、traffic correlation 和 global adversary 返回零个命中。仓库内的那份威胁模型(connect/DESIGNNOTES.md §3.7)完全是关于运营方中间人的。本文是第一个把这个缺口写下来的地方。
8.2 多提供者窗口不是一种抗关联机制
流量通常同时从若干个提供者出网 — 常见的是跨两个窗口三到八个 — 而按站点固定让某个给定站点停留在一个提供者上(connect/ip_remote_multi_client.go:138-158,1234-1240)。那确实限制了任何单个出口能看到多少。但这个窗口在源代码里的设计理由自始至终都是可靠性 — 坏目的地的缓解、按健康度加权的伸缩、黑洞检测(:27-52) — 而那句唯一写着"更低的亲和度更私密"的注释挂在 ClientAffinityTimeout 上,那是一个被注释掉的旋钮(:587-591,以及在 :220 处的默认值里再一次),而 DestinationAffinity 出于可靠性的理由发布时为 true(:270)。复数的出口身份是一项真实的好处,把它说成好处是公平的;但把它说成一项针对关联而设计的防御,代码并不支持。
8.3 全局被动观察者:不在范围内
一个全局被动对手 — 能够同时观察互联网上很大一部分链路的对手 — 不在范围之内,而 URnetwork 不针对它提供任何防御。在没有填充也没有掩护流量的情况下,这样一个对手会直接跨中继和那些提供者关联各条流。这与 Tor 所采取的立场相同,而且与 Tor 不同的是,URnetwork 甚至不添加会抬高成本的逐跳延迟。
有一点确实不同,而且值得在不夸大的前提下说出来:出网集合是一个由相互独立的住宅连接组成、不断翻新的群体,而不是某一个运营方公布的地址段,所以一个对手必须观察一个更宽、而且在不断移动的端点集合,才能覆盖单个用户,而那些端点无法从一份服务器清单里枚举出来。那抬高了成本。它并不改变一个已经能看到两端的对手所得到的结果。
9. 设备标识符与可关联性
9.1 一个提供者能观察到你的什么
合同的 SourceId 是一个逐窗口槽位的客户端 id,不是你的设备。StoredContract.SourceId 是已认证调用方的 client_id(server/controller/connect_controller.go:461-463;由提供者在 connect/transfer.go:6088-6106 解析)。生成器为每一次窗口进入铸造一个新的客户端 id、jwt 和实例 id,并在拆除时把它移除 — 源代码直接就这么说:"The api generator mints an ephemeral platform client id (with a fresh instance id) for every window entry and removes it on teardown"(connect/ip_remote_multi_client_identity.go:10-15;在 connect/ip_remote_multi_client_api.go:326-345 铸造,在 :410,473 释放)。
但那些身份被刻意复用最长达四小时,而且在每一条路径上都是如此 — 不只是托管的那些。除非设备是托管的,否则它会安装自己的本地身份存储(sdk/device_local.go:1204-1208),而一个被存下来的窗口身份会对着同一个目的地被重放,直到它过期:windowIdentitiesStaleAfter = 4 * time.Hour(sdk/window_identity_store.go:44-52)。这么做的目的是让某个提供者的 NAT 流在一次重启之后仍然活着(connect/ip_remote_multi_client_identity.go:17-23)。代价是一个提供者可以在那个时间窗内,看到同一个 SourceId 跨应用重启从你这里再度出现。要说四小时,不要说"临时的"。
客户端的 Ed25519 身份密钥在出网路径上是每个窗口客户端新生成的。窗口客户端的设置是从一份新的 DefaultClientSettingsWithBufferSize 构建的(sdk/device_local.go:3434-3437),它不携带 ClientKeySeed(connect/transfer.go:166-183),所以 ClientKeyManager 会生成一对新的密钥对(connect/transfer_key.go:96)。被持久化的窗口快照只存客户端 id、jwt 和实例 id — 没有密钥种子(sdk/window_identity_store.go:46-52),所以一个被恢复的身份会重新发布一把新密钥。在后量子加密关闭的情况下,根本不会出示任何身份证明(connect/ip_remote_multi_client.go:9149-9157)。
隧道的来源地址是逐会话的,而且熵很低。SDK 给它的 TUN 分配一个随机的 RFC1918 地址 — "一个随机的 10.x.y.h(RFC1918、DHCP 形态)"(sdk/device_local.go:566-571,1070-1090) — 主机字节在 2..254 之间生成,取自最小的不冲突 10.a.b.0/24(connect/tun.go:323-348)。它在构造函数里算出来,从不持久化,所以每次会话都会变。提供者确实看得到它:它是每一个被隧道封装的数据包的来源字段,而提供者以它为键维护 NAT 状态(connect/ip.go:742,1009-1020,1151)。大约 253 个取值是一个很弱的逐会话指纹,不是一个跨会话的标识符。
9.2 提供者角色的身份密钥是持久的
如果你同时也共享自己的连接,那么你的提供者客户端会持有一对长期的 Ed25519 密钥对,其种子被持久化到本地存储(sdk/local_state.go:418-461,.device_local_key_material,JSON 里的 client_key_seed;在 sdk/device_local_key_material.go:54-70 应用)。与窗口客户端不同,那把密钥被刻意在一次自动登出清理中保留下来 — Android 上的注释很明确:"Clear a stale or partial auth state WITHOUT rotating the device identity. The identity key material is device-scoped, not session-scoped"(android/.../MainApplication.kt:636-647)。因此令牌刷新、部分认证恢复和 30 天闲置后的重新登录,会产生一个新的 client_id 以及一个新的 device_id,而那把 Ed25519 公钥保持不变。只有用户显式登出才会轮换它(sdk/local_state.go:639-644)。
那把密钥被发布出去,被封进每一份把该客户端列为目的地的合同(server/controller/connect_controller.go:413-415,468),而且任何人都读得到:GET /key/ 按设计是免认证的(server/api/api.go:123-124;server/controller/connect_controller.go:826-829)。所以任何一方 — 不只是你服务过的某个提供者 — 都能把任何一个客户端 id 解析成它的公钥,并把共享同一把密钥的那些客户端 id 聚成一簇。对一个提供者账户来说,那是一个持久的、跨会话、跨 client_id、跨 device_id 的把手。这是提供这件事的一项真实代价,而本语料里别的任何地方都没有记载过它。
9.3 在运营方那里,一切都连得上
client_id从不被吊销。代码自己的注释:"client_ids are globally unique addressess tantamount to IPv6 / they are never revoked once allocated, to preserve security and audit records"(server/model/network_client_model.go:64-67)。一个顶层客户端在闲置 30 天后被停用(TopLevelClientIdleExpiration,:2289),并在那之后 30 天被硬删除(NetworkClientReapAfterDeactivate,:2269),并级联到设备行和 Redis 的ckey(:2537)。device_id是逐登录的,不是逐硬件或逐安装:每一个顶层客户端都会铸造一个新的(:332-352),而代码指出由此产生的"身份翻新(每次登录一个新的 device_id)"(:2280)。窗口客户端继承它(:356-380),而它从不被发给提供者。- 网络令牌是一个 24 小时的 JWT(
server/jwt/by_jwt.go:35-37,188),由 SDK 在半衰期时刷新 — 大约 12 小时 — 并带抖动(sdk/device_token_manager.go:107-124,165)。刷新令牌并不会轮换客户端 id;同一份声明会被重新铸造(server/model/network_client_model.go:102-125)。 audit_contract_event按合同记录客户端和提供者的身份(server/db_migrations.go:234-251)。client_reliability把那个带密钥的 IP 块哈希按块直接连到一个客户端 id 上:最初的四列主键(server/db_migrations.go:2083-2106)后来被缩减为(block_number, client_address_hash, client_id)(:2191-2195),而线上的分区表用的是同样这三列(server/model/network_client_reliability_partition_model.go:194-195),其中network_id作为索引载荷保留下来(:63,631-634)。留存期是 30 天(server/model/network_client_reliability_model.go:42,687-721;按日分区整个丢弃)。network_client.auth_time是一个持久的最后可见时间,保留 30 天(server/model/network_client_model.go:2269,2289);已断开的连接行在 8 小时后被回收(server/taskworker/work/network_client_work.go:85)。- 账户级别的标识符,只在运营方那里:
network_user.user_auth里的明文邮箱或电话、SSO 登录的完整第三方 IdP JWT、user_auth_attempt里每一次登录尝试上的明文标识符、钱包地址、支付行、一条持久的network_referral谁邀请了谁的边,以及audit_provider_event行,后者对着network_id和device_id携带字面的国家、地区和城市字符串,而且不受那个 8 小时连接回收的约束(review/verified/PRIVACY-ENFORCEMENT.md§1.4)。 User-Agent被那份五个请求头的白名单记录下来(server/http_log.go:15-29)。它是那五个里唯一一个构成指纹面的条目。
9.4 WireGuard 桥把你跨提供者关联起来
每一个 WireGuard 客户端都从一个被打乱的、包含 10,000,000 个 RFC1918 地址的池子里分到一个稳定的私有隧道地址(server/model/network_client_proxy_model.go:1034,1038-1118),写进配置文件作为接口地址(:756-771),而且它在出网之前没有被归一化 — 源代码自己的 FIXME 写着"currently the client ipv4 is threaded to the egress providers / this can allow tracing a single client ipv4 across multiple providers"(proxy/wg.go:23-25)。它是一个私有地址,不是你的真实 IP,而目的地网站永远看不到它。但你窗口里的每一个提供者都看到同一个来源地址,所以合谋的提供者可以判断出那些流属于同一个客户端 — 而那恰恰是多提供者窗口在别处所提供的那项性质。再加上那个固定的 1.1.1.1 解析器(§3.2)以及那条路径上密封会话的缺席,WireGuard 桥是使用这个网络最不私密的方式。应用和 SDK 是最私密的。
10. 法律要求
管辖权。BringYour, Inc. 是一家特拉华州公司(docs/legal/ur.xyz/terms.md:16,25),通信地址在旧金山(docs/legal/terms.md:269-271)。ur.io 的条款把准据法和管辖地定在德克萨斯州哈里斯县(docs/legal/terms.md:315-321);ur.xyz 的条款定在特拉华州(ur.xyz/terms.md:223-229)。这一切都在美国,而这一切都可被美国的强制程序触及,包括带着保密令到来的程序。条款声明运营方"可在法律或政府命令要求的情况下监控并披露信息"(docs/legal/terms.md:204-206)。
一项针对运营方的要求能够到什么。按去匿名化能力从强到弱:这个账户是谁 — 明文的邮箱或电话、SSO 登录的第三方 IdP JWT、钱包地址,以及那些经由 Stripe、Apple、Google Play 或链上 tx_signature 与真实身份挂钩的支付记录;它在什么时候、从哪座城市连接,按连接记录;它用过哪些提供者、传了多少字节;以及它连接时所在 /29 或 /56 块的带密钥哈希,外加明文的来源端口。第三方持有的更多:§1 中点名的那些支付处理商持有运营方从不存储的身份,而一个 Solana tx_signature 可以在一条公链上解析到一个钱包。
一项要求够不到什么,因为那些东西根本不存在。传输账本里任何地方都没有目的地、主机、URL、SNI、端口或域名字段(review/verified/PRIVACY-ENFORCEMENT.md §1.2)。上传的支持日志在收到时即被丢弃。不存在任何可以交出的浏览记录,在任何路径上都是如此,无论密封与否 — 这是本文中最强的一项结构性性质,而且无论谁怎么表现它都成立。
一项要求前瞻性地能够到什么。上面的一切都是关于已存储的记录。一项强制未来行为的要求是另一回事,而 §7.4 正是它咬下去的地方:一个被强制去截获某个特定用户的运营方,今天可以替换或省略提供者密钥材料,从而即便在后量子加密启用的情况下也读取那个用户的流量,而唯一的信号是一行设备日志。密封会话抬高了回溯性披露的成本。以它当前的形态,它并不抵抗一个受到强制的运营方向前实施的行为。
账户删除是不完全的。RemoveNetwork 是一份手写的删除清单,不是数据库级联(server/model/account_model.go:108-260)。它移除 network_user 行及其认证记录(密码、SSO、钱包、恢复短语)、network 行和名称索引条目,并排期一次 Stripe 退订。它不删除 account_payment、stripe_customer、apple_subscription_transaction、已完成的 solana_payment_intent 行、network_referral、设备行或审计行;那些按各自的时间表自行老化 — 审计事件在 180 天(server/model/audit_model.go:984,1018) — 或者,对已完成的链上支付而言,根本不老化(server/model/solana_payment_intent_model.go:386-400 只删除 tx_signature 为空的意图)。删除还会写入一行:一条以被删除的 network_id 为键的 AuditEventTypeNetworkDeleted 事件(account_model.go:255-257)。隐私政策里那句"删除他们的账户及相关个人信息"(docs/legal/privacy.md:69)比代码实际做的更宽。
不存在搜查令预警、不存在透明度报告,也不存在公开的执法处理流程。已在 docs/ 和 ur.io 站点源码中核实为缺席。任何地方唯一一处指向法律程序的指引,是引导送达程序的人从特拉华州公司司去获取特拉华州的注册代理人(docs/legal/ur.xyz/terms.md:262);[email protected] 的范围限于漏洞报告(docs/legal/vdp.md:42),而 [email protected] 是一般性的合同通知地址。URnetwork 自己的对比文档在面对那些确实发布这些东西的竞争对手时,已经承认了这一点,而本文重复它,而不是把它放软。
隐私政策在本该具体的地方保持沉默。它声明“我们从用户处以及关于用户所收集的全部个人信息,仅限于电子邮箱地址或电话号码”,并且收集发生在“你提供时直接从你那里”取得(docs/legal/privacy.md:29-37)。
本节的一份更早草稿曾以“代码存储的类别比政策所点名的更多”为由,把这称作描述不足。那种说法已于 2026-08-09 被撤回,因为它是错的。本文盘点的其余一切,要么是用户通过注册或付费而自己创建的账户数据(那个 Stripe 连接键之所以存在,是因为有人同意了被计费),要么根本不是个人信息:一个来源端口,以及一个运营方不去反解的带密钥地址块哈希。一个带密钥的哈希在原则上能否被它自己的持有者反解,是一条真实的工程注意事项 — §5 说了这一点,而缺失的轮换是一处真实的弱点 — 但一个没有人去查的值,不是一个政策未曾申报的个人信息类别。
政策真正缺失的是另一回事,而且更窄:没有留存期、没有日志声明,也没有执法或政府披露章节。在这份政策里,retention、log、IP address、subpoena 和 legal process 这些字串都不出现。这一点之所以要紧,是因为留存纪律在代码里真实存在,而且比政策所声称的更强 — §5.1 列出了经核实的窗口。一个今天去读这份政策的用户,无法拿其中任何一个来约束运营方。
11. URnetwork 不防御什么
有三件事成立,而且值得在下面这份清单之前先点出来,好让这份清单被读作一条边界,而不是一份判决书。在中继路径上,提供者永远不会得知你是谁,而且没有任何设置或提供者构建能改变这一点(§2)。传输账本里任何地方都没有目的地、主机、URL、SNI、端口或域名字段,所以不存在可被强制交出、泄露或出售的浏览记录(§5、§10)。而这些主张中的每一条,都能在公开源代码里读到,这正是本文能够对自己的失败如此具体的原因。
下面的一切都是失败。不加保留地陈述;每一项在上面都有展开。
- 端到端的时序与流量关联。没有填充、没有掩护流量、不做混淆。一个同时观察你的接入网络和承载你流量的那些提供者的对手,可以把两端关联起来。§8.1。
- 一个全局被动对手。完全不在范围之内。§8.3。
- 运营方与提供者的合谋。运营方知道你是谁,提供者知道你去了哪里,而那份合同记录把两者接了起来。密封收窄了运营方独自持有的东西;它并不打断那个接点。§6.2。
- 一个受到强制或怀有敌意的运营方替换或省略提供者密钥材料。今天,那项本该抓住它的交叉核对只是记录并继续。§7.4。
- 密封会话的静默降级 — 在后量子加密关闭的情况下。在那种模式下,任何失败(握手、身份证明、密钥缺失)都会回退到明文,没有任何用户可见的提示。对于那个明确要求加密的模式,已于 2026-08-10 修复:在开关开启的情况下,客户端以失效关闭的方式运行,拒绝发送或接受明文的应用数据。在两种模式下都留存下来的,是那个缺失的指示 — 没有任何应用显示某条给定的连接是不是已密封。§2.2。
- 一个恶意提供者篡改未加密的流量。明文 HTTP 被原样透传;提供者所处的位置就是一个怀有敌意的热点。§3。
- 端点被攻陷。恶意软件、被攻陷的操作系统、一个怀有敌意的浏览器扩展,或者任何拿到你已解锁设备的人。任何网络产品里都没有东西能处理这个。
- 目的地一侧的识别。Cookie、登录、浏览器指纹和账户行为,会不管数据包是怎么到达的都把你识别给你访问的那些站点。改变你的出口地址,并不会让你对一个你登录了的服务变得匿名。
- 支付通道的身份。银行卡和应用商店通道会把你的身份留在支付处理商那里,尽管运营方并不存储它。链上 USDC 会留下一个公开的
tx_signature。 - 女巫提供者。任何人都可以提供,不需要任何证明或质押。单独一方可以运行许多提供者,其中包括运营方。
- 浏览器和代理路径上密封的缺席。浏览器扩展没有后量子加密这个设置,而在那些路径上是运营方在运行客户端。§2.4。
- WireGuard 桥上的跨提供者可关联性。在你窗口里的每一个提供者面前都是同一个稳定的隧道地址。§9.4。
- 对任何同时也在提供的人而言的一个持久身份把手。提供者客户端的 Ed25519 密钥按设计能挺过客户端 id 和设备 id 的轮换,而
GET /key/是免认证的,所以任何一方都能把共享一把密钥的那些客户端 id 聚成一簇。§9.2。 - 本地网络在 DNS 启动窗口期间所看到的流量。源代码里把它记载为一项被接受的代价。§3.2。
- 一次第三方审计本会发现的任何东西。对协议或服务器代码,从来没有做过这样的审计。
12. 本文无法确立的东西
一项在威胁模型里未被确立的主张,也是一项不该出现在文档里的主张。以下这些都还开着。
- 入口负载均衡器记录什么。nginx 的请求头配置就在树里(
xops/.../connect/ingress.yaml:9),而 connect 服务基于一个哈希后的地址做限流(server/connect/transport_rate_limit.go:65-80),但 nginx 自己的访问日志配置没有被审计过。任何端到端的"我们不保留你的地址"陈述,都取决于它。 - §5 里那些不受门控、携带地址和 SNI 的日志行是否到达持久存储。它们被写到 stderr;而 stderr 在生产环境里去往何处、被保留多久,是一个部署问题,这次源码复核回答不了。那个监控尾随器确实会从日志行里抓取
ip:port模式并把它们重新发到发现结果里(server/monitor/tailer.go:136,328,333),这是至少其中一部分行会被那些会保留它们的系统读取的证据。 - WireGuard 服务器端的代理设备在把数据包交给提供者之前,是否重写了数据包来源。那个稳定的隧道地址以及运营方对真实公网端点的视野(
server/proxy/wg_handoff.go:56-67)都已核实;而穿过server/proxy/proxy_device.go和OpenProxyDevice的完整路径没有被从头到尾读过(review/verified/ARCHITECTURE.md,无法核实项 3)。 - 浏览器路径上的名称解析是否有可能在本地发生。扩展默认配置的是一个 HTTPS CONNECT 代理,它在远端解析,而 SOCKS 的名称解析经由隧道的 DoH 拨号器(
connect/tun.go:1155-1175)。各浏览器在每一种配置下的代理 DNS 行为没有被测试过。 - 生产环境实际的日志详细程度。
BY_LOG_V默认为 0(server/env.go:41-49),这会抑制客户端和服务器上由V(1)门控的目的地日志 — 但树里唯一那份部署清单把它设成了2(xops/gitops-unused/.../api/deployment.yaml:47-48),而它所在的目录名叫gitops-unused。请把日志详细程度当作一个运行时开关,绝不要当作一项结构性保证。 - 队伍的独立性。运营方从它自己对当前已连接、有效提供者的聚合中发布实时的城市和国家数量(
server/model/network_client_location_model.go:1607-1641),而位置是由运营方从它观察到的连接推导出来的,不是由提供者申报的(review/verified/ARCHITECTURE.md§6.4)。但没有任何独立方从外部测量过这支队伍,也没有任何东西核实提供给某一位特定客户端的那些提供者彼此独立、或者独立于运营方。这个机制在源代码里是可核查的;而这个群体在今天不是第三方可核查的。 - 账户创建时的身份密钥绑定。设计中陈述的期望是"(ClientId,公钥)绑定在账户创建时注册"(
connect/transfer_key.go:20-30)。而实际发布出来的,是一个客户端把它的密钥发布到由运营方控制的 Redis,再由一个由运营方控制的 API 提供回来。是否在运营层面存在一种更强的注册,无法从源码确立;请假定不存在。 - 是否每一条设备发送路径都使用一个窗口客户端。窗口路径的临时密钥已核实(§9.1)。设备的顶层客户端携带那个持久的种子,并无条件启用加密(
sdk/device_local_provider.go:90-98),所以任何搭乘顶层客户端的发送 — 网络对端关系、伴随设备的返回路径 — 都是用那把稳定的密钥签名的,并携带那个稳定的顶层client_id。那些路径没有被穷举列出。请把"出网标识符是临时的"读作限定于窗口客户端那条路径。 Roles和Principal是否曾为普通消费者客户端填充过。它们是由运营方赋值的字符串,被封进已签名的合同字节里,而且只为ProvideMode_Network设置(connect/protocol/transfer.proto:408-415;server/controller/connect_controller.go:474-481)。没有找到任何证据表明那些应用会填充它们,而且不是每一个调用方都被审计过。如果它们曾经为消费者流量填充过,它们就会是提供者可见的一等跨会话标识符。- 非移动主机上的密钥材料持久化。
ClientKeySeed只从sdk/local_state.go、sdk/device_local_key_material.go、sdk/device_local.go和sdk/cgo/被引用。不调用那些的主机每个进程都会拿到一把新密钥;Linux、Windows、扩展和服务器代理各自的行为没有被逐一核实。 - ur.io 条款里的德克萨斯管辖地是否有意为之,考虑到一个特拉华州实体、一个加利福尼亚地址,以及 ur.xyz 条款里的特拉华管辖地。树里没有任何文档解释它。这是一个交给法务的问题,不是一项发现。
13. 报告
漏洞:[email protected],以及位于 ur.io/vdp 的披露政策。对本文的更正,包括对其中任何结论的不同意见,同样欢迎经由这条路径提出 — 一项与这里某条主张相矛盾的独立发现,比那条主张本身更有用。