威胁模型
假设:公共 Relay 已被攻击者完全控制;网络可被劫持;攻击者可以枚举短码、重放任何看到的帧。
| 威胁 | 缓解 | 验证 |
|---|---|---|
| Relay 读取用户数据 | 应用层 Noise XX E2EE;Relay 只见 forward.payload 密文 | relay_never_sees_plaintext 集成测试:中继端截获全部帧,断言不含明文与静态公钥 |
| Relay 伪造已信任设备 | 静态身份公钥在握手内加密传输并由 Noise 认证;TrustedDevice 记录公钥;重连比对 | identity_change_is_flagged 回归测试 |
| MITM(含 Relay 替换握手) | XX 模式双向认证;扫码时票据钉住电脑的临时公钥(替换第一条握手消息即失败),用验证码时用户核对 Safety Code(来自 handshake_hash,攻击者无法让两端一致) | mitm_yields_different_safety_codes、crates/voltip-pairing/tests/flow.rs@regression_ticket_bound_to_wrong_ephemeral_fails_handshake |
Relay 或看到验证码的人冒充手机配对(扫码配对电脑不需确认,docs/pairing.md「扫码配对只在手机上确认」) | 电脑只在手机证明读过二维码时自行确认:证明是二维码里 32 字节 secret 对 handshake_hash 的 HMAC,secret 从不经过 Relay;证明不对、重放或不该出现时配对失败;用验证码加入时电脑仍要它的用户确认 | crates/voltip-pairing/tests/flow.rs@a_proof_under_another_secret_fails_the_pairing、a_replayed_proof_fails_the_pairing、without_a_proof_both_users_confirm、crates/voltip-crypto/src/proof.rs@the_proof_is_hmac_sha256_over_the_label_and_the_hash |
| 短码暴力枚举 | Relay 限流:连接 5 次 / IP 20 次·分钟 / 会话 10 次;TTL 120 s | brute_force_locks_session、ip_rate_limit |
| 重放票据 / 短码 / 会话 | 一次性 session_id;join 成功即删除短码;票据 nonce 记录;过期即销毁 | replayed_ticket_rejected、expired_session_rejected |
| 长期私钥泄露 | 只存平台安全存储(keyring / Keystore);不写配置、不写 DB、不出设备;日志脱敏 | secret_store_never_serialises_private_key、代码审计:grep -r private 无明文序列化 |
| 客户端内嵌高权限 secret | 客户端只有 Relay URL(非 secret);签名/发布凭据只在 GitHub Secrets | CI 配置审查 |
| 协议降级 | version 强制校验;不支持则断开 | unsupported_version_disconnects |
| DoS 单连接刷会话 | 每连接最多 1 个活动会话;create_session 每 IP 每分钟 10 次 | session_create_rate_limit |
中继在反向代理后面,按 IP 的限制变成全站共用一份:一个客户端用光所有人这一分钟的配对与 attach;或者客户端伪造 X-Forwarded-For 绕过限制 | 只读 --trust-proxy 列出的对端发来的 X-Forwarded-For,从右往左跳过可信代理,取第一个其他地址作为客户端(与 nginx 的 real_ip_recursive on 相同);其他对端发来的这个头一律忽略;默认不信任任何代理 | crates/voltip-relay/tests/socket.rs@regression_clients_behind_a_trusted_proxy_have_their_own_limits、crates/voltip-relay/tests/socket.rs@a_forwarded_address_counts_only_from_a_trusted_proxy、crates/voltip-relay/src/forwarded.rs@a_peer_that_is_not_trusted_is_its_own_client_whatever_it_forwards |
| 无声断开的连接占住频道(channel_full),配对设备一直连不上 | 中继每 10 s ping 每条连接,25 s 没有任何消息就关闭它并通知对方;客户端在 channel_full 时每 4 s 重新 attach(docs/pairing.md「重连」)。0.1.0 起 App 不再在局域网上监听配对与会合端口 | regression_a_connection_gone_silent_gives_up_its_place_on_the_channel、regression_a_path_that_breaks_without_a_word_heals_on_its_own | | 本机语音服务被其他用户、网页或局域网滥用(docs/dictation.md §23) | 默认只监听回环地址,voltip-server 监听其他地址必须加 --allow-remote(并警告明文),App 只监听 127.0.0.1;除 /healthz 外每个请求都要 32 字节随机令牌(0600 文件,常量时间比较,可随时更换),没有关闭认证的选项;不返回 CORS 头,网页读不到响应;并发与排队有上限,时长、请求体和字段有界限,原始上传不落盘;日志不记文字、音频和令牌;无头服务端只读钥匙串与 App 的文件;没有钥匙串时可以从 VOLTIP_KEY_* 环境变量读取密钥(只在 voltip-server,文档建议 0600 的 EnvironmentFile;同一用户的进程能读到服务的环境变量,这一点写在文档里),--check 和日志只给变量名 | the_token_guards_everything_but_the_health_check、malformed_and_oversized_requests_are_refused、a_waiting_request_reads_nothing_until_it_has_a_permit_and_a_full_queue_is_refused(voltip-serve);the_command_line_names_the_service_and_its_one_shot_actions、print_token_creates_the_token_once_and_prints_it_again(voltip-server);peek 的零写入测试(voltip-identity);keys_from_the_environment_win_over_the_keychain_and_are_named_never_printed(voltip-server) | | 代理看到或改动 Voltip 的流量(docs/dictation.md §24) | 代理只转发:https:// 的请求(内置服务、服务商的默认地址、更新源、反馈服务)仍是端到端的 TLS(证书照常校验),中继里的内容还有 Noise E2EE,代理能看到的是要连接的主机和流量的大小与时间。用户自填的 http:// 自定义端点(远程地址也允许)经过代理时是明文,代理能读到并改动录音、文字和密钥,与没有代理时网络路径上的风险相同;隐私页提醒改用 https:// 或把它列入不使用代理的地址;本机地址(本地模型服务、本机语音服务)从不发给代理;手动代理的密码只在钥匙串,settings.json、界面、状态、日志里都没有;日志与错误不记代理的地址和用户名密码;socks5h 让代理解析域名,socks5 在本机解析 | a_stream_tunnels_through_an_http_proxy_with_its_credentials、a_local_stream_goes_direct_and_a_refused_tunnel_names_no_host、a_client_follows_the_plan_from_one_request_to_the_next(voltip-net);the_relay_connects_through_the_saved_proxy_with_the_keychain_password(voltip-core);no proxy applies at once; a manual proxy is checked, saved tidy, its password kept out of view(桌面) | | AI 工具接入(本机 MCP 服务,docs/dictation.md §25)被其他程序、网页或历史里的提示注入滥用 | 默认关闭,只监听 127.0.0.1,没有监听其他地址的选项;除 /healthz 外每个请求都要 32 字节随机令牌(0600 文件,与本机语音服务的令牌分开,常量时间比较,可随时更换,换后立即生效);Host 只认 127.0.0.1:{port} / localhost:{port}(防 DNS rebinding),带 Origin 的请求一律拒绝,不返回 CORS 头,网页读不到响应;读取分三级(仅词典和规则 / 替换汇总(缺省)/ 完整历史),完整历史要用户在确认框里显式允许并限定时间范围,权限在每次调用时检查、收回立即生效;修改缺省只能写词典,外部工具新建的规则在用户启用前不执行(历史文本是不可信数据,可能带提示注入,规则会改写插入到任何应用里的文字),没有删除工具,每次写入都记在活动记录里并可撤销;最多 16 个会话、每会话每秒 10 次调用、单次结果 256 KiB;日志不记请求和结果里的文字;令牌与接入命令由核心放进剪贴板,不经过 webview | crates/voltip-mcp/tests/http.rs@requests_without_the_token_or_from_a_web_page_are_refused、crates/voltip-mcp/tests/http.rs@the_levels_of_the_moment_decide_each_call、crates/voltip-mcp/tests/http.rs@a_clients_rule_waits_and_its_writes_can_be_undone、crates/voltip-mcp/tests/http.rs@sessions_and_calls_are_limited、crates/voltip-mcp/tests/http.rs@a_new_token_and_switching_off_take_effect_at_once、crates/voltip-mcp/tests/http.rs@the_logs_hold_no_text;crates/voltip-core/tests/mcp.rs@reads_follow_the_read_level_of_the_moment、crates/voltip-core/tests/mcp.rs@a_tools_rule_waits_for_approval_and_runs_once_approved | | 内置服务的每日额度被绕过,或用量把用户关联起来(docs/dictation.md §27) | 设备号是首次使用时生成的随机 UUID(usage-id),与配对密钥无关,不进备份、不同步,只发给内置服务;内置服务仍要应用令牌,按 IP 的限流照旧;额度是没有账号时的公平使用额度,不是安全边界(重装或伪造请求头能拿到新的额度,旧版本按 IP 计数);额度用完只影响内置服务,其他服务商和本机模型照常可用;网关的统计只给每台设备 / 每个 IP 一个数,不含设备号和 IP;用量记录不含录音和文字 | crates/voltip-cloud/src/lib.rs@only_the_built_in_service_is_told_who_asks、crates/voltip-core/src/usage/mod.rs@the_device_id_is_made_once_and_read_without_writing、apps/server/tests/cli.rs@the_server_meters_as_the_apps_device_or_as_itself_and_never_writes_the_apps_directory | | 中继的统计泄露谁在和谁连接(docs/pairing.md「中继统计」) | GET /stats 只在中继的回环监听地址上,反向代理只转发 /ws;只有按小时的计数,不含 IP、频道、会话号和验证码;版本只按已发布版本的名单分桶,其余记为 other,每小时的桶数有上限 | crates/voltip-relay/src/core.rs@the_statistics_count_what_happened_and_name_no_device、crates/voltip-relay/src/stats.rs@regression_versions_are_named_only_from_the_policy_or_up_to_the_hours_slots | | 管理台被冒用:调高最低版本把所有客户端挡在外面,或改动额度(docs/runbook.md「管理台与同步」) | 管理台(私有仓库 sunerpy/voltip-admin)只在 Cloudflare Access 之后;Functions 用团队域的公钥验证每个请求的 Cf-Access-Jwt-Assertion(签名、aud、exp),失败一律 403;同步接口只认 Access 的 service token;最低版本只能选已发布的版本,并发修改按 revision 回 409,每次修改记审计(邮箱、前后内容、时间),可以恢复上一个设置;中继和网关主机上的本地覆盖文件优先于同步来的配置,坏的配置不会替换上一份有效配置 | 私有仓库的 site/ 测试(Access JWT 用测试密钥对、409、已发布版本校验、同步令牌);crates/voltip-relay/src/policy.rs@the_override_wins_while_valid_and_a_broken_main_file_keeps_the_last_valid_policy | | fork 或其他客户端使用托管中继和内置服务(docs/pairing.md「设备票据」) | 官方构建带每个版本一把的构建密钥(CI 从主密钥派生、拆分编入,产物扫描不许整把出现),用它向 voltip-auth 证明版本,换到 1 小时的设备票据;中继在连接内验票(门禁下未认证的连接不能配对、attach 和转发,门禁打开时已有的未认证连接当场撤销,票据到期的连接被关闭),内置服务网关验 X-Voltip-Ticket;票据只发给构建自带的中继和内置服务;设备可在管理台撤销,最多 1 小时失效;每个 IP 每天的新设备登记有上限;门禁要和最低版本一起打开 | crates/voltip-relay/src/core.rs@regression_turning_the_gate_on_stops_unauthenticated_connections_forwarding、crates/voltip-relay/tests/socket.rs@regression_a_full_queue_does_not_keep_a_cut_off_connection、crates/voltip-transport/tests/link.rs@regression_a_relay_without_a_ticket_source_gets_no_ticket_and_neither_does_the_probe、crates/voltip-core/tests/e2e.rs@regression_a_relay_the_user_entered_never_gets_the_device_ticket、crates/voltip-cloud/src/lib.rs@only_the_built_in_service_is_told_who_asks、scripts/check-build-key.sh | | 转发洪泛与连接耗尽(docs/pairing.md「中继限额」) | 每条连接每分钟的转发字节和帧数、每个地址和全部的连接数都有上限(默认不限,先统计分布再设);超过时 rate_limited 并关闭连接 | crates/voltip-relay/src/core.rs@forwarding_over_a_minute_limit_closes_the_connection、connection_limits_refuse_new_connections |
已知缺口
扫码配对时二维码就是凭据:有效期内(120 秒,常开配对时每个会话都是)看得到它的人(截图、拍照、共享屏幕)不经电脑确认就能配对,之后能在电脑的光标处输入文字。电脑的配对卡片写明这一点并提醒不要截图或共享;配对后设备出现在列表里,可以随时忘记(用户 2026-10-27 接受的取舍)。
构建密钥能从某个版本的安装包里提取出来(它必须在客户端里):拿到它的人能伪造这个版本的设备证明。影响面限于这个版本,每个 IP 每天的登记有上限;发现后在管理台停用这个版本,或调高最低版本。没有账号和平台认证(Play Integrity / App Attest,用户 2026-11-03 决定暂不做),挡不住有心逆向的人;目标是让改个地址的 fork 用不了。
中继持有票据密钥:拿到中继主机就能伪造票据。中继和
voltip-auth在同一台主机上,攻击面没有因此变大。转发和连接的上限默认不限,统计一周后才设数值;在那之前
forward洪泛仍只受连接数和各自网络的限制。频道标签可被知道两把公钥的人计算出来(公钥不是秘密),攻击者可占位造成
channel_full;缓解:attach 需要对频道所有者的签名挑战(下一版协议)。桌面端 keyring 在 Linux 依赖 Secret Service(没有守护进程、钥匙串被锁或解锁提示被关掉时
StoreUnavailable):应用不降级到内存存储或明文文件,启动时显示说明框,关闭后以退出码 1 退出(docs/dictation.md§34)。macOS 钥匙串和 Windows 凭据管理器不可用时同样如此。本机语音服务的令牌对同一用户的所有进程可读(这就是给其他程序用的):能读到这个文件的程序就能使用服务,包括它背后的内置服务额度。
--allow-remote时令牌以明文 HTTP 传输,应改用 SSH 隧道或 TLS 反向代理。内置服务的每日计数放在网关的 njs 共享内存里,网关容器重启后当天的计数清零,这一天可以多用一些(
docs/dictation.md§27.9)。AI 工具接入的令牌同样对同一用户的所有进程可读,复制时会经过剪贴板(其他程序可能读剪贴板);持有令牌的程序能在授权范围内读写。AI 工具读到的内容会发给它所用的模型服务,这一点由完整历史的确认框说明,Voltip 无法约束。