# AI SSH 客户端安全吗？从 Hatoba 源码看 E2EE、数据出站与命令审批

> Canonical: https://vxi.me/posts/ai-ssh-client-security-hatoba-e2ee/
> Locale: zh-cn
> Published: 2026-10-15
> Updated: 2026-10-15
> Markdown: https://vxi.me/posts/ai-ssh-client-security-hatoba-e2ee.md

让 AI SSH 助手帮你排查 Nginx 502，很容易想到它能省多少次复制日志、切换窗口的操作。麻烦在于，终端不只有错误信息：环境变量、内部地址、配置文件，甚至一行误打出来的访问令牌，都可能混在上下文里。

当助手有了读取文件和执行命令的能力，问题就不只是“它会不会给错建议”，还有两个更实际的问题：**终端内容会发给谁？哪些操作不需要你再次确认？**

以 [Hatoba](https://github.com/scarletkc/Hatoba) 为例会比较具体。这个开源 SSH 客户端把 AI 助手、Vault 和 Cloudflare Worker + D1 同步放在一起；[Show HN 原帖](https://news.ycombinator.com/item?id=50076388)也重点介绍了 E2EE。单看功能列表，容易把“同步内容已加密”理解成“AI 使用过程都在安全边界内”，但实际上至少有三条不同的数据通道。

**先看四个结论：**

- **同步加密保护 Vault 内容，不等于云端什么也看不到。** Worker 仍要处理认证凭据和记录 ID、版本号、时间戳等同步元数据。
- **只读操作也可能产生模型出站数据。** 当终端屏幕、日志或 `read_file` 结果被放进模型上下文，Cloudflare 的 E2EE 不负责保护这一跳。
- **Manual 不是所有工具都逐次审批。** 读取和修改有不同的判定路径；Auto、Bypass 还有各自的例外与风险。
- **密文认证成功，也不保证它是最新版本。** AES-GCM 的认证标签不会单独阻止旧的合法密文被重放，防回滚要另有可信的版本状态。

| 风险场景 | 实际经过的边界 | 源码所支持的结论 | 采用前的检查 |
| --- | --- | --- | --- |
| 云端 Vault 泄密 | 本机 Vault → Worker/D1 | 客户端加密业务内容；服务端仍可见部分同步元数据 | 核对密钥派生、元数据、恢复与备份 |
| 终端日志或凭据进入模型 | SSH 屏幕 / 文件 → 模型提供方 | 屏幕、附件和工具结果可能作为上下文发送，独立于云同步 | 核查屏幕附带设置、脱敏与模型接收方 |
| AI 越权执行 | AI 工具 → SSH 主机 | 普通读取和写入/执行的审批条件不同，Bypass 会放宽常规审批 | 用最小权限账号，先验证 Manual 行为 |
| 旧同步状态被重放 | Worker/D1 → 本地 Vault | 有效密文的认证不自动保证它是最新版本；上游回滚检测仍待完善 | 检查可信版本状态和恢复流程 |

以下基于 **Hatoba v0.3.0 的[固定提交 `5e9b35302`](https://github.com/scarletkc/Hatoba/tree/5e9b353020a43fbb7560dcb0abf5e071c986ee7a)** 做源码核对，并用一份独立 Python 密码学原型检验其中的通用机制。研究范围是架构和边界判断，不是 Hatoba 实机部署或渗透测试。

## E2EE 把哪些数据留给了客户端，又让 D1 看到了什么

先看密钥，不然很难判断“加密同步”到底包住了哪一层。

[Hatoba 的 `crypto.rs`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/crates/hatoba-core/src/crypto.rs) 先用 Argon2id 从主密码导出 `master_key`，再用 HKDF-SHA256 分出用途不同的密钥。源码里这两个标签值得记住：

```rust
enc_key: hkdf_sha256(master_key, "hatoba/enc/v1"),
auth_key: hkdf_sha256(master_key, "hatoba/auth/v1"),
```

`enc_key` 用于在本地包裹随机的 `vault_key`；同步条目再使用 `vault_key` 进行 AES-256-GCM 加密，AAD 绑定 `hatoba/item/v1/{item_id}`。这样就不只是“密文有没有被改动”，还会核对它属于哪一个条目。

`auth_key` 则走另一条路。查看 [Worker 登录路由](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/src/routes/auth.ts)可以看到，客户端提交派生的 `auth_key` 作为认证凭据，服务端拿它的 SHA-256 与存储的 `auth_hash` 比对，成功后签发会话令牌。两把密钥用途分离的意义是：**用于登录的凭据并不能直接替代用于解密 Vault 的密钥**。但登录凭据、恢复码和有效会话令牌依然需要按敏感数据管理。

接着看 [D1 的 `items` 表](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/migrations/0001_init.sql)。关键列在固定源码里是这样定义的（略去 SQL 注释和索引）：

```sql
CREATE TABLE items (
  id         TEXT PRIMARY KEY,
  envelope   TEXT,
  revision   INTEGER NOT NULL,
  seq        INTEGER NOT NULL,
  deleted    INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL
);
```

`envelope` 承载加密后的条目内容；`id`、`revision`、`seq`、`deleted`、`updated_at` 却用于索引和增量同步，仍是服务端可处理的字段。记录数、密文大小、访问与同步时间等信息，也不在“业务明文被加密”的承诺之内。[官方安全模型第 4.5 节](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md)同样列出了服务器可见内容。

所以真正要区分的是：**E2EE 保护同步内容的机密性，并不承诺隐藏所有使用痕迹。** 数据库副本不应该因为握有密文就直接得到 SSH 私钥正文；另一方面，同步频率、记录变更乃至会话管理风险仍然存在。

这也解释了为什么仅看加密算法还不够。Hatoba 的 [威胁模型](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md)承认，恶意 Worker 可以删除数据、拒绝服务，或返回旧数据。[项目状态表](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md)把 rollback detection（回滚检测）列为 P2 待完成项。它和加密强度不是同一个问题。

## AI 读取终端之后，数据可能走向另一个提供方

假设一次排障需要读取 Nginx 日志。日志中有访问路径、内网主机名，甚至某条错误信息把密钥片段一并打印了出来。SSH 通道本身可以是加密的，Vault 同步也可以是 E2EE，但当这些文本进入 AI 请求，接收方已经变成了模型提供者。

Hatoba 的 [README 安全说明](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/README.md)提到，发送消息时可以带上终端屏幕内容，默认附带行为可在设置里调整；用户消息、附件和工具返回结果也可能进入模型请求。这里的关键不是“客户端会自动上传整个密钥库”，而是**哪些内容在某次对话里被实际选为上下文**。即便关闭云同步，也不等于关闭模型出站。

“只读操作”在审批里是什么地位？[前端 `tools.ts` 的 `needsApproval()`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/apps/desktop/src/features/ai/tools.ts)给出的常规判断很直接，下面这段代码里没有列出读取工具：

```ts
export function needsApproval(kind: ToolKind, mode: AiPermissionMode): boolean {
  if (mode === "bypass") return false;
  return kind === "run_command" || kind === "send_input" ||
    kind === "edit_file" || kind === "write_file" ||
    kind === "fetch_url" || kind === "mcp";
}
```

按这段判断，Manual 模式通常会要求确认 `run_command`、`send_input`、`edit_file`、`write_file` 等调用；`read_terminal`、`read_file`、`grep`、`glob` 一类读取不在这份常规审批名单里。已保存的允许规则会进一步影响是否再次询问。MCP 还有独立的工具级条件，`mustAsk()` 对 `always_ask`、`always_allow` 做了区分。

**只读不代表数据留在本地。** “能读取”是操作权限，“把读到的文本放进模型 Context”是另一条数据流。对于包含生产凭据的环境，比起只盯着 Shell 命令确认框，我会先确认默认附带了多少屏幕内容、模型提供方是谁、是否有敏感目录或日志被工具读入。

Auto 和 Bypass 也不能归到同一类。Auto 会引入 judge 决定部分调用是否跳过审批；[官方状态表](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md)记录了模拟服务、规则单测和有限样本试用，不能由此推出它能识别所有恶意命令输出或提示词注入。Bypass 则跳过一般审批，但 MCP 的 `always_ask` 仍可能触发询问。这一层越自动，越要在 SSH 账号、文件系统权限和审计日志上留下独立限制，而不是只相信 AI 会自己收手。

## 密文是真的，也可能已经过期：六项独立原型验证

我用一份独立 Python 原型检查了一个容易被 E2EE 讨论忽略的区别：修改密文应该被认证机制发现，但**一份以前有效的密文，不会仅因为已经有更新版本就自动解密失败**。

这份脚本按固定源码中的 Argon2id、HKDF、AES-GCM 和 item-ID AAD 机制编写，没有直接调用 Hatoba 的 Rust/Worker。记录的执行环境是 Python 3.13.5、`argon2-cffi 25.1.0`、`cryptography 46.0.4`，`unittest` **6 项通过、0 失败，退出码为 0**。

| 独立原型测试 | 实际观察 |
| --- | --- |
| `enc_key` / `auth_key` 分离 | 两把 32 字节派生密钥不同 |
| 加解密往返、明文检查 | 能解回测试内容；密文信封不含测试明文 |
| 修改 AES-GCM 密文末位 | `InvalidTag`，解密失败 |
| 更换信封对应的 `item_id` | AAD 不匹配，`InvalidTag` |
| 同一条目先后生成两个有效信封 | 新旧版本都可以正确解密 |
| 模拟同步记录附带修订字段 | `revision`、`updated_at` 对外可见，条目明文不可见 |

第五项最值得看。下面是实际执行脚本中的关键断言：

```python
old = seal_item(self.vault_key, self.item_id, {"generation": 1})
new = seal_item(self.vault_key, self.item_id, {"generation": 2})

self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})
```

这不是 AES-GCM 失效。新旧两份密文都没有被篡改，认证标签都合法；只是认证标签并不携带“客户端已经接受到第几代数据”的可信状态。要识别服务器重新交回旧内容，不能只检查一条记录自报的 `revision`，还得有攻击者无法一并回滚的版本锚点、可信客户端状态或其他防回滚机制。

**有效密文 ≠ 最新数据。** Hatoba 上游把回滚检测列为待完成项，独立原型则解释了为什么需要单独做这件事。两者指向同一个架构判断，但不构成对 Hatoba 同步服务的真实攻击复现。

原始 `unittest` 输出末尾如下；完整脚本放在文末，方便检查每个断言：

```text
----------------------------------------------------------------------
Ran 6 tests in 0.100s

OK
```

## 准备让 AI 连接生产 SSH，我会先验证什么

看完源码，Hatoba 更像一套值得继续观察的 AI SSH 产品设计，而不是已经能凭这份资料直接替换生产 SSH 的工具。它支持的 SSH、SFTP、Vault、AI 文件操作和云同步能力各有实现进度，不能只看一列“已完成”就忽略真实环境的覆盖情况。

[固定版本 `docs/status.md`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md)还留着这些明确记录：

| 功能 | 上游公开的验证进度 |
| --- | --- |
| Worker 同步 | 有实现与对应测试；本文没有取得真实 Cloudflare 部署结果 |
| D1 Direct | 使用 SQLite 模拟 REST 接口验证，尚未对真实 D1 验证 |
| AI 提供方与 ChatGPT 登录 | 有协议和模拟测试，真实账号与提供方组合仍需确认 |
| AI 文件操作及审批界面 | 有脚本、OpenSSH 集成或模拟后端测试；真实模型驱动的桌面操作仍有待覆盖 |
| macOS | Apple Silicon 实验构建，ad-hoc 签名、未公证；仍有实机兼容性待检验 |

如果要在团队内试用，我会按这个顺序放开权限，而不是一开始就把生产机管理员账号交给 AI：

1. **先隔离网络、主机和账号。** 用没有敏感数据的测试机，配非 root 的最小权限 SSH 用户，关闭非必要的云同步，从有限的只读诊断开始。
2. **核查哪些内容会出站。** 检查是否默认附带终端屏幕，逐项看日志、附件和工具结果会发给哪个模型提供方、如何留存。涉及客户数据或生产凭据时，先完成分类、脱敏和接收方评估；无法满足要求就不发送原始上下文。
3. **在 Manual 下验证审批。** 在测试机上安排一次无害的文件写入，检查是否出现确认卡、有没有误存“始终允许”规则，以及修改能否按预期恢复。Auto、Bypass、MCP 和第三方 Skill 的授权要单独检查。
4. **核实恢复与备份。** 妥善保存主密码，恢复码放在独立的安全位置；检查设备丢失后的会话吊销、可用的导出或恢复方式，以及离线备份是否真实可用。若两种凭据都丢失，Hatoba 按其设计无法替你解密旧 Vault。

这些步骤解决的是不同问题。即便 Shell 写操作每次都要求确认，远程文件、终端输出或网页中的提示词注入仍可能影响模型；数据会不会发出去，也需要单独审查。

我看中这类工具的地方仍然是减少人工复制日志、切换终端的摩擦；但采用条件不取决于页面上是否写着 E2EE，而取决于**数据发送给谁、谁有执行权限、出问题后能恢复到什么状态**。如果你正在评估别的 AI 终端助手，也可以先收藏上面的四类风险核对表，再对照各自的固定源码和真实运行记录逐项验证。

如果关注的不是直连 SSH，而是把 AI 生成的操作放进独立审核后再执行，我在 [ChatGPT Web + GitHub Self-hosted Runner](/posts/chatgpt-web-github-self-hosted-runner/) 中介绍过另一种隔离发布权限的方式。

<details>
<summary>展开：六项独立 Python 原型的完整源码</summary>

这份脚本使用合成内容，不连接外部服务。要自行复跑，可在独立 Python 环境安装 `argon2-cffi`、`cryptography`，保存为 `hatoba_crypto_boundary_fixture.py` 并运行 `python3 -m unittest -v hatoba_crypto_boundary_fixture.py`。

```python
"""Isolated, source-adapted crypto boundary demonstration; NOT Hatoba execution.

Models only the publicly documented v0.3.0 key labels, algorithms and item AAD.
No Hatoba source is imported or run, no network, accounts, SSH or real data.
"""
import base64
import json
import os
import unittest
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF


def derive_keys(password: bytes, salt: bytes):
    master_key = hash_secret_raw(
        password, salt, time_cost=3, memory_cost=64 * 1024,
        parallelism=4, hash_len=32, type=Type.ID,
    )
    def derive(info):
        return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info).derive(master_key)
    return derive(b"hatoba/enc/v1"), derive(b"hatoba/auth/v1")


def seal_item(vault_key: bytes, item_id: str, content: dict) -> dict:
    nonce = os.urandom(12)
    aad = ("hatoba/item/v1/" + item_id).encode()
    ciphertext = AESGCM(vault_key).encrypt(
        nonce, json.dumps(content, sort_keys=True).encode(), aad,
    )
    return {"v": 1, "n": base64.b64encode(nonce).decode(),
            "c": base64.b64encode(ciphertext).decode()}


def open_item(vault_key: bytes, item_id: str, envelope: dict) -> dict:
    nonce = base64.b64decode(envelope["n"])
    ciphertext = base64.b64decode(envelope["c"])
    aad = ("hatoba/item/v1/" + item_id).encode()
    return json.loads(AESGCM(vault_key).decrypt(nonce, ciphertext, aad))


class BoundaryFixture(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.enc_key, cls.auth_key = derive_keys(b"test-only-long-password", b"16-byte-testsalt")
        cls.vault_key = os.urandom(32)
        cls.item_id = "test-item-1"
        cls.content = {"type": "host", "hostname": "fixture.example.invalid", "secret": "synthetic-only"}

    def test_distinct_auth_and_encryption_keys(self):
        self.assertEqual(len(self.enc_key), 32)
        self.assertEqual(len(self.auth_key), 32)
        self.assertNotEqual(self.enc_key, self.auth_key)

    def test_roundtrip_and_no_plaintext_in_envelope(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        self.assertNotIn("fixture.example.invalid", json.dumps(envelope))
        self.assertNotIn("synthetic-only", json.dumps(envelope))
        self.assertEqual(open_item(self.vault_key, self.item_id, envelope), self.content)

    def test_tampered_ciphertext_rejected(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        tampered = dict(envelope)
        c = bytearray(base64.b64decode(envelope["c"]))
        c[-1] ^= 0x01
        tampered["c"] = base64.b64encode(c).decode()
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, self.item_id, tampered)

    def test_other_item_id_rejected_by_aad(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, "another-item", envelope)

    def test_stale_but_valid_envelope_still_decrypts(self):
        # An authenticated envelope is not a freshness/revision check.
        old = seal_item(self.vault_key, self.item_id, {"generation": 1})
        new = seal_item(self.vault_key, self.item_id, {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})

    def test_server_row_exposes_sync_metadata_not_item_plaintext(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        row = {"id": self.item_id, "envelope": json.dumps(envelope),
               "revision": 2, "seq": 6, "deleted": False, "updated_at": 42}
        serialized = json.dumps(row)
        self.assertIn('"revision": 2', serialized)
        self.assertIn('"updated_at": 42', serialized)
        self.assertNotIn("fixture.example.invalid", serialized)
        self.assertEqual(json.loads(row["envelope"]), envelope)


if __name__ == "__main__":
    unittest.main()
```

</details>

## Verification context

- Level: source-inspected
- Verified: 2026-10-15T00:00:00.000Z
- Environment: Hatoba v0.3.0 pinned source 5e9b353
- Environment: Independent Python 3.13.5 synthetic 6/6
- Evidence mode: mixed
- Evidence receipt: https://vxi.me/evidence/ai-ssh-client-security-hatoba-e2ee.json
