Termiyo

安全

保险库是怎么工作的

不含糊:下面是实际用的构造、参数,以及每一项为什么这样选。

我们在防什么

下面每一个决定都由三种情形塑造。一台存着你数据、却永远不应该能读它的同步服务器。一台被偷的笔记本,数据库文件落在别人手里。以及一个离线拿着那个文件的攻击者,用硬件允许的最快速度对它做猜测。

第三种决定了密钥派生的选择,因为只有在这一种情形里,节奏是由攻击者的预算决定的,而不是由你的密码决定的。

第二重验证,以及它到底管什么

两步验证守的是账号,不是保险库,这个区别比听上去要紧得多。被偷到或猜到的密码,将不再足以把一台新设备登录进来、把你封装好的密钥拉走 —— 那是一份加密的密钥包,而且因为打开它的密钥同样出自这个密码,里面的一切也会一并落入他人之手。这正是验证码真正挡下的攻击。

但它仍然足以在别人已经拿到的机器上打开保险库。保险库是在这里、在你自己的磁盘上加密的,中间没有任何服务器可以被问上一句 —— 所以那里也没有什么需要验证码来守。两步验证同样不会让已经登录的设备退出,也不会让忘掉的密码变得可以找回。

开启时会一次性显示十个恢复码。它们,加上任何还保持登录的机器 —— 在那上面可以用你的密码关掉这一层 —— 就是验证器丢了之后回来的两条路。没有第三条:这里没有人能挥挥手让你绕过这一层,因为我们这边根本没有能做到的东西。

Argon2id,不是快速哈希

你的主密码通过 Argon2id 被拉伸成一个 32-byte 的密钥,参数是 64 MiB 内存和三轮——用的是 libsodium 的 `crypto_pwhash`,它把 Argon2id 跑在单一通道上。内存正是关键:GPU 可以并行执行极大量的哈希运算,但它没法给每一个都分 64 MiB 的快速内存。这就把一场很宽的并行攻击重新压回成很窄的一场。

作为对照,一千次迭代的 PBKDF2-SHA256 派生——至今仍是常见默认值——对攻击者来说每次猜测不到一毫秒,而且几乎可以完美并行。在这些参数下,Argon2id 在笔记本上大约要 130 ms,而且正好抵抗那种并行。

参数与保险库一起记录,所以以后提高它们只影响新的派生,已有的保险库照样能打开。

每条记录各自封装

每个保险库都有自己的随机 32-byte 密钥,包裹在主密钥之下。记录用 XChaCha20-Poly1305 加密,每次都用一个新的 24-byte 随机数,而记录自身的身份——它的类型、它的保险库和它的 id——作为 associated data 绑进去。

最后这一点比听起来更重要。没有它,任何持有密文的人都可以把一段有效的加密数据挪到另一条记录上,而客户端会接受。有了它,一条被改了标签的记录根本打不开。

共享而不交出密钥

每个账号都有一对 X25519 密钥。共享一个保险库,会把它的密钥封装到接收者的公钥上,所以服务器可以安排谁能访问什么,却从不持有任何能打开东西的密钥。

撤销访问会删掉那份封装的副本,并停止同步。它够不到对方已经拿到的东西:已经读过数据的人就是读过了,没有系统能撤销这件事。撤销也不会更换保险库的密钥——Termiyo 做不到——所以在拥有“可编辑”期间同步过的人,手里仍有一把密钥,能打开之后存进这个保险库的一切,只要这些记录到了他手上;再次把保险库共享给他,即使是“只读”,也至少会把其中的主机发给他。要收回一个机密,靠的是在服务器上更改密码和密钥。

主机密钥,已固定

Termiyo 第一次见到一台服务器时,会把它的密钥和 SHA-256 指纹显示给你,并固定下你接受的那个。之后每次重连都会和这个固定值核对。

密钥变化时,Termiyo 会直说,而不是把它埋掉:对软件来说,一台重建过的服务器和一个被拦截的连接看起来完全一样,而只有你知道自己期待的是哪一种。

秘密实际放在哪里

连接跑在与界面分开的进程里。画出你终端的那个窗口没有网络访问权,没有文件系统访问权,也从不接收密码或私钥——它只是按名字请求连接某个已保存的主机,而那意味着什么,由另一个进程决定。

界面上显示的任何内容都先经过脱敏。带着被遮蔽字段把一条记录保存回去,不会动到已存的那个秘密。

校验你下载的东西

两种不同的检查,回答的是不同的问题。下载页面上每个下载旁边的 SHA-256 告诉你,你手里的字节就是我们构建出来的字节:在 macOS 和 Linux 上用 `shasum -a 256 <file>`,在 Windows 上用 `certutil -hashfile <file> SHA256`。

签名告诉你是谁构建的,而你的操作系统在允许运行应用之前会替你检查它。你也可以先自己检查。在 macOS 上:`spctl -a -t open --context context:primary-signature -vv <file>.dmg` 应该回答 "accepted",并带上 "source=Notarized Developer ID"。

Windows 安装程序未签名,所以“属性”中没有“数字签名”选项卡。缺少这个选项卡并不能说明文件是否被修改过。请先保存安装程序,不要运行,然后执行 `certutil -hashfile "<file>" SHA256`,将 `<file>` 替换为已保存文件的路径。在打开安装程序之前,请将结果与下载页面上同一文件旁边的 SHA-256 比较。不一致就不要运行。哈希值一致说明文件字节与我们发布的文件相同,但它不是发布者签名。跟下面的 Linux 不同,这是我们的欠账,不是平台的性质——Windows 检查签名没有任何问题,是我们还没给它可检查的东西。在你下载到的安装程序真的带上你能自己检查的签名之前,这个页面就一直这么写。

在 Linux 上没有对应的东西——.deb 或 AppImage 不带任何你系统会检查的签名,所以哈希就是你能校验的全部。这是平台的性质,不是我们的选择,而且知道它比假设相反要好。

什么会离开你的机器

有两件事,而且都列了出来。Termiyo 每 24 小时最多发送一次关于它自己的简短报告——版本、平台、处理器、安装方式、界面语言、是否开启了更新检查,以及它自己的文件夹里的崩溃转储数量——报告不带任何标识符,也不带你会话里的任何东西;它默认是开着的,点一下就能关掉,第一次运行时可以关,也可以在“设置 → 帮助”里关。崩溃会把转储写进你自己磁盘上的一个文件夹,而把其中一份发给我们是另一个开关,在你打开它之前它一直是关闭的;“设置 → 帮助”会列出它、说明一份转储可能包含什么,并打开那个文件夹。

把转储发给我们,是那个页面上的一个开关。全新安装时它是关闭的,更新之后仍然关闭,只要它是关闭的,就没有任何转储会离开这台机器——自己把它附到缺陷报告里,依然是它唯一的出路。打开它后,只有同时开启“Termiyo 启动时检查更新”,下一次崩溃才会通过 HTTPS 上传转储,连同你自己的机器给那个文件起的名字、版本号、平台、处理器架构,以及这次安装的一个标识(在第一次上传时才生成),除此之外别无他物;同一个页面会列出已经发送的内容,并提供一个按钮,请求我们的服务器把它删除。这一段以前写的是崩溃上报不只是默认关闭而是根本不存在——对加入上传地址之前的每一个版本来说,这都是真的。细节在隐私页面上。关掉更新检查时,你已同意上传的转储会先留在这台机器上,等检查重新打开后的第一次启动时再发送。

关掉更新检查后,更新模块永远不会被加载:那个库是第一次用到时才导入的,所以检查关着的时候这个模块根本不在那里,也就没有任何东西能用来发出请求。这一点是结构决定的,而不是关于某个分支的承诺。

这里我们原来说错了,现在改正:原文说关掉检查之后,启动一次不会发出任何对外请求,还把 tcpdump 当作你可以拿来验证它的办法。现在,关掉检查之后,一次没人碰的启动确实是安静的——这是在 Linux 上从网络层面测出来的——但保险库一打开,就会开始一些跟更新设置无关的通信,而这一页以前只说了其中一部分。第一件是你没关的终端:上次关闭 Termiyo 时还开着的 SSH 标签页,会在保险库一打开时重新打开,每一个都会重新完整登录一次它的主机——这就是让你的工作回到你离开时那个样子的功能,退出前关掉的标签页不会自己回来。没有登录时,在你回应账号邀请之前,「首次运行」这个界面会问 termiyo.com 是否应答,这样它才知道向你提供账号这件事值不值得做——每次启动问一次,在第一次解锁时;termiyo.com 连不上的时候不会弹出邀请,所以下次启动还会再问。已经登录时,Termiyo 会问你的邮箱地址是否已确认——每次启动问一次,还没确认的时候会再问一次,因为随后弹出来要你填验证码的那个面板会再读一遍答案——保险库打开时同步一次,之后每五分钟同步一次;打开主机、分组或代理的表单时,它会问这条记录可以存进你的哪个保险库;如果你没有自己的 AI 密钥,在你打开 AI 面板或开始在终端里输入时,它还会问一下你账户的 AI 额度还剩多少。主机列表打开时,Termiyo 能直接连到的每一台已保存的 SSH 或 telnet 主机,每 15 秒会被检查一次——连一下它的端口,一个字节都不发就关掉——好让旁边那个小圆点是准的;这些连接是发往你的服务器的,不是发给我们的,会出现在那些服务器的日志里。按名称而不是按地址保存的主机,每次检查还要多一次 DNS 查询,这个查询发给的是你这台机器所用的解析服务器。把主机列表收起来,这些检查就停了,但同类的另一个不会停:只要前台标签页是一个连着其中某台主机的终端,状态栏就会每 10 秒用同样的方式检查一次那台主机,用来显示延迟,列表开不开都一样,只要状态栏上显示的是「已连接」;按名称保存的主机,每次还会顺带问一次你的解析服务器。还有你自己设置的东西,会照你设置的去做:计划备份会复制到你指定的镜像,Webhook 会调用各自的地址,计划任务和端口转发会连接各自的主机——计划任务每运行一次连接一次,片段跑完就登出。所以这个说法恰恰错在我们自己说过唯一值得说的那种形式上,也就是可以验证的那一种。

你共享出去的终端,是这次会话存在的第二个地方,而那个地方是一个浏览器标签页。字节从你的机器直接走到他的机器,不经过任何别的东西:没有中继在搬运它们,这是个选择——一台 TURN 服务器会看见流量,所以 Termiyo 不用它,而我们自己的服务只负责把你们两个牵到一起。我们确实会用的是公共 STUN,Google 的和 Cloudflare 的,用来问你的地址从外面看起来是什么样子,因为一台在 NAT 后面的主机否则根本没法被连上;这两家知道的就是那个地址,别的什么都不知道。访客的浏览器则根本没拿到任何 STUN 服务器。接着是代价:访客是在浏览器里看的,所以扩展能读到那个页面,而浏览器可能为了标签页预览留下它的一张图。访客一开始是只读,在你放行之前不能输入;共享在你停止它的时候结束,或者四小时后结束。它不是什么,也直说:这不等于应用本身给你的那种保护——而访客页面在访客自己的屏幕上就这么说,不只是写在这里。

我们不能声称的部分:SQLite 文件本身没有加密,只有里面的记录加密了。拿到这个文件的人能知道你有多少台主机、你最后一次动每一台是什么时候,以及你给它们起的名字。它们的地址、用户名、密码和密钥是加密的。我们宁愿在这里说清楚,也不愿让你自己发现。

发现了什么?

安全报告请发到 info@termiyo.com。我们会在两个工作日内确认收到。