威胁模型
本文点名 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-09-17 位于 /Users/brien/urnetwork/{connect,sdk,server,proxy,extension} 的工作树;就在那一天,本文中每一条锚定到行号的引用都按标识符对照这些工作树重新解析了一遍。行号会漂移;标识符不会。凡是某项发现是在更早一轮代码核实中确立的,它引用的是 review/verified/ARCHITECTURE.md 或 review/verified/PRIVACY-ENFORCEMENT.md,它们采用同样的约定。
那次重新解析不是表面功夫,值得记下来,作为对任何在读旧副本的人的提醒。在 2026-08-07 那一轮与这一轮之间,connect/transfer.go 的长度大约翻了三倍,几乎每一条 connect 引用都挪了位置 — 当初引用在 transfer.go:2796 的那道失效关闭的发送闸门,今天在 :6751,而当初引用在 :5844 的接收闸门,今天在 :14886。在本文任何一份不带上面那个日期的副本里,任何行号都应被视为不可靠;请按标识符定位。
有三个标签是刻意使用的:
- 性质 — 代码强制执行它,而且一个控制了其他各方的攻击者仍然无法违反它。
- 纪律 — 代码选择不去做一件它有能力做的事。一次部署变更或一行补丁就能把它反转。
- 设计意图 — 设计说这是目标;现有发布代码尚未强制执行它。
摘要
本节是给不会读完整份记录的读者的。其中每一句话,都由下面某个带编号的小节或上面那段保证声明支撑。
这个系统是什么。URnetwork 是一个隐私网络,其中成员为其他成员中继流量。路径是:你 → 扩展器 → 运营方 → 提供者 → 互联网。信任的分割在两个中继方之间:运营方,它知道你是谁;以及提供者,它看得到你的流量去哪里。
本文存在,是为了回答这个问题。一个不受信任的运营方和一组不受信任的提供者,只要它们不合谋,还能不能承载一条私密且匿名的会话?今天诚实的回答是大部分做到了,而缺口可以被精确定位:提供者对身份的盲视,面对一个任意恶意的提供者也无条件成立;运营方对内容的盲视,面对一个被动的运营方和任何路径上的攻击者都成立,但它依赖于运营方诚实地分发提供者身份密钥,也依赖于运营方不以敌对的方式为你挑选提供者。§1.1 是逐方陈述这一点的信任模型;§7 是这条保留意见背后的机制。
两条性质,各自限定范围。在中继路径上,提供者永远收不到你真实的来源 IP;没有任何设置或提供者构建能改变这一点(§2)。运营方读不了密封的客户端到提供者会话,该会话以后量子加密(Post Quantum Encryption)之名,在五个原生应用(Android、iOS、macOS、Windows 和 Linux)上可用(§2.1)。浏览器扩展没有密封会话 — 它的客户端设备运行在运营方内部,运营方在那里充当协议转换点,所以那里不可能存在密封(§2.4)。
三项信任依赖,以及各自的现状。这个密封过去在每一种模式下都会静默地失效开放。2026-08-08/10 已修复:在后量子加密开启的情况下,客户端以失效关闭的方式运行,宁可拒绝发送或接受明文的应用数据,也不降级到明文(§2.2)。还剩下两件事,而两者都是对运营方的信任,而不是对密码学的信任:
- 密钥分发。运营方能替换一把提供者密钥吗?自 2026-09-17 起难得多了,而且不再能抵赖。在后量子加密开启的情况下,客户端会扣住会话 cipher,直到合同提供的那把提供者密钥与运营方已发布的一份签名的、哈希串链的、绑定部署域的注册历史相互印证为止;而一次经过验证的不一致会为那个提供者终结这条会话(§7.4)。一个实施替换的运营方如今必须为这次替换签名,留下一条永久的、可归责的记录,而这条记录与该客户端 id 的其他每一位读者所看到的内容相分歧。它挡不住的,是一个从你第一次接触起就怀有恶意、并且始终保持一致的运营方:它签署一条自洽的链,上面写着它自己的密钥,而一个对哪个签名者才具权威没有独立视野的客户端分辨不出来。有签名,不等于签名者自己伪造不了。
- 提供者挑选。运营方为提供者集合排名并把它返回,而客户端没有任何办法核实被提供给它的那些提供者彼此独立、或者独立于运营方(§5、§6.1)。因此,一个不受信任的运营方不需要与提供者合谋;它可以挑选它自己的提供者。
- 还有默认设置。失效关闭的保证绑定在一个开关上,而这个开关在全部五个原生应用里仍然以关闭状态发布(§2.2),所以一个采用默认配置的客户端根本不会与提供者建立会话:每一个应用数据包都以未密封的状态穿过运营方,走的是标准路径。
verify/BEFORELAUNCH.md 的第 7、8 和 9 项对这三者都在跟踪;§2.2、§6 和 §7.4 描述的是当前状态。
这个系统不防御什么。一个同时观察你的接入网络和承载你流量的那些提供者的对手,可以靠大小和时序把两端关联起来(§8.1)。一个全局被动观察者完全不在范围之内(§8.3)。URnetwork 不提供填充、不提供掩护流量、也不做混淆;第 11 节是完整清单。
保证。没有对协议、连接引擎或运营方服务器代码的独立审计;2025 年的两项第三方评估覆盖的是其他层面:一次对 Web 应用和 API 的渗透测试,以及一次 Leviathan Security Group 对 Android 应用的 MASA AL2 评估,结果通过。
细节住在哪里。第 1 节定义各方,并逐方陈述信任模型(§1.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:395)。不是"一台中继服务器" |
| API / 控制面 | 运营方 | 认证、提供者发现与排名、合同、公钥查询(server/api/api.go) |
| 提供者 | 任何成员 | 接收被中继的数据包,并从它自己的连接拨向目的地(connect/ip.go:7167 NewRemoteUserNatProvider;Receive 在 :8726,ReceiveBatch 在 :8486 → LocalUserNat,:731,784) |
| 扩展器 | 任何志愿者 | 路径的第一段:一个位于独立地址上的 TLS 转发中继。它承载客户端↔平台会话而不终止它,所以它看得到连接方的 IP,也无法解密 — 内层的流是"客户端自己通往目的地的 tls,扩展器永远看不到其内部"(connect/extender/extender.go:35-44;connect/net_extender.go:34-48) |
| DoH 解析器 | Cloudflare、Google、Quad9、OpenDNS | 应用路径把 DNS 经隧道以 DoH 解析到这四家之一(connect/net_http_doh.go:148-166) |
| 支付处理商 | Stripe、Apple、Google、Solana/Circle | 为付费账户持有真实身份;运营方存储那些连接键(server/db_migrations.go:1804、:4732、:2441) |
运营方是 BringYour, Inc.,一家特拉华州 C 类公司,通知地址在旧金山(docs/legal/terms.md:8-9,265-271 写明了该实体和这个地址;其注册成立地在已发布的条款中并未载明)。第 10 节讲这一点对法律程序意味着什么。
关于用词,需要说明一句,因为这个网络的经济层用另一个词称呼同一方。在提供者因其承载的带宽而获得报酬的语境里,它们被称为矿工(miner);而且这个网络被设计为拥有许多独立运营的运营方,而不是只有一个。两者都不改变密码学:模式始终是一个客户端 ↔ 一个运营方 ↔ 一个提供者,而下面的每一项性质都是这个三角的性质。运营方和提供者的多元化所改变的,是 §1.1 中不合谋这一假设的可信度,而不是那个假设所保护的机制。
1.1 信任模型
这张表这样读:如果这一方任意恶意而其余各方诚实,这项性质还能成立吗?
| 性质 | 面对恶意的提供者 | 面对恶意的扩展器 | 面对恶意的运营方 |
|---|---|---|---|
| 你的身份对出口隐藏(提供者永远不会得知你的地址或账户) | 是 — 无条件。地址从不出现在线路上;提供者的入口点只接收 id(§2、§3) | 是。扩展器看得到你的地址,而那是你的 ISP 早已知道的;它对出口一无所知。例外是提供者角色,也就是你共享连接时开启的那个角色:它会以自己的客户端 id 向它所测量的扩展器表明身份(§4) | 否。运营方按构造就知道你是谁;那正是它的角色(§5) |
| 你的目的地对协调方隐藏(运营方读不了这条会话) | 不适用 — 提供者正是看得到目的地的那一方 | 是。扩展器转发的是一条它无法终止的会话(§4) | 有条件。面对一个被动的运营方、面对降级、面对一个后来才变得恶意的运营方,以及面对一个在自己各条通道之间前后不一的运营方,都成立。面对一个从第一次接触起就恶意且自洽的运营方,不成立(§7.4) |
| 不会降级到明文(流量要么被密封,要么不流动) | 是,在开关开启时。接收闸门拒收被剥除封装的明文(§2.2) | 是,在开关开启时 | 是,在开关开启时 — 省略变成拒绝服务,而不是泄露。否,在开关关闭时,而那仍是发布时的默认状态(§2.2) |
| 不存在可被强制交出或泄露的浏览记录 | 是 | 是 | 是 — 结构性的。传输账本里任何地方都不存在目的地、主机、URL、SNI、端口或域名字段(§5、§10) |
| 你的流量无法被端到端关联 | 否 | 否 | 否。没有填充、没有掩护流量、不做混淆 — 按设计不在范围之内(§8) |
由此得出三点,而它们就是摘要里那个问题的答案的诚实轮廓。
提供者不被托付任何东西,而这是在结构上强制执行的。不是靠政策,也不是靠提供者运行官方构建 — 而是靠地址从来不被发出去。这是本文中最强的一项性质。
运营方被托付两件事,而两件都悬而未决。它分发密封据以校验的那些提供者身份密钥(§7),它还决定你拿到哪些提供者(§5、§6.1)。今天,这两件事客户端都无法核实。
这两件事并不相互独立,而第二件更尖锐。只要提供者是由运营方挑选的,一份说"除非运营方与提供者合谋,否则就是安全的"的文档就低估了问题:如果运营方能挑选它本来就控制的任何一方,它就不需要去收买任何人。设计上的区分是替换与挑选(connect/DESIGNNOTES2.md §3.4)。封堵替换有一个密码学上的答案,而且正在建造当中(§7.4)。封堵挑选,需要某种能让客户端就一个提供者集合的独立性加以核查的东西,而发布的代码里没有任何东西尝试这样做。
2. 三种模式,以及每一方看到什么
这张表是权威版本,并与 OVERVIEW.md 一致。本文其余的一切都是它背后的机制。
| 模式 | 运营方看到 | 提供者看到 | 默认与可用性 |
|---|---|---|---|
| 中继密封 | 账户/来源连接、提供者关联、密文及时序/数据量 | 目的地流量、设备/合同 id,不含你的真实来源 IP | 仅限原生应用,而且今天需自行开启:已裁定为默认,但发布时仍为关闭;见 §2.2 |
| 中继标准 | 账户/来源连接、提供者关联、内层目的地和数据包字节 | 目的地流量、设备/合同 id,不含你的真实来源 IP | 原生应用今天的默认状态,因为密封发布时是关闭的(§2.2);浏览器和代理路径也是如此(§2.4);密封开着时,无法完成密封的提供者会被跳过,而不是改走这里(§2.2) |
| 直连 | 更少的中继参与 | 你的真实来源 IP及目的地流量 | 自行选择开启,做法是关闭强匿名化(§2.3) |
由此得出两句不对称的陈述,而它们的强度并不相等:
提供者对身份的盲视是一项性质,而且在两条中继路径上都是无条件的。提供者的入站入口点接收的是 id,从来不是地址:RemoteUserNatProvider.Receive 的参数是一个由三个 16 字节 id 组成的 TransferPath(connect/ip.go:8726,批量形式在 :8486;connect/connect.go:50)。没有任何传输协议缓冲区携带客户端地址、城市或国家 — 对 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-09-24,发布时仍为关闭。产品负责人在 2026-08-07 裁定,密封会话以启用状态发布,好让运营方的盲视成为默认状态,而不是一个用户必须自己做出的选择。这一裁定尚未落地。性能配置标志在全部五个应用里仍然是 false — android/.../PerformanceProfileSettings.kt:34,88、apple/app/network/Shared/ViewModels/DeviceManager.swift:395,479、windows/app/src/App/SdkHost.h:192 和 linux/app/src/ConnectDrawer.cpp:746 — 而 SDK 启动设备时根本不带任何性能配置。没有这个标志,窗口客户端就保持 DefaultEncryptionSettings,其模式是 EncryptionModeOff(connect/transfer_encrypt.go:768-770;sdk/device_local.go:4483),所以一个采用默认配置的客户端今天运行的既不是 Required,也不是 Opportunistic:它根本不与提供者建立会话,每一个应用数据包都以未密封的状态穿过运营方(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),而中继恰好只反序列化这一条消息,不多一分(server/connect/resident.go:3824) — 而那是纪律,不是一道密码学屏障。
2.1 这个密封究竟是什么
一条直接位于客户端与提供者之间的 TLS 1.3 会话,以普通控制帧的形式穿过同一个运营方承载:EncryptedControl 消息装着握手字节,搭乘正常的可靠有序 Pack/Ack 流,每一对对端一条会话,由字典序较小的那个 ClientId 担任 TLS 客户端角色(connect/transfer_encrypt.go:30-60)。密钥交换是混合的 X25519MLKEM768,以常规 X25519 作为后备(connect/transfer_encrypt.go:376-384);AEAD 是 AES-256-GCM,其 32 字节密钥在 RFC 5705 标签 urnetwork-connect-aead 之下导出,每条消息使用一个 12 字节的随机 nonce(标签和长度见 :88-99,构造见 :283-301);身份是 Ed25519,由 ClientKeyManager 在进程内生成(connect/transfer_key.go:87-143,三棵树中唯一的 ed25519.GenerateKey 调用,位于 :113) — 所以"后量子"只限定于密钥交换。双向 TLS 是必须的(ClientAuth: tls.RequireAnyClientCert,connect/transfer_encrypt.go:406),而对端证书是在序列层对照合同的承诺值来校验的,不是由 TLS 栈来校验;connect/transfer_encrypt.go:386-398 处的注释解释说,没有 mTLS 的话,担任 TLS 服务器角色的那一侧就不会有 PeerCertificates,合同校验会在全部对端对中的一半上失败。
2.2 你要求时失效关闭,你不要求时失效开放
2026-08-10 更新。本节此前说这个密封会静默地失效开放,而且不存在失效关闭的选项。那已经不再属实,而这项变更是本文历史上最重要的一次加固 — 它把双向的降级通道都关上了。下面写的是当前的行为,读自源代码。
加密仍然是 Cipher() != nil 的一个二元性质,而 cipher 为空仍然意味着会话不可用:一次失败的握手、一次失败的身份证明,或者一份从来没有携带对端公钥的合同,都会让它保持为空。其结构上的原因值得说一次,因为其余的一切都取决于它 — AEAD 在对端通过认证之前就已存在,而且是被刻意扣住的。completeHandshake 把它停放在 derivedTlsCipher 上,而不是把它暴露出来(connect/transfer_encrypt.go:1719-1726);只有 maybeVerifyPendingPeerIdentityProof 会把一个 epoch 提升为已建立,而且只在身份证明验证通过时才这么做(:1928-2050,提升位于 :1979-1981)。Cipher() 读取的是 establishedEpoch(:2625),所以对每一个调用方来说,一个未经证明的对端与一次未完成的握手无法区分。
对本节早先版本的一处更正:身份证明失败不再只是被"留作未认证"。它现在还会设置 identityFailedTerminal、取消该 epoch,并发出一个 EncryptionEventIdentityFailed(connect/transfer_encrypt.go:2005-2030)。日志行仍然写着 "session left unauthenticated"(:2027),这个措辞低估了代码实际所做的事。
变的是接下来会发生什么。现在有三种模式(connect/transfer_encrypt.go,EncryptionMode):
| 模式 | 行为 |
|---|---|
EncryptionModeOff | 零值。会话层不起作用,一切都走明文。 |
EncryptionModeOpportunistic | 一旦会话建立起来就密封,在那之前走明文 — 如果会话始终没有建立起来,就永远走明文。这是历史上的行为。 |
EncryptionModeRequired | 对于一个本应建立会话的对端,绝不以明文暴露发往它或来自它的应用数据。 |
在 EncryptionModeRequired 之下,这项保证在四个点上被强制执行:
- 发送入口闸门(
connect/transfer.go:6751-6800,在SendSequence.Pack中)。一个应用 pack 会在调用方的超时之内等待 cipher,随后被拒绝、不发出 — 绝不降级 — 并带上一个带类型的ErrEncryptionRequiredNotEstablished(connect/transfer_encrypt.go:505)。这道闸门被刻意放在分配序列号之前:客户端角色的握手搭乘的正是同一个序列,所以扣住一个已经分配了序列号的帧,会在有序的接收侧留下一个缺口,把 ClientHello 困在缺口后面,从而让那个本该放开这道闸门的握手陷入死锁。 - 发送兜底(
connect/transfer.go:11726-11738,在writeMaybeWrappedBytes中)。一个在没有 cipher 的情况下到达写入器的帧会被拒绝,而不是被写出,这覆盖了会话在入队与写出之间被拆掉的那一小段竞态。 - 接收闸门(
connect/transfer.go:14886-14913)。来自一个本应建立会话的对端的明文应用帧会被丢弃并记入审计 — 这是更要紧的那一半,因为它关掉了这样一种降级:攻击者剥除封装,而接收方本来会接受那份明文。那个帧是先被确认(ack)再丢弃的,而不是被搁着不确认,因为扣住确认会在有序序列里留下一个缺口,把两端都卡死。 - 候选者预筛(
connect/ip_remote_multi_client.go:11695-11710,EncryptionCapabilityPrefilter,默认值为 true,位于:196)。如果平台的带外密钥 API 表明某个窗口候选者从来没有发布过身份密钥,它会被立即判为失败,因为它永远无法完成握手。这条拒绝规则被刻意做得很窄(:13229-13236):一次抓取出错并不导致拒绝,所以运营方不可达无法被变成对提供者的封禁。它只是让必定的失败来得更快;它绝不会放进一个候选者。
握手控制帧、确认(ack)以及控制面对端按设计豁免 — 这道闸门覆盖的是应用载荷,不是把它引导起来的那些脚手架。
你拿到哪一种模式,取决于后量子加密这个开关。当它开启时,消费者客户端运行 EncryptionModeRequired(connect/ip_remote_multi_client.go:13080-13091,取自 :1311 处的配置标志):一个建立不起会话的提供者,根本不会为你承载任何应用流量,而不是把它明着承载过去。写明的代价是可用性,而这是刻意接受的。提供者运行 EncryptionModeOpportunistic(sdk/device_local_provider.go:186),这样一个提供者就能同时服务已密封和未密封的消费者;那是响应方一侧的兼容性选择,并不削弱发起方的保证。
请注意,这个模式枚举的零值是 Off(connect/transfer_encrypt.go:469-504),所以一个零值的设置结构体什么都不加密,而不是悄悄地只加密一半。这是在 Encrypt bool 被三态取代时刻意做出的选择。
这对那个开关意味着什么。在后量子加密开启的情况下,密封不再失效开放:它在发送和接收两侧都失效关闭,而且是响亮地关闭。在后量子加密关闭的情况下(应用正是这样发布的),客户端连机会性都谈不上:它运行 EncryptionModeOff,从不发起会话,所以它的全部应用流量都以未密封的状态流动,而且没有任何东西告诉用户。所以诚实的说法是带条件的,而默认状态要紧:见 §2 里发布时的默认那一段。
这个行为由测试覆盖,不只是靠注释 — TestRequiredEncryptionFailsClosedAgainstPlaintextPeer、TestRequiredGateNonBlockingSendRefusesPreCipher、TestRequiredGateBoundedBudgetRefusesUnsent、TestRequiredSendRefusalTypedErrorAndEvent 和 TestRequiredContractFreeWithoutKeySourceFailsClosed。
审计时有一条过时的注释可以忽略:connect/transfer_encrypt.go:1723-1725 处 completeHandshake 内部的那条注释仍然不加限定地断言,在身份证明验证通过之前,"封装路径观察到 cipher == nil,流量以明文流动"。那只在 Off 和 Opportunistic 模式下成立;在 Required 之下,上面那几道闸门推翻了它。这条注释早于那次修复。
此后已部分解决:现在有了一个身份展示界面,尽管还不是逐连接的提示横幅。SDK 导出一组已建立会话且身份已验证的对端,并附上一个规范的显示指纹 — sdk/post_quantum_identity.go 把 PublicIdentityKeyHash 定义为"各平台上身份密钥的唯一规范显示哈希:密钥的 SHA256,以不带填充的大写 base32(RFC 4648)编码",ProviderIdentity 位于 :24-30,视图控制器位于 sdk/post_quantum_identity_view_controller.go:21,两个应用都在使用它(PostQuantumIdentityViewModel.kt、PostQuantumIdentityStore.swift)。这不只是一个钩子:它是一条用户可核查、独立于运营方的比对通道的开端,因为从别处获得的指纹可以拿来与显示出来的那个相比对。仍然缺少的,是一个朴素的逐连接"这条会话已密封"指示。其余的设备日志信号是:成功时的 peer identity proof verified — cipher is now usable(connect/transfer_encrypt.go:2004)、失败时的一次 Errorf(:2026-2029),以及闸门拒绝时的一个 NotifyRequiredSendBlocked 事件。
2.3 直连模式
关闭强匿名化会设置 AllowDirect,而它自己的字段注释写着:// setting this to true exposes the real source IP to the provider(sdk/sdk.go:1130-1139)。数据流被强制走上一条 P2P 流(connect/ip_remote_multi_client_probe.go:1207-1209),跑在一条 WebRTC/ICE 数据通道上(connect/transport_p2p_webrtc.go:818-825),而 ICE 意味着两个端点都会得知对方的地址。默认是关闭 — 一个空的性能配置产生 false(connect/ip_remote_multi_client.go:841-850;sdk/sdk.go:1130-1139)。
有两种被强制的情形:同网络对端(你自己的设备)总是允许直连(connect/ip_remote_multi_client.go:843-850,2656-2660),而托管设备则强制关闭它(sdk/device_local.go:608-615)。请注意,直连模式只把运营方从数据路径中移除。合同、提供者选择和控制消息仍然经过平台,所以运营方仍然得知你用了哪个提供者、移动了多少字节。
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:858,SetUpgradeMuxSettings(nil)),所以应用路径上隧道内的 DoH(§3.2)在这里不适用。
在这条路径上替代加密的是存储纪律:代理数据路径在客户端可驱动的路径上不记录任何内容,并由一个回归测试钉住 — proxy/socks5_nolog_test.go 里的 TestClientDrivenTrafficNeverLogs 断言在格式错误、超大和无法拨号的各种情形下日志行数为零。有两条诚实的注意事项:这个测试自述的动机是日志放大式 DoS 而不是隐私,而且那个 panic 恢复处理器确实会记录一个客户端地址(proxy/socks5_server.go:129-132)。见 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:816-830) — 那是一个逐窗口槽位的标识符,只有运营方能把它解析为一个账户;而它永远看不到 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:4098,4220),而测量是运营方做的,不是提供者自报的,所以这一点是有界的 — 但它是一项排名输入,不是一道完整性控制。 - 在它自己的视野内关联各条流。按站点固定把一个站点钉在一个提供者上(
connect/ip_remote_multi_client.go:837),所以对于它所持有的那些站点,单个提供者看到的是一个客户端浏览活动里连贯的一片。
没有帮助它做不到什么。在中继路径上得知你的真实 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:136,而服务器名被从流键中清除(:366,386,424),计数器以 (version, protocol, port) 为键,IP 字段从未被启用(connect/ip_security.go:371)。
它是给诚实提供者用的安全控制,不是对付敌意提供者的安全控制。它约束的是一个提供者的连接会向外承载什么。对于一个提供者拿它确实承载的流量做了什么,它什么也管不了;它跑在一个由提供者拥有的进程里,而一个被修改过的构建可以把它关掉。不要把它读作对一个恶意提供者的限制。
有一处上报细节属于这里,因为它是一条元数据通道。一次 BitTorrent 特征命中会返回 Incident,后者调用 ReportAbuse(connect/ip.go:9014,9110),向运营方发送一个 PeerAudit 控制帧,其中携带对端的设备 id 和一个布尔值 — 没有目的地、没有域名、没有内容(connect/transfer.go:2971)。运营方今天没有针对该消息类型的处理器,所以收到时什么都不会被存储(server/controller/connect_controller.go:132,436-437;在 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:7105 默认安装),发往 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:774),而 53 端口被安全策略未加检查地放行(connect/ip_security_cfaa.go:113)。在那条路径上,DNS 查询以普通 DNS 的形式穿过提供者,而提供者可以读取并应答它们。
那四个 DoH 解析器是第三方,它们看到的是从某个提供者出网地址到来的查询,与你的账户没有关联。那是一项真实的依赖,而且它不可能被配置为零:DNS 总得有人来应答。
4. 对手:一个网络观察者
四个位置,从最弱到最强。
你的本地网络和 ISP。它们看到你经 TLS/QUIC 连接到 connect.bringyour.com,或者连接到一个扩展器,外加大小和时序。它们看不到隧道内部的目的地,除了上面那个有记载的启动期 DNS 窗口。在平台被封锁的地方,扩展器以可信端口上的普通服务形态出现,而客户端会轮换伪装身份,配合随机化的分片(connect/net_extender.go:123-133;构建于 connect/net_extender_network.go:915),另有一种 DNS 形态的传输,供只有 DNS 能出去的网络使用(connect/transport_pt.go:37,73)。这些是用于穿过本地和区域防火墙的机制,排在直连传输之下(connect/transport.go:29-33)。它们不是流量分析防御,也绝不应被如此描述。
一位扩展器运营者。任何人都能运行一个,应用接受手动输入的 IP,而且默认不要求任何签名(connect/extender/extender.go:35-44)。扩展器是那个 TCP 对端,所以它看得到连接用户的 IP;而它无法解密自己所转发的内容,因为那条 TLS 会话是穿过它通到平台的,而不是终止在它身上(connect/net_extender.go:34-48)。因此扩展器这一段在路径上放进了一个观察你地址的第一跳观察者,而这个人不是运营方。那是这套设计为了带你穿过封锁所做的交换,而把它的大小说准是值得的:扩展器这一段不增加任何加密,也不增加任何匿名性 — 它得知的是你的 ISP 早已知道的东西,仅此而已,只有下面这一个例外。在发布的客户端里,直连传输会先被竞速,而扩展器拨号器在那些失败之后才展开(connect/net_http.go:1686-1709);一个配置了自定义扩展器的客户端会把它们当作唯一的路线(connect/net_http.go:1686-1709)。
例外是提供者角色。每个客户端都会探测扩展器,按往返时间给它们排名。设备只在共享连接期间才为它测得的结果出具证明:你打开共享时证明开始,关掉共享时证明停止,哪怕一轮探测正进行到一半也是如此;从不共享连接的设备则始终匿名探测(sdk/device_local_provider.go:1155-1167;connect/net_extender_network.go:1240)。在共享期间,每一份声明都携带它的客户端 id,并用它的客户端密钥签名(connect/DESIGNNOTES4.md §1、§3)。扩展器由此得知:带那个 id 的提供者 — 因而也就是那把公钥(§9.2)的持有者 — 从那个地址测量了它;运营方收到的是提供者 id、扩展器、往返时间和测量时刻。扩展器之间也以同样的方式相互测量,被测目标会为它认可的每一次往返时间联合签名,而运营方把这些 ping 保留一天,用来推导位置(connect/GEOMAP.md §2)。这是关于公共基础设施的运行数据,所用的密钥在提供者角色里本来就是持久且公开的。
一位只观察一端的观察者。只盯着客户端一侧,得到的是"这个用户连接到了 URnetwork"以及一份字节/时序画像。只盯着某个提供者的出网点,得到的是目的地和一份字节/时序画像,上面没有附着任何身份。
一位同时观察两端的观察者。见 §8。这就是 URnetwork 击败不了的那个对手。
5. 对手:运营方
运营方为客户端做身份验证、挑选并排名提供者、编写合同、分发公钥,并在中继路径上承载数据包。本节假定它怀有敌意或受到强制。
它不需要任何额外努力就看到什么:你的账户;你连接那一刻的地址;由那个地址经一次本地数据库查询推导的城市/地区/国家 — server/ip.go 中的 ipDb 从磁盘打开内置的 MaxMind GeoLite2-City 数据库(mmdb/geolite2.mmdb),而 ip.go 不发起任何形式的网络调用,所以地址从不会被交给任何地理定位服务 — 并按连接持久化(server/model/network_client_location_model.go 中的 SetConnectionLocation);哪些提供者服务过你;字节数;以及在标准路径上,隧道内部的目的地址和数据包字节 — 尽管那些通常已经在目的地自己的 HTTPS 里面了。
它存储什么。连接和认证记录保存的是外围 /29(IPv4)或 /56(IPv6)地址块的带密钥单向哈希,而不是那个地址(server/ip.go:46-72)。一处对审计者要紧的精确说明:那是一个来自保险库、进程级、只记忆化一次的 pepper,没有逐行的盐,而且整个仓库里任何地方都没有轮换机制(server/ip.go:42-43);IPv4 的键空间是 2^29 个地址块,所以任何持有该 pepper 的人都可以用暴力枚举把它反解出来。这个 pepper 的保密性就是全部的安全性质。/verify 子系统对 IPv6 用的是 /48,不是 /56(server/ip.go:74-89;server/model/verify_model.go:113-116)。来源端口以明文形式与哈希并排存储(server/db_migrations.go:2044)。账户创建的审计行记录的是加了 pepper 的哈希和端口,绝不是原始的 ip:port(server/model/network_model.go:1157),而审计行在 180 天后被移除(server/model/audit_model.go:1018,1052,在 server/taskworker/taskworker.go:85-88 排期)。
什么东西根本不存在,所以无从存储。传输账本里任何地方都不出现目的地、主机、URL、SNI、端口或域名 — 对整份迁移文件就那些词做一次 grep 返回零个命中(review/verified/PRIVACY-ENFORCEMENT.md §1.2)。HTTP 日志放行的是一份恰好五个请求头的白名单(server/http_log.go:13-29),由测试钉住。没有任何 Prometheus 指标携带 IP、主机、目的地、端口或客户端 id;在 39 个已声明的指标里只有三个带标签,而且每个标签都是一个有界的枚举(review/verified/PRIVACY-ENFORCEMENT.md §2.5)。支持标签页上传的日志被排空到 io.Discard(server/controller/log_file_controller.go:76);只有元数据留存。
5.1 这一切各保留多久,以及去向哪里
留存是由 server/taskworker 里按排期运行的清扫来强制执行的,不是由政策。下面的窗口读自那些清扫所调用的常量,而它们比隐私政策 — 后者根本没有写出任何留存期 — 会让读者预期的要短。
| 数据 | 保留时长 | 强制执行位置 |
|---|---|---|
| 连接行,以及挂在其上的逐连接城市/地区/国家、延迟和速度 | 8 小时 | taskworker/work/network_client_work.go:91;删除在同一条语句里级联到 network_client_location(model/network_client_model.go:2580) |
| 一个顶层客户端闲置(无认证、无连接) → 标记为停用 | 30 天 | TopLevelClientIdleExpiration(model/network_client_model.go:2576) |
| 一个已停用的客户端 → 硬删除,并带级联 | +30 天 | NetworkClientReapAfterDeactivate(:2556) |
| 因此一台不再使用的设备,端到端 | 约 60 天 | 前两者串联 |
| 已完成的合同 | 7 天 | taskworker/work/subscription_work.go:21-43 |
| 审计行 | 180 天 | model/audit_model.go:1018,1052 |
有两条说明,是审计者应当掌握的。闲置窗口已于 2026-07-18 从 90 天收紧到 30 天;那个常量带着它自己的理由说明,而 taskworker/work/network_client_work.go:83-91 处一条过时的注释仍然写着 90 — 如果你在别处遇到那个数字,这条注释就是它最可能的来源。另外,RemoveLocationLookupResults 每个周期都被排期,但它是一个空操作(no-op) — 它的函数体和它的模型调用都被注释掉了,而且那张表根本不存在。它是一个残留的桩,不是未被清扫的数据;逐连接的位置会随上面的连接行一起被删除。
地理定位从不离开这台机器。城市/地区/国家查询读取的是本地磁盘上内置的 MaxMind GeoLite2-City 数据库 mmdb/geolite2.mmdb(server/ip.go 中的 ipDb),而 ip.go 里不含任何 HTTP 客户端,也不发起任何网络调用。运营方用 MaxMind 的 geoipupdate 客户端刷新这个文件,这是一次下载,不会向 MaxMind 发送任何关于任何用户的信息。This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. GeoLite2 包含 GeoNames 的数据,依 CC BY 4.0 许可提供。没有任何地址被发送给地理定位服务,也没有任何个人信息与任何第三方共享。本文各方表格里仅有的那些第三方,持有的都是用户因选择那条路径而亲手交给它们的数据:用户自己的 DNS 查询经隧道抵达的那些 DoH 解析器,以及用户为接受计费而注册的那些支付处理商。
入口负载均衡器不记录客户端地址,而这一点可以核查。负载均衡器的 nginx 配置由 warp 生成,而 warp 以 Apache 2.0 许可开源(warp/LICENSE),所以这是任何人都能读的源代码,而不是一句关于部署的断言。分两层:
- 访问日志使用一种专门设计、省略地址的格式 —
log_format noclientaddr携带时间、连接、主机、请求、状态、字节数、耗时、上游和 user-agent,而配置里的注释点名了刻意缺席的东西:$remote_addr,还有$http_x_forwarded_for和$http_referer,"两者都可能携带从别处转发来的客户端地址"(warp/warpctl/config.go:1243-1254)。 - 错误日志无法设定格式,所以 nginx 自带的
, client:字段改为在下游移除:负载均衡器运行 nginx 时,把它的 stderr 包在一个ClientAddrScrubber里(warp/nginx.go:98-103),后者先用正则表达式剥掉那个字段,再把条目中任何位置上剩下的每一个 IPv4 或 IPv6 字面量都替换成[scrubbed],保留端口(warp/nginx.go:111,116,176-232)。没有任何地址范围被豁免,所以不存在一条"哪些地址属于用户"的规则可以出错。
一个回归测试把它钉住:只要有任何一条 access_log 没有指明格式,TestNginxLogsOmitClientAddr(warp/warpctl/config_test.go:1208)就会失败,因为 nginx 内置的后备格式 "combined" 以 $remote_addr 打头。
服务容器是在描述符上做脱敏,而不是在调用点上。warp 以 --log-driver=journald 运行服务容器(warp/warpctl/run.go:200),并且不过滤它们的输出,所以曾有一段时间,服务写出的任何带地址的行都会原样到达 journald。这样的调用点真实存在,而且不止一处:server/http.go:413,453 在构造 http.Server 时没有设置 ErrorLog,所以每一次半开连接,Go 标准库都会写出 http: TLS handshake error from — 而这正是 proxy/http.go:199,273 用 ErrorLog: discardLog 堵上的那个窟窿;客户端提供的 SNI 被以 ERROR 级别记录(server/tls.go:111);一个原始的调用方地址在 server/proxy/proxy_device.go:858;WireGuard 对端身份在 server/proxy/server.go:699。
那些调用点已不再是边界,因为边界已不在调用点上。每个服务都在 main 的第一条语句处用管道替换掉自己的 stdout 和 stderr 文件描述符,并对写入其中的一切做脱敏(server.ScrubProcessLogs,server/scrub.go:62,由全部八个服务调用 — server/cli/{api,connect,alt,proxy,taskworker,gossip,monitor,mcp}/main.go)。在描述符而不是写入器上操作,意味着它覆盖 log、glog、net/http 默认的 ErrorLog、任何依赖,以及以后新增的任何调用点,而不需要任何人去把它们找出来。它使用的是负载均衡器为 nginx 所用的同一个 warp.ScrubAddrs(warp/nginx.go:207),所以脱敏器只有一份实现,而不是两份可能渐行渐远的实现。
有两处刻意设定的局限,审计者都应当自己掂量,而不是凭信任接受:
- 崩溃转储不经脱敏直接通过。一行
panic:、fatal error:、signal SIG或goroutine会把脱敏器锁存为直通模式,直到该进程的生命周期结束(scrubPassthroughMarkers,server/scrub.go:48)。转储很少见,而一旦发生,它就是日志里最有价值的东西,所以它被完整保留 — 代价是接受一个栈帧或寄存器可能携带地址。锁存触发时会写出一条可见的通知,所以一个停止了脱敏的进程,不是运营方需要去推断的事情。 - 凡是能被解析为地址的,它都会脱敏。能被这样解析的版本字符串和以冒号分隔的标识符也会被抹去。最显眼的后果是,从日志行里抓取
ip:port的server/monitor/tailer.go,现在看到的是[scrubbed]:443。
所以,"服务不会把你的地址写进它的日志"如今是进程的一项性质,而不是它各个调用点的性质。这并不能确立的,是 journald 随后如何处置它收到的内容;§12 把这一点记为悬而未决。关于原先那些调用点的完整细节:review/verified/PRIVACY-ENFORCEMENT.md §2.4。
运营方所控制的、容易被忽略的东西。提供者发现完全在运营方一侧:FindProviders2 是唯一在线的端点(server/api/api.go:140),平台为候选者打分并排名(server/model/network_client_location_model.go:5056),而客户端接受它被给到的那个排好序的集合。消费者一侧唯一的控制是一份位置封锁清单(server/api/api.go:149)。客户端没有任何办法核实被提供给它的那些提供者彼此独立、或者独立于运营方。
6. 运营方自运行的提供者,以及运营方与提供者的合谋
6.1 运营方能运行提供者吗?
能,而且系统里没有任何东西标记或阻止这件事。提供对任何人开放,不需要任何证明或质押,而且代码里任何地方都不存在第一方、官方或运营方自有的提供者概念 — 在 server、connect 和 sdk 中搜索这样一个概念什么也返回不了。一个运营方自运行的提供者,与一个成员的提供者无法区分。
结合 §5 那一点,即运营方为提供者集合排名并把它返回,这意味着运营方在原则上可以把它自己的提供者放进一位用户的窗口里。不存在客户端一侧的多样性检查、不存在关于独立性的证明,也不存在任何外部方对这支队伍的公开测量。确实存在的制衡是真实的,但只是部分的:窗口同时持有若干个提供者 — 质量窗口 2–6 个(硬上限 12),速度窗口 1–2 个(硬上限 4),两者都活在默认配置里(connect/ip_remote_multi_client.go:160-189) — 而且提供者会因统计不健康、黑洞检测、ping 失败和逐通道的生命周期轮换而被移除(:37-54、:610-616)。那些限定了任何单个提供者能看到多少。它们并不限定一个由同一方挑选出来的提供者集合。
6.2 运营方和提供者合在一起能重建出什么,按模式分
假定运营方和你窗口里的一个或多个提供者共享它们各自持有的东西。
| 中继标准 | 中继密封 | 直连 | |
|---|---|---|---|
| 你的身份(账户、邮箱/钱包/支付) | 运营方 | 运营方 | 运营方 |
| 你连接时的真实 IP | 运营方 | 运营方 | 运营方,以及直接得知的提供者 |
| 你访问过的目的地 | 运营方(内层数据包)以及提供者 | 仅提供者 | 提供者 |
| 两者之间的联结 | 微不足道 — 一方本来就两者都持有 | 那份合同:audit_contract_event 按合同把客户端与提供者身份配成对(server/db_migrations.go:325-347) | 微不足道 |
| 结果 | 对那些合谋提供者所承载的一切实现完整的浏览归属 | 对那些合谋提供者所承载的一切实现完整的浏览归属 | 完整的浏览归属,外加提供者不靠别人就知道你的地址 |
诚实的结论:密封会话不防御运营方与提供者的合谋。它移除的是运营方独立地读取你流量的能力;它并不能阻止一个本来就看得到你目的地的提供者,把那些目的地交给一个本来就知道你是谁的运营方。那份把两者接起来的合同记录不是泄露 — 它是这个网络赖以运转的记账。
密封针对这个对手确实买到的,是一个范围上的限制:在它开启时,运营方自己的视野里不含任何目的地,所以合谋需要那些在承载那些具体流量的时刻承载了它们的具体提供者的配合,而不是对运营方独自持有的记录做一次查询。那在所需的力气上、以及在被强制的披露能够回溯到什么这一点上,是一个有意义的差别(§10)。它不是免疫,而且任何由其中一方挑选其他方的三方安排,都不提供免疫。
这套设计在结构上的答案,而它并未部署。线路协议和客户端支持串接更多的提供者中间节点 — MaxMultihopLength = 8(connect/connect.go:15-18)、CreateContract 上的 IntermediaryIds(connect/protocol/transfer.proto:547-561)、客户端在 connect/ip_remote_multi_client_api.go:661-662 的接受逻辑、服务器在 server/controller/connect_controller.go:915 的管路。而线上的发现端点从不填充那个字段 — FindProvidersProvider 恰好在两处被构造,而两处都没有设置它(server/model/network_client_location_model.go:5044,5093;该字段在 :3693 声明,而两处都没有给它赋值) — 所以已发布的网络分配的是长度为一的链条。提供者串接是协议的一项能力,不是这次部署的一项性质,而拿数字 8 当跳数来引用会造成误导。
7. 提供者身份密钥,以及运营方能不能替换一把
这是最值得攻击的一节,因为答案让人不舒服,而源代码自己就这么说。
7.1 签发与绑定
每一个 connect.Client — 一个提供者客户端,以及用户的每一个窗口客户端 — 都持有一对由 ClientKeyManager 在进程内生成的 Ed25519 密钥对(connect/transfer_key.go:87-143,三棵树中唯一的 ed25519.GenerateKey 调用)。在有种子被持久化并重新载入的地方它是长期的,那就是提供者客户端;在没有种子的地方它是每进程新生成的,那就是窗口客户端;§9 讲这两者的差别以及为什么它要紧。私钥那一半从不离开进程;一个尺寸错误的种子会导致一次硬性的构造错误,而不是静默地生成一个新身份(:84-94)。公钥那一半以一条 ClientKey 控制消息发布给平台(connect/protocol/transfer.proto:684-700),并在服务器一侧存进 Redis 的 ckey_,没有过期时间 — Redis 就是事实来源,不存在 SQL 表(server/model/network_client_key_model.go:19-65)。
因此绑定关系是 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:1193-1199)。当客户端 id 被回收时该条目会被删除(network_client_key_model.go:67-78,从 RemoveDisconnectedNetworkClients 调用)。没有定期轮换、没有过期、也没有吊销列表。一个提供者客户端会持久化它的密钥材料并重新载入,所以它的身份跨重启是稳定的(sdk/device_local.go:3592-3597;sdk/local_state.go:788) — 而且在 Android 上,还刻意在一次自动登出清理中保持稳定(§9.2)。一个没有持久化种子的客户端会在每次进程启动时生成一个新身份,而用户的窗口客户端就是这样。会话中途的密钥变更会被拒绝:SetPeerClientPublicKey 是首次写入者胜出,而一把后来出现的不同密钥会被记录并忽略(connect/transfer_encrypt.go:2051-2096)。
7.3 验证,以及那两道防御
- 防御 1,签名的证书绑定。提供者用它的 Ed25519 密钥签署自己临时的 TLS 证书链,并把两者一起发布(
connect/protocol/transfer.proto:494-522)。平台把这条链、那个签名和提供者的公钥附加到每一份点名该提供者的合同上(:340-368、:388-406)。客户端只有在签名能用提供者的公钥验证通过时才接纳这条链,随后再把握手中出示的证书与已接纳的链做核对(connect/transfer.go:9560-9583,核对位于:11928)。 - 防御 2,握手内的身份证明。在 TLS 握手之后,双方各自在标签
urnetwork-sequence-identity-proof之下签署 RFC 5705 的导出器输出,并把它作为一条EncryptedControl发出(connect/protocol/transfer.proto:615-660,701-712)。在对端的证明验证通过之前,AEAD 不会交给封装路径(connect/transfer_encrypt.go:1719-1726、:1719-1756),所以一个在一段上终止 TLS、又在另一段上重新握手的中间人会产生不匹配的导出器,并且无法伪造那个签名。
两道防御都是对照 peerClientPublicKey 验证的 — 而那个值取自平台编写的合同(connect/transfer.go:9560-9583;connect/transfer_encrypt.go:2051-2096)。
7.4 运营方能替换一把提供者密钥吗?要签名、可归责、难得多 — 但并非不可能。
2026-09-17 更新。本节此前写的是"今天:能。"那已不再是完整的答案,而这项变更是本文历史上仅次于失效关闭修复的第二重要的加固。
这种攻击,形态未变。一个把证书、证书签名和 destination_client_public_key 三者同步替换掉的运营方,能击败 §7.3 中的两道防御,因为两者都是对照同一份合同所提供的那把密钥来验证的。设计笔记用仓库自己的话写明了这一点(connect/DESIGNNOTES.md §3.7)。
如今挡在路上的是什么。在后量子加密开启的情况下(EncryptionModeRequired),客户端会扣住会话 cipher,直到合同提供的那把身份密钥与运营方已发布的一份签名的注册历史相互印证为止;而一次经过验证的不一致,对那个提供者来说是终局性的 — 不会有任何流量流向它,它也会被从窗口中剔除。
每一条注册记录(connect/transfer_key_history.go:200-213)都携带部署域、一个单调递增的代数、身份密钥、一个指向其前驱内容哈希的链接,以及一个由运营方根签名者签出的 secp256k1 签名。客户端会检查规范编码、签名恢复、从第 1 代起的链连续性、域与签名者权限,以及相对于一个钉住的链头只能向前推进(VerifyClientKeyHistory,:422)。闸门位于 Cipher() 中(connect/transfer_encrypt.go:2699-2716),决策表位于 connect/transfer_key_history_session.go:157。服务器一侧:server/model/st_client_key_history.go 存储这条签名链,server/controller/st_client_key_history_public.go:77 对外提供它,路由位于 server/api/api.go:226。
所以,运营方再也不能既前后不一、又可以抵赖。要实施替换,它必须以签名的形式对一条链作出承诺,而这件产物是永久的、可归责的,并且与该客户端 id 的其他每一位读者所看到的内容相分歧 — 其中包括已经在审计这份日志的那些独立验证者。那道更早的、未签名的、对照 GET /key/ 的交叉核对仍在一旁运行,而且仍然只是建议性的:它在 connect/transfer_encrypt.go:2131-2134 处记录 CONTRACT vs FETCHED ... MISMATCH ... (today: log only)。
有四处精确说明,是审计者应当抓住不放的。
- 有签名,不等于签名者自己伪造不了。一个从你第一次接触起就怀有恶意、并且始终自洽的运营方,会签署一条自洽的链,上面写着它自己的密钥。验证这条链检查的是链本身,而不是链背后的权威;协议自己就这么说 — "The caller must separately authenticate the expected signer in the operator's pinned chain version"(
sn/protocol/client_key_history.go:166-167)。客户端的应对是构建时钉住的签名者,加上首次使用即信任(connect/transfer_key_history.go:491),这能抓住一个后来变得恶意、只针对一部分用户,或者制造分叉的运营方 — 但抓不住一个从来就不诚实的运营方。要封堵这一点,需要跨多个独立运营的运营方进行多方读取,而这一点已被搁置(connect/DESIGNNOTES3.md§10)。 - 省略仍然是更便宜的一招,而约束它的是那个棘轮。当受信任集合为空时,证书验证会被跳过且不锁存(
connect/transfer.go:11915-11926);而当对端的密钥缺席时,身份证明根本无法被验证(connect/transfer_encrypt.go:1947-1950)。扣住签名历史,是高一个层级的同一招。它受一个层级棘轮约束:一个曾经对照签名证据验证过的对端,此后再也不会在没有签名证据的情况下被接受;而一旦运营方向某次安装提供过签名证据,一个没有签名证据的对端就会被拒绝(connect/transfer_key_history_session.go:168-185,持久化于sdk/peer_client_key_pin_store.go)。一次抓取出错被刻意不当作拒绝 — 否则一次 API 故障就会同时为每一个用户排除掉每一个提供者 — 所以一个弄坏自己端点的运营方,会让客户端降级到使用合同里的密钥。那是一笔被接受的可用性交换,也是一处真实的残余风险。 - 这一切都以那个开关为限。强制执行只在
EncryptionModeRequired之下运行(connect/transfer_key_history_session.go:91),因为在 Opportunistic 之下拒绝会降级到明文,而那正是一个实施替换的运营方想要的。由于这个开关发布时仍然是关闭的(§2.2),一个采用默认配置的客户端这些一样也得不到。 - 合同的真实性同样扎根在运营方那里。提供者用一把由提供者自己生成并发布给平台的提供密钥来验证合同的 HMAC(
server/controller/connect_controller.go:727,840;sdk/device_local.go:3592-3597)。运营方持有一份副本,而那正是它能够编写合同的原因。那对一个记账权威来说是意料之中的,但它意味着"这份合同是真的"并不是一句独立于运营方的陈述。
这削弱了什么、没削弱什么。它不触及提供者对身份的盲视,后者不依赖任何密钥分发。在密封路径上,运营方对内容的盲视已不再只是一项光秃秃的政策性质:如今,替换会让运营方付出一件签名的、永久的、可被第三方发现的产物作为代价。但它仍然不是一项能在面对一个从来就不诚实、且下定决心的运营方时依然成立的性质,而任何把密封会话描述为让运营方盲视成为无条件的 URnetwork 文档,都是在夸大它。本文取代那样的措辞。
8. 时序与流量关联,以及全局被动观察者
8.1 不存在任何流量分析防御。一个都没有。
URnetwork 对用户数据不提供掩护流量、不提供填充、不做混淆、不做批处理延迟、也不做流量整形。这一点在 connect、sdk 和 server 上被穷尽检查过:
- 任何地方都不存在干扰包、诱饵、哑包或掩护流量生成器。
- AEAD 在构造上是保长的:密文长度等于 nonce + 明文 + 标签(
connect/transfer_encrypt.go:303-336)。connect/protocol/里没有任何 protobuf 携带填充字段,而分帧是一个光秃秃的 4 字节长度前缀(connect/message_framer.go:29-31)。 - 发送侧的合并被明确设定为零延迟:"There is no batching wait: the sequence only takes a second Pack when it is already queued"(
connect/transfer.go:2971)。这几棵树里每一处jitter都是重连退避或通道生命周期的错开,不是流量整形。ACK 压缩(connect/transfer.go:272)只延迟确认,目的是削减中继的消息量。 - 用户流量唯一被限速的地方,是 DNS 形态传输的
WritePacketsPerSecond上限(connect/transport_pt.go:68,359-361),它的存在是为了对解析器友好,而且只适用于最不被优先选用的那种传输模式。它那些空闲的"泵"查询与承载数据的查询长度可区分,所以它既不掩盖大小,也不掩盖速率。
因此:一个既观察进入你设备的流量、又观察离开承载它的那些提供者的流量的对手,可以靠大小和时序把两端关联起来。URnetwork 不防御那个对手。这与 Tor 关于端到端关联所做的陈述是同一句话,而且它在这里适用时余量更小,因为 URnetwork 的设计目标是低延迟 — 而那恰恰是让关联更容易的那项性质。一条延迟受限的四段路径,是相对于混合网络所增加的延迟而言的一次刻意交换,而这就是那次交换中由用户来付的那一面。把扩展器那一段也数进去并不改变这一点:那一跳把加密会话原样转发给运营方而不终止它,所以它既没有为观察者添上一层可剥的东西,也没有添上任何能把观察者甩掉的延迟。
有两样东西有时会被误认为是防御,而它们不是。扩展器和那些形态化传输(connect/net_resilient.go:114-215、connect/transport_pt.go:37,73)针对的是基于 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:160-189,837)。那确实限制了任何单个出口能看到多少。但这个窗口在源代码里的设计理由自始至终都是可靠性 — 坏目的地的缓解、按健康度加权的伸缩、黑洞检测(: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:816-830;由提供者在 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;经 AuthNetworkClient 在 connect/ip_remote_multi_client_api.go:691-727 铸造,经 RemoveNetworkClient 在 :802-832 释放)。在此之上,窗口成员还在不断翻新:提供者会因统计不健康、黑洞检测、ping 失败和逐通道的生命周期轮换而离开(connect/ip_remote_multi_client.go:37-54),所以一个槽位 id 的短命是由构造决定的,而不是由政策决定的。
你的流量同时分散在若干个提供者上,所以没有哪一个出口能看到这个客户端的全貌。默认配置运行一个由六个提供者组成的质量窗口,以及一个由一到两个提供者组成的速度窗口(connect/ip_remote_multi_client.go:160-189)。每个提供者以它自己的槽位 id 持有你活动的一片,而按站点固定让某个给定站点停留在一个提供者上,而不是把它摊到所有提供者身上(:837)。这确实限定了单个诚实但好奇的出口所能重建的东西。它不是一种抗关联防御,§8.2 说明了原因。
窗口 id 与你的提供者角色 id 是相互分离的身份空间,所以提供者无法把一个槽位 id 反推回你。如果你同时也共享自己的连接,你的提供者客户端就是那个持有持久化密钥种子的顶层客户端(§9.2)。窗口客户端是另外的客户端:它们由一份新的 DefaultClientSettingsWithBufferSize 构建(sdk/device_local.go:4349),其中不携带 ClientKeySeed,所以每一个都在每个进程里生成自己的密钥对;而你自己的客户端 id 被明确排除在你自己的候选集合之外(sdk/device_local.go:4338)。因此,一个持有某个槽位 id、并经免认证的 GET /key/ 去查询它的提供者,拿到的是一把对那个进程来说全新的密钥,它不连向任何持久的东西 — 不连向你的提供者身份,不跨重启,也不跨槽位。§9.2 中那把持久的、可公开解析的密钥属于提供者角色,而出网路径上没有任何东西暴露它。
设备自己的客户端 id 在出网路径上不会到达提供者,而且这条追踪短到足以核查。DeviceLocal.sendPacket 确实会设置 source := connect.SourceId(self.clientId) — 设备的顶层 id — 并把它交给多客户端层(sdk/device_local.go:4870,批量形式在 :4968)。那个值只用于本地记账:作为出网分片重组的键,以及用于 provideMode 关系检查(connect/ip_remote_multi_client.go:5876-5906)。线路上的发送由逐槽位的窗口客户端完成 — self.client.SendMultiHopWithTimeoutDetailed(frame, self.args.Destination, …)(connect/ip_remote_multi_client.go:14941-14948),发生在生成器为那个槽位铸造的一个 Client 上(:13094) — 而设备的来源 id 不在它的参数之中。在那段发送代码范围内 grep source,什么也返回不了。
唯一持久的身份是提供者角色,而它指向的是另一个方向。一台共享自己连接的设备会运行第二个客户端,它在 sdk/device_local_provider.go:198 构建,带有 ProviderStreamPolicy = true(:191)和那份持久化的密钥种子。网络对端关系和提供者一侧的返回流量搭乘的是那个客户端,所以它们携带稳定的顶层 client_id 和那把持久的密钥。那是一台设备提供服务时所用的身份,而不是它浏览时所用的身份:它暴露给一个提供者所服务的那些消费者,§9.2 对此有完整记录;而它从不被交给承载一个消费者自身流量的那些出口。这两个身份空间就是上面描述的那两个,它们互不交叉。
但那些身份被刻意复用最长达四小时,而且在每一条路径上都是如此 — 不只是托管的那些。除非设备是托管的,否则它会安装自己的本地身份存储(sdk/device_local.go:824-830),而一个被存下来的窗口身份会对着同一个目的地被重放,直到它过期:windowIdentitiesStaleAfter = 4 * time.Hour(sdk/window_identity_store.go:42-45)。这么做的目的是让某个提供者的 NAT 流在一次重启之后仍然活着(connect/ip_remote_multi_client_identity.go:17-23)。代价是一个提供者可以在那个时间窗内,看到同一个 SourceId 跨应用重启从你这里再度出现。要说四小时,不要说"临时的"。
客户端的 Ed25519 身份密钥在出网路径上是每个窗口客户端新生成的。窗口客户端的设置是从一份新的 DefaultClientSettingsWithBufferSize 构建的(sdk/device_local.go:7105),它不携带 ClientKeySeed(connect/transfer.go:1547-1558),所以 ClientKeyManager 会生成一对新的密钥对(connect/transfer_key.go:113)。被持久化的窗口快照只存客户端 id、jwt 和实例 id — 没有密钥种子(sdk/window_identity_store.go:42-45),所以一个被恢复的身份会重新发布一把新密钥。在后量子加密关闭的情况下,根本不会出示任何身份证明(connect/ip_remote_multi_client.go:13080-13091)。
隧道的来源地址是逐会话的,而且熵很低。SDK 给它的 TUN 分配一个随机的 RFC1918 地址 — "一个随机的 10.x.y.h(RFC1918、DHCP 形态)"(sdk/device_local.go:7105) — 主机字节在 2..254 之间生成,取自最小的不冲突 10.a.b.0/24(connect/tun.go:517-523)。它在构造函数里算出来,从不持久化,所以每次会话都会变。提供者确实看得到它:它是每一个被隧道封装的数据包的来源字段,而提供者以它为键维护 NAT 状态(connect/ip.go:731,784)。大约 253 个取值是一个很弱的逐会话指纹,不是一个跨会话的标识符。
9.2 提供者角色的身份密钥是持久的
如果你同时也共享自己的连接,那么你的提供者客户端会持有一对长期的 Ed25519 密钥对,其种子被持久化到本地存储(sdk/local_state.go:788,.device_local_key_material,JSON 里的 client_key_seed;在 sdk/device_local_key_material.go:32,90 应用)。与窗口客户端不同,那把密钥被刻意在一次自动登出清理中保留下来 — 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 中的 clearStaleAuthState)。因此令牌刷新、部分认证恢复和 30 天闲置后的重新登录,会产生一个新的 client_id 以及一个新的 device_id,而那把 Ed25519 公钥保持不变。只有用户显式登出才会轮换它(sdk/local_state.go:788)。
那把密钥被发布出去,被封进每一份把该客户端列为目的地的合同(server/controller/connect_controller.go:744-770,816-830),而且任何人都读得到:GET /key/ 按设计是免认证的(server/api/api.go:222-223;server/controller/connect_controller.go:1227)。所以任何一方 — 不只是你服务过的某个提供者 — 都能把任何一个客户端 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:74)。一个顶层客户端在闲置 30 天后被停用(TopLevelClientIdleExpiration,:2576),并在那之后 30 天被硬删除(NetworkClientReapAfterDeactivate,:2556),并级联到设备行和 Redis 的ckey(:2537)。device_id是逐登录的,不是逐硬件或逐安装:每一个顶层客户端都会铸造一个新的(:332-352),而代码指出由此产生的"身份翻新(每次登录一个新的 device_id)"(:2280)。窗口客户端继承它(:356-380),而它从不被发给提供者。- 网络令牌是一个 24 小时的 JWT(
server/jwt/by_jwt.go:55-67,238),由 SDK 在半衰期时刷新 — 大约 12 小时 — 并带抖动(sdk/device_token_manager.go:172-217)。刷新令牌并不会轮换客户端 id;同一份声明会被重新铸造(server/model/network_client_model.go:74)。 audit_contract_event按合同记录客户端和提供者的身份(server/db_migrations.go:325-347)。client_reliability把那个带密钥的 IP 块哈希按块直接连到一个客户端 id 上:最初的四列主键(server/db_migrations.go:325-347)后来被缩减为(block_number, client_address_hash, client_id)(:2191-2195),而线上的分区表用的是同样这三列(server/model/network_client_reliability_partition_model.go:194),其中network_id作为索引载荷保留下来(:63,631-634)。留存期是 30 天(server/model/network_client_reliability_partition_model.go:52-69;按日分区整个丢弃)。network_client.auth_time是一个持久的最后可见时间,保留 30 天(server/model/network_client_model.go:2556,2576);已断开的连接行在 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:13-29)。它是那五个里唯一一个构成指纹面的条目。
9.4 WireGuard 桥:隧道地址在出网之前经过 NAT 转换
每一个 WireGuard 客户端都从一个被打乱的、包含 10,000,000 个 RFC1918 地址的池子里分到一个稳定的私有隧道地址(server/model/network_client_proxy_model.go:774),写进配置文件作为接口地址(:756-771)。这个地址是持久的:它是该对端配置的一部分,挺过每一次会话。
它不会到达提供者。托管设备在出网时把它替换成一个逐设备的地址,并在返回时恢复该对端自己的地址。这个替换地址在设备构造时一次性取得,来自应用路径的 tun 所用的同一个 169.254.0.0/16 池(server/proxy/proxy_device.go:954,经由 connect.TakeLocalIpv4Address,connect/tun.go:471),并在关闭时归还,所以它是逐设备的而不是逐账户的;而且它取自与其他每一个本地隧道地址相同的空间,并不显眼。改写由 natRewriteEgress(server/proxy/proxy_device.go:1297)和 natRewriteReturn(:1314)完成,建立在 connect.RewriteIpv4Source/RewriteIpv4Destination(connect/ip_nat_rewrite.go:71,77)之上,后两者以增量方式修正 IPv4 首部校验和,以及任何伪首部传输层校验和。返回的数据包按替换地址来匹配,因为提供者正是把它们发往那个地址的(receiveWithNotifyNat,server/proxy/proxy_device.go:1339)。
有三处局限值得说出来,因为这移除的是一种关联,而不是其他关联。只有已挂接对端自己的流量会被改写,所以一台同时提供 HTTP 或 SOCKS 服务的设备,不会动那些流。如果本地地址池耗尽,改写会把自己禁用,而不是丢弃流量,这意味着旧的行为仍然是失败模式 — 这是一个可用性上的选择,也是这项性质之所以是"通常成立"而不是"不可能失效"的原因。而且,在那台设备存续期间,同一个窗口里的每一个提供者仍然看到同一个替换地址,就像应用路径的 tun 地址在一次会话的各个提供者之间共享一样(§9.1);被移除的是那个跨会话、随账户持久存在的把手,而不是会话内的关联。
这一行为由 TestWgNatReplacesThePeerAddressOnEgress、TestWgNatRoundTripIsExact、TestWgNatLeavesOtherSourcesAlone 和 TestWgNatReturnIsMatchedOnTheNatAddress(server/proxy/proxy_device_nat_test.go)钉住,而校验和的算术由 connect/ip_nat_rewrite_test.go 中的十二个测试钉住,它们对照完整的重新计算来验证,而不是照搬那套增量运算。
再加上那个固定的 1.1.1.1 解析器(§3.2)以及那条路径上密封会话的缺席(§2.4),WireGuard 桥仍然是使用这个网络最不私密的方式。应用和 SDK 是最私密的。
10. 法律要求
管辖权。合同相对方是 BringYour, Inc.,一家特拉华州 C 类公司(docs/legal/terms.md:8-9 写明了该实体;其注册成立地在那里并未载明),通知地址为 2261 Market Street #5245, San Francisco, CA 94114(docs/legal/terms.md:265-271)。准据法是德克萨斯州法律,管辖地是德克萨斯州哈里斯县(docs/legal/terms.md:314-321) — 这是有意的选择,与那个加利福尼亚地址并不矛盾:公司的法律顾问在德克萨斯州。读者会碰到的唯一缺口是,已发布的条款既没有写明注册成立的州,也没有写明注册代理人,所以送达程序所需的公司事实无法仅凭这些条款确立。这一切都在美国,而这一切都可被美国的强制程序触及,包括带着保密令到来的程序。条款声明运营方"可在法律或政府命令要求的情况下监控并披露信息"(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)。它移除 network_user 行及其认证记录(密码、SSO、钱包、恢复短语)、network 行和名称索引条目,并排期一次 Stripe 退订。它不删除 account_payment、stripe_customer、apple_subscription_transaction、已完成的 solana_payment_intent 行、network_referral、设备行或审计行;那些按各自的时间表自行老化 — 审计事件在 180 天(server/model/audit_model.go:1018,1052) — 或者,对已完成的链上支付而言,根本不老化(server/model/solana_payment_intent_model.go:96,137 只删除 tx_signature 为空的意图)。删除还会写入一行:一条以被删除的 network_id 为键的 AuditEventTypeNetworkDeleted 事件(account_model.go:108)。隐私政策里那句"删除他们的账户及相关个人信息"(docs/legal/privacy.md:69)比代码实际做的更宽。
不存在搜查令预警、不存在透明度报告,也不存在公开的执法处理流程。已在 docs/ 和 ur.io 站点源码中核实为缺席。根本不存在任何法律程序地址:[email protected] 的范围限于漏洞报告(docs/legal/vdp.md:42),而 [email protected] 是一般性的合同通知地址(docs/legal/terms.md:265),两者都不是送达程序的渠道。一方若想向公司送达,在已发布的条款里除了那个旧金山的通知地址之外,没有任何可以据以行事的东西。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 桥上的会话内关联。对端那个持久的隧道地址在出网之前经过 NAT 转换,所以它不再跨会话地跟着一个账户走 — 但同一个窗口里的每一个提供者仍然看到同一个替换地址,就像它们在应用路径上看到同一个 tun 地址一样。而且,如果本地地址池耗尽,改写会把自己禁用,而不是丢弃流量,所以 NAT 之前的行为仍然是失败模式。§9.4。
- 对任何同时也在提供的人而言的一个持久身份把手。提供者客户端的 Ed25519 密钥按设计能挺过客户端 id 和设备 id 的轮换,而
GET /key/是免认证的,所以任何一方都能把共享一把密钥的那些客户端 id 聚成一簇。§9.2。 - 本地网络在 DNS 启动窗口期间所看到的流量。源代码里把它记载为一项被接受的代价。§3.2。
- 一次第三方审计本会发现的任何东西。对协议或服务器代码,从来没有做过这样的审计。
12. 本文无法确立的东西
一项在威胁模型里未被确立的主张,也是一项不该出现在文档里的主张。以下这些都还开着。
- 主机上 journald 的留存与转发。服务日志如今在离开进程之前就已在描述符上完成脱敏(§5),所以到达 journald 的内容,除了崩溃转储之外,应当不携带任何地址。这次源码复核无法确立的是:journald 把它收到的东西保留多久、转发到哪里,以及任何崩溃转储 — 按设计它不经脱敏直接通过 — 是否比其余内容保留得更久。那是一个部署问题,不是一个代码问题。
- 浏览器路径上的名称解析是否有可能在本地发生。扩展默认配置的是一个 HTTPS CONNECT 代理,它在远端解析,而 SOCKS 的名称解析经由隧道的 DoH 拨号器(
connect/tun.go:517-523)。各浏览器在每一种配置下的代理 DNS 行为没有被测试过。 - 生产环境实际的日志详细程度。
BY_LOG_V默认为 0(server/env.go:46),这会抑制客户端和服务器上由V(1)门控的目的地日志 — 但树里唯一那份部署清单把它设成了2(xops/gitops-unused/.../api/deployment.yaml:47-48),而它所在的目录名叫gitops-unused。请把日志详细程度当作一个运行时开关,绝不要当作一项结构性保证。 - 队伍的独立性。运营方从它自己对当前已连接、有效提供者的聚合中发布实时的城市和国家数量(
server/model/network_client_location_model.go:3525),而位置是由运营方从它观察到的连接推导出来的,不是由提供者申报的(review/verified/ARCHITECTURE.md§6.4)。但没有任何独立方从外部测量过这支队伍,也没有任何东西核实提供给某一位特定客户端的那些提供者彼此独立、或者独立于运营方。这个机制在源代码里是可核查的;而这个群体在今天不是第三方可核查的。 - 账户创建时的身份密钥绑定。设计中陈述的期望是"(ClientId,公钥)绑定在账户创建时注册"(
connect/transfer_key.go:60-85)。而实际发布出来的,是一个客户端把它的密钥发布到由运营方控制的 Redis,再由一个由运营方控制的 API 提供回来。是否在运营层面存在一种更强的注册,无法从源码确立;请假定不存在。 Roles和Principal是否曾为普通消费者客户端填充过。它们是由运营方赋值的字符串,被封进已签名的合同字节里,而且只为ProvideMode_Network设置(connect/protocol/transfer.proto:547-561;server/controller/connect_controller.go:831-838)。没有找到任何证据表明那些应用会填充它们,而且不是每一个调用方都被审计过。如果它们曾经为消费者流量填充过,它们就会是提供者可见的一等跨会话标识符。- 非移动主机上的密钥材料持久化。
ClientKeySeed只从sdk/local_state.go、sdk/device_local_key_material.go、sdk/device_local.go和sdk/cgo/被引用。不调用那些的主机每个进程都会拿到一把新密钥;Linux、Windows、扩展和服务器代理各自的行为没有被逐一核实。
13. 报告
漏洞:[email protected],以及位于 ur.io/vdp 的披露政策。对本文的更正,包括对其中任何结论的不同意见,同样欢迎经由这条路径提出 — 一项与这里某条主张相矛盾的独立发现,比那条主张本身更有用。