Security安全
A key that can join a machine to your tailnet deserves a straight answer about what the app protects and what it does not. This page is that answer, including the parts that are limitations rather than features.一把能把机器加入你 tailnet 的密钥,理应得到一个直白的回答:这个 App 保护了什么, 没保护什么。本页就是这个回答,也包括那些属于局限而非功能的部分。
Please do not open a public issue. Use GitHub's private vulnerability reporting on tailnet-byok → Security → Report a vulnerability, or open a public issue containing only "security report — please contact me" and no technical detail.请不要开公开的 issue。请使用 GitHub 的私密漏洞报告功能: tailnet-byok → Security → Report a vulnerability; 或者开一个公开 issue,里面只写 "security report — please contact me",不要写任何技术细节。
What the encryption actually buys加密实际买到了什么#
The auth key is encrypted with a randomly generated AES-256-GCM key. That key is created inside the Android Keystore and is non-exportable: the app holds a handle, not the bytes. On a device with a Trusted Execution Environment or StrongBox, the key material never enters the app's address space at all.认证密钥用一个随机生成的 AES-256-GCM 密钥加密。那把密钥在 Android Keystore 内部生成, 且不可导出:App 拿到的是一个句柄,而不是密钥字节。在带有可信执行环境或 StrongBox 的设备上, 密钥材料根本不会进入 App 的地址空间。
| Threat威胁 | Protected?是否受保护? | By what靠什么 |
|---|---|---|
| Someone copies the app's data directory — rooted file copy, forensic image, stray backup有人拷走了 App 的数据目录 —— root 后的文件拷贝、取证镜像、 无意留下的备份 | Yes是 | Only ciphertext is on disk, and the decrypting key is hardware-bound磁盘上只有密文,而解密密钥与硬件绑定 |
| The ciphertext reaches a cloud backup密文进了云备份 | Yes是 | allowBackup="false" plus backup rules that exclude every domainallowBackup="false",加上排除所有域的备份规则 |
| An attacker reads it from logcat攻击者从 logcat 读到它 | Yes是 | One logging entry point, scrubbed; release builds strip v/d/i;
CI fails the build on a bare android.util.Log只有一个日志入口,且做了脱敏;release 构建会剥离 v/d/i;
CI 只要看到裸的 android.util.Log 就让构建失败 |
| The ciphertext is decrypted while the phone is locked在手机锁屏状态下密文被解密 | Yes是 | setUnlockedDeviceRequired(true) on API 28+API 28+ 上的 setUnlockedDeviceRequired(true) |
| The app is used to reach arbitrary internet hosts有人用这个 App 去访问任意互联网主机 | Yes是 | Address policy refuses anything outside the tailnet ranges, before the dial地址策略在发起连接之前就拒绝 tailnet 网段之外的一切 |
| An attacker runs code as this app, on your unlocked device攻击者在你的解锁设备上以本 App 的身份运行代码 | No否 | Nothing can. The app must be able to read the key to use it.谁也挡不住。App 要使用密钥,就必须能读到它。 |
| Someone takes a memory dump of the live process有人对运行中的进程做了内存转储 | No否 | See the heap caveat below见下面的堆内存保留 |
| Your control plane is malicious你的控制面是恶意的 | No否 | Inherent to the protocol, not to this app这是协议本身的问题,不是这个 App 的问题 |
The heap caveat, stated plainly直说堆内存这一点#
Kotlin strings are immutable and cannot be zeroed, and the Go bridge takes a string. So the plaintext auth key exists in the process heap for the window between decryption and the node coming up. The mitigations are real but partial:Kotlin 的字符串不可变、无法清零,而 Go 桥接层接收的是字符串。所以在解密之后、节点起来之前 这段时间里,明文认证密钥存在于进程堆中。缓解措施是真实的,但只是部分有效:
- it is never persisted in plaintext, so it does not survive a process restart;它从不以明文持久化,所以进程重启之后就不复存在;
- it is never logged, so it does not reach logcat or a bug report;它从不写进日志,所以不会到达 logcat 或问题报告;
- it is dropped from the UI state as soon as it is saved, so it is not held in Compose state;一旦保存就立刻从 UI 状态里丢弃,所以它不会被留在 Compose 状态中;
TailnetCredentials.toString()is overridden to printpresent/absent, so an accidentalprintlnor a debugger pretty-print cannot leak it.TailnetCredentials.toString()被重写为只打印present/absent,所以一次不小心的println或 调试器的美化打印都泄露不了它。
What none of that fixes: an attacker who can dump the heap of a running, unlocked process can recover the key. This is a limitation of every Android app that holds a credential, including the official clients.以上这些都没能修好的是:能对运行中且已解锁的进程做堆转储的攻击者,就能恢复出密钥。 这是每一个持有凭据的 Android App 都有的局限,官方客户端也不例外。
Software-backed Keystore软件实现的 Keystore#
Many devices — and every emulator — have no TEE. There, the Keystore key is software-protected rather than hardware-isolated. The app tells you which situation you are in on the settings screen rather than implying the stronger guarantee, because a security indicator that is always green is not an indicator.很多设备 —— 以及每一个模拟器 —— 都没有 TEE。在那些设备上,Keystore 密钥是软件保护而非 硬件隔离的。App 会在设置页告诉你处在哪种情况,而不是暗示那个更强的保证, 因为永远显示绿色的安全指示器根本算不上指示器。
The address policy is a security control地址策略是一道安全控制#
TailnetAddressPolicy is easy to mistake for input validation. It is not. It
is the boundary that stops a typo, a pasted public hostname, or a malicious shared
configuration from turning this app into a general-purpose request forwarder that reaches
arbitrary internet hosts through the user's node.TailnetAddressPolicy 很容易被误认为是输入校验。它不是。它是一道边界,用来阻止一个手误、
一个粘贴进来的公网主机名,或一份恶意的分享配置,把这个 App 变成通过用户节点访问任意互联网主机的
通用请求转发器。
Allowed:允许:
100.64.0.0/10— Tailscale's IPv4 range100.64.0.0/10—— Tailscale 的 IPv4 网段fd7a:115c:a1e0::/48— Tailscale's IPv6 ULA prefixfd7a:115c:a1e0::/48—— Tailscale 的 IPv6 ULA 前缀- named hosts, with a warning when the name is not a
*.ts.netFQDN具名主机;当名字不是*.ts.net这样的 FQDN 时会给出警告
Refused, with a reason the user can act on:拒绝,并给出用户可以据以行动的原因:
- any IPv4 or IPv6 literal outside those ranges, including
127.0.0.1;上述网段之外的任何 IPv4 或 IPv6 字面地址,包括127.0.0.1; - a pasted URL (the scheme belongs in a separate field);粘贴进来的 URL(协议应填在单独的字段里);
- malformed input, including out-of-range ports.格式错误的输入,包括超出范围的端口。
The check runs in ConnectionTester before the provider is asked to dial, and
TargetAddress — the type the providers accept — can only be constructed with
a validated host and port. It is not possible to reach a dial without passing through the
policy, which is the property that makes it a control rather than a hint.这项检查在 ConnectionTester 里、在要求 provider 发起连接之前执行;而 provider 接受的
类型 TargetAddress 只能用一个校验过的主机和端口构造。不经过这道策略就不可能发起
连接,正是这个性质让它成为一道控制,而不是一句提示。
Why cleartext HTTP is permitted为什么允许明文 HTTP#
Traffic to a 100.x address is encrypted by WireGuard from the app to the
destination node. There is no point on the path — including the Wi-Fi network the phone
is attached to — where a passive observer sees the plaintext.发往 100.x 地址的流量,从 App 到目标节点之间由 WireGuard 加密。这条路径上没有任何
一点 —— 包括手机所连的 Wi-Fi 网络 —— 会让被动观察者看到明文。
Requiring HTTPS on top of that would push users toward self-signed certificates and "install this CA" flows, which is a strictly worse security story. Most self-hosted services on a tailnet speak plain HTTP behind the tunnel.在此基础上再强制 HTTPS,只会把用户推向自签名证书和「安装这个 CA」的流程,那在安全上是更差的 故事。tailnet 上大多数自建服务在隧道后面说的就是明文 HTTP。
So the network security config permits cleartext only for tailnet destinations and refuses it by default everywhere else. Point the app at a public hostname and it must use HTTPS.所以网络安全配置只对 tailnet 目标放行明文,其它地方一律默认拒绝。 把 App 指向一个公网主机名,它就必须使用 HTTPS。
Permissions, exhaustively权限,逐一列出#
| Permission权限 | Why原因 |
|---|---|
INTERNET |
To reach the control plane and the target.为了访问控制面和目标。 |
ACCESS_NETWORK_STATE |
So "no underlying network" can be reported as a distinct, actionable error. It reads no payload.这样「没有底层网络」才能被报告为一个独立、可采取行动的错误。它不读取任何载荷。 |
POST_NOTIFICATIONS |
Android 13+, for the two notifications this app can show: the silent row that says it is waiting for a running task while the DSH screen is closed, and the "task finished" notice — the only one that may pop up, and the only one that carries anything (the session's title, and nothing of the conversation). Asked for when the DSH screen opens; declining costs the notice and nothing else.仅 Android 13+,用于本 App 可能显示的两条通知:「DSH 界面」关闭后仍在等待任务跑完的那条静音行,以及「任务完成」通知 —— 后者是唯一会弹横幅、也是唯一带内容的(只带会话标题,不带任何对话内容)。它在「DSH 界面」打开时询问, 拒绝的代价只是收不到这条通知。 |
REQUEST_INSTALL_PACKAGES |
So the in-app updater can hand a downloaded, hash-verified build to the system installer. It cannot install anything silently: the installer always shows the package and asks.为了让 App 内更新能把一个已下载、已校验哈希的安装包交给系统安装器。它无法静默安装: 安装器总会显示包名并询问。 |
CAMERA |
Requested at runtime by the scanner screen, and only to turn one QR code into text: frames are decoded inside the app, no picture is stored or sent, and declining costs nothing — the same screen reads a code from a picture you pick, which needs no permission.只由扫码页在运行时申请,且只用来把一个二维码变成文字:画面在 App 内解码,不保存也不发送任何图片; 拒绝也没有代价 —— 同一个页面可以用你选的图片识别,那条路不需要任何权限。 |
Deliberately absent, and why each one:有意不申请的权限,以及每一项的理由:
| Not requested未申请 | Reason原因 |
|---|---|
BIND_VPN_SERVICE |
The embedded node dials a single destination inside its own process. It never intercepts device-wide traffic, so it must not take the VPN slot — and taking it would break any other VPN the user runs.内嵌节点只在自己进程内连接单个目标。它从不拦截整机流量,所以它不该占用 VPN 槽位 —— 占用还会破坏用户在用的其它 VPN。 |
QUERY_ALL_PACKAGES |
Nothing here needs to enumerate other apps.这里没有任何东西需要枚举其它 App。 |
CAMERA |
Requested at runtime by the scanner screen, and only to turn one QR code into text. Declining costs nothing: the same screen reads a code from a picture you pick.只由扫码页在运行时申请,且只用来把一个二维码变成文字。拒绝也没有代价:同一个页面可以用你选的图片识别。 |
FOREGROUND_SERVICEFOREGROUND_SERVICE_DATA_SYNC |
Raised only while the DSH screen is open, and only so a task you started can be noticed finishing while the app is off screen — Android freezes a backgrounded app, which used to stop the check silently. It cannot outlive the screen, it has no binder and cannot be started by another app, and the notification it requires is on a silent low-importance channel.只在「DSH 界面」打开期间启动,目的只有一个:你启动的任务在 App 退到后台时跑完, 也能被发现——Android 会冻结后台 App,以前这让检查静默失效。它不能比页面活得更久,没有 binder,别的 App 也启动不了, 它要求的那条通知在静音、低优先级的通道上。 |
| Location, storage, contacts, phone位置、存储、联系人、电话 | None of it is used. Exports, if added, go through the Storage Access Framework, which grants exactly one file.这些一个都没用到。将来若加入导出功能,会走 Storage Access Framework,它只授权一个文件。 |
There is no IPC surface不存在 IPC 攻击面#
The app declares exactly one exported component: its launcher activity. No exported service, receiver or content provider exists, so there is nothing for another app to bind to, query, or send an intent to. That is a deliberate structural choice — the cheapest way to secure an attack surface is to not have one.App 只声明了一个导出组件:它的启动 Activity。不存在任何导出的 service、receiver 或 content provider,所以别的 App 没有任何东西可以绑定、查询或发送 intent。这是一个有意的结构选择 —— 保护攻击面最便宜的办法,就是根本不存在攻击面。
On not setting FLAG_SECURE关于不设置 FLAG_SECURE#
Screenshots are permitted. The reasoning: the connection-test output is the main thing a
user needs to share when asking for help, and the only secret on screen is behind a
password-masked field that requires an explicit tap to reveal. If you fork this and
disagree, it is a one-line change in app/src/main/res/values/themes.xml.允许截图。理由是:用户求助时最需要分享的就是连接测试的输出,而屏幕上唯一的秘密藏在一个
密码掩码字段后面,必须显式点击才会显示。如果你 fork 之后不同意这个决定,改动只有一行,
位于 app/src/main/res/values/themes.xml。
Revocation is your job吊销是你自己的事#
Deleting the key from the device does not revoke it at the control plane. If the key was copied anywhere before you deleted it, it still works. Revocation happens in your tailnet's admin console, and only there.从设备上删除密钥并不等于在控制面上吊销它。如果这个密钥在你删除之前被复制到过别处, 它依然有效。吊销只发生在你 tailnet 的管理控制台里,也只在那一处。
Forget key and reset node does two things: it deletes the stored ciphertext and destroys the Keystore key, which makes any pre-existing copy of the ciphertext permanently unreadable. It also clears the node's state directory, because node state carries the identity the old key produced — leaving it behind is exactly what makes a freshly pasted key appear to be ignored.忘记密钥并重置节点做两件事:删除已存的密文,并且销毁 Keystore 密钥, 这会让密文此前存在的任何副本永久无法读取。它还会清空节点的状态目录,因为节点状态承载着旧密钥 所产生的身份 —— 把它留在那里,正是让新粘贴的密钥看起来被忽略的原因。
Hardening checklist加固清单#
Before you put a key on a device:在把密钥放进设备之前:
- Use a tagged, single-use, short-expiry pre-auth key, and write an ACL that grants the resulting node access to only the service you intend. The app cannot enforce this for you; your ACLs can.使用带标签、一次性、短有效期的预授权密钥,并写好 ACL,只让产生的节点访问你打算开放的 服务。App 无法替你强制这一点;你的 ACL 可以。
- Prefer a
100.xaddress when testing, so a failure means the network rather than the resolver.测试时优先用100.x地址,这样失败意味着网络问题,而不是解析器问题。 - If the device is not one you fully trust, do not store a key on it.如果这台设备不是你完全信任的,就不要在上面存密钥。
- Delete the key in your admin console when you stop using the device.不再使用这台设备时,去管理控制台里删除这个密钥。
Rules for contributors贡献者规则#
- No test, fixture, log line, screenshot, issue or commit message may contain a real
auth key. Use obvious dummies such as
tskey-auth-EXAMPLE…. CI enforces this on tracked files.任何测试、夹具、日志行、截图、issue 或提交信息里都不得出现真实的认证密钥。请使用一眼可辨的 假值,例如tskey-auth-EXAMPLE…。CI 会对纳入版本控制的文件强制执行这一点。 - Security-relevant changes must add or update a note on this page in the same pull request.与安全相关的改动,必须在同一个 pull request 里新增或更新本页的说明。
- Never introduce a new outbound destination. If a change requires one, it needs a
documented justification in
PRIVACY.mdand explicit reviewer sign-off.绝不引入新的出站目标。如果某项改动确实需要,就必须在PRIVACY.md里有书面理由, 并得到审阅者的明确签字。 - New logging goes through
SafeLog. A bareandroid.util.Logcall is a review-blocking defect, and CI greps for it.新增日志一律走SafeLog。裸的android.util.Log调用是阻塞审阅的缺陷,CI 会 grep 它。
Out of scope不在范围内#
- A rooted or compromised device. An attacker running code as this app can read what the app can read.已 root 或被攻陷的设备。以本 App 身份运行代码的攻击者,能读到 App 能读到的一切。
- A compromised control plane. Whoever controls it can re-key your nodes. Inherent to the protocol.被攻陷的控制面。控制它的人可以给你的节点重新下密钥。这是协议本身的问题。
- Traffic analysis. The app does not hide that a Tailscale node is running.流量分析。本 App 并不隐藏有一个 Tailscale 节点正在运行。
- The security of the target service. This is a client. If the thing you point it at is insecure, it will be reached insecurely and competently.目标服务自身的安全性。这是个客户端。如果你指向的东西本身不安全,它就会被不安全地、但很称职地 访问到。
- Physical coercion. The masked field resists shoulder surfing and nothing more.人身胁迫。掩码字段只能防肩窥,仅此而已。