# AI SSH 用戶端安全嗎？從 Hatoba 原始碼看 E2EE、資料外傳與指令核准

> Canonical: https://vxi.me/zh-tw/posts/ai-ssh-client-security-hatoba-e2ee/
> Locale: zh-tw
> Published: 2026-10-15
> Updated: 2026-10-15
> Markdown: https://vxi.me/zh-tw/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 操作都在同一個信任邊界內。

**先看四個結論：**

- **E2EE 保護 Vault 的內容，不會隱藏所有雲端資訊。** Worker 仍需要處理登入憑證，以及項目 ID、版本、時間戳記等同步中繼資料。
- **唯讀操作也可能把資料送到模型。** 終端機畫面、日誌、附件或 `read_file` 結果進入模型上下文後，就不是 Cloudflare 同步加密能涵蓋的範圍。
- **Manual 模式不會對每一次讀取都跳出確認。** 讀取和修改會走不同核准分支；Auto、Bypass 與 MCP 也各有例外。
- **認證成功的密文不一定是最新版本。** AES-GCM 標籤無法單獨辨認合法的舊密文是否被重放，仍需要額外的防回滾機制。

| 風險情境 | 真正經過的邊界 | 固定版本原始碼能支持的結論 | 採用前要檢查什麼 |
| --- | --- | --- | --- |
| 雲端 Vault 內容外洩 | 本機 Vault → Worker/D1 | 條目內容在用戶端加密，但部分同步中繼資料仍可見 | 密鑰分離、中繼資料、備份與復原 |
| 正式環境日誌或憑證進入模型 | SSH 畫面／檔案 → 模型供應商 | 畫面、附件及工具結果可能獨立於雲端同步被傳送 | 模型接收方、上下文設定與遮罩 |
| AI 危險指令或越權操作 | AI 工具 → SSH 主機 | 一般讀取與寫入／執行的核准條件不同 | 最小權限 SSH 帳號、Manual 測試 |
| 舊同步狀態被重放 | Worker/D1 → 本機 Vault | 密文可驗證真偽，但不會自行證明版本最新 | 可信版本狀態與復原路徑 |

以下的判斷以 **Hatoba v0.3.0 的[固定提交 `5e9b35302`](https://github.com/scarletkc/Hatoba/tree/5e9b353020a43fbb7560dcb0abf5e071c986ee7a)** 為準。我閱讀 Rust 加密模組、Worker 資料表與前端核准程式碼，另以獨立 Python 密碼學原型檢查其中的通用機制。這是原始碼與架構分析，並非 Hatoba 的實機部署或滲透測試。

## E2EE 留在用戶端的資料，以及 D1 依然看得見的部分

先從密鑰看起。[Rust `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)會收到用戶端傳來的衍生驗證密鑰，比對其 SHA-256 與已儲存的 `auth_hash`，通過後核發工作階段權杖。**驗證身分的憑證不等於解密 Vault 的密鑰**；不過，登入密鑰、復原碼與有效的 session token 仍然必須視為敏感資料。

再看 [D1 的 `items` 資料表](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/migrations/0001_init.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 保護內容的機密性，並不是所有使用軌跡都不可見。** 拿到 D1 備份不應因此直接讀取正確加密的 SSH 私鑰內容，但同步活動與 session 管理仍有自己的暴露面。

還有資料新鮮度問題。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；但只要日誌文字進入模型請求，資料接收方就改變了。

Hatoba 的 [README 安全說明](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/README.md)提到，終端機畫面可在傳送訊息時一併送出，這項預設行為可以調整。使用者訊息、附件和工具回傳值也可能送給選擇的模型供應商。這**不是**說整份 Vault 一定會被自動上傳；真正要問的是每一輪請求到底附上了哪些上下文。即使停用雲端同步，模型請求仍可能發生。

閱讀 [前端 `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` 和 `fetch_url` 等操作通常需要核准；`read_terminal`、`read_file`、`grep`、`glob` 則不一定逐次跳出確認。既有的允許規則可能讓某次操作直接通過。MCP 還有額外判定，同檔案的 `mustAsk()` 也區分 `always_ask` 和 `always_allow`。

**唯讀不表示資料只留在本機。** 能不能讀取，是工具權限問題；會不會把讀到的文字送去模型，是資料外傳問題。對含有正式環境設定的主機，我會先確認終端機畫面附帶範圍、模型接收方和保留方式，再來討論 Manual 的確認視窗是否足夠。

Auto 會使用 judge 決定哪些呼叫可以略過部分核准；[專案狀態紀錄](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md)中有單元測試、模擬後端與有限的試用資料，但不足以宣稱能阻擋所有 prompt injection。Bypass 則略過一般工具核准，MCP 的 `always_ask` 仍可能構成例外。無論採用哪一種模式，SSH 使用者權限與檔案系統隔離都不能交給 AI 自行決定。

## 合法密文也可能過期：六項獨立 Python 原型測試

我用獨立的 Python 密碼學模型區分兩件事：篡改密文應該讓 AES-GCM 認證失敗，但**以前生成、沒有被修改的合法密文**，不會僅因為出現更新版本就自動無法解密。

這份程式參考固定原始碼裡的 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-byte 密鑰 |
| 加解密往返與明文檢查 | 能還原合成內容，信封不包含測試明文 |
| 修改 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 壞了。認證標籤不會記住客戶端已接受哪一個最新版本；若要發現伺服器回傳較舊的有效資料，就需要攻擊者無法一起回滾的可信版本錨點或其他防回滾設計。

**有效密文 ≠ 最新資料。** 這個模型能解釋 Hatoba 為何另列 rollback detection 工作，卻不能當成「已在 Hatoba 實際利用回滾漏洞」的證據：測試沒有接觸它的同步客戶端或協定。

留存的 `unittest` 輸出結尾如下：

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

OK
```

其中的耗時只對應這支小型獨立程式，不是 Hatoba 的同步吞吐量或 AI 反應時間。完整腳本放在文末。

## 準備讓 AI 連接正式環境 SSH 前，我會怎麼驗證

Hatoba 確實有 SSH、SFTP、Vault、AI 檔案操作和雲端同步的設計與實作，但「功能存在」、「有測試」和「已在真實環境驗證」不能混為一談。[v0.3.0 的 `docs/status.md`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md)就留下了以下差異：

| 能力 | 官方當時記錄的驗證範圍 |
| --- | --- |
| Worker 同步 | 已有實作與相關測試；本文沒有實際 Cloudflare 部署紀錄 |
| D1 Direct | 使用 SQLite 模擬 REST 介面，尚未驗證真實 D1 |
| AI 模型與 ChatGPT 登入 | 協定／模擬測試已有，真實帳號與供應商組合仍需確認 |
| AI 檔案操作與核准 UI | 有腳本、OpenSSH 或模擬後端測試；實際模型驅動桌面操作尚未完整覆蓋 |
| macOS | Apple Silicon 實驗版、ad-hoc 簽章、未經公證，仍有實機相容性缺口 |

如果是團隊試用，我會依序放開，而不是直接讓 AI 拿到正式機器的管理員身分：

1. **隔離主機與帳號。** 先用沒有敏感資料的測試機，搭配非 root、最小權限 SSH 使用者；停用不必要的同步，從受限唯讀診斷開始。
2. **核對外傳內容。** 檢查終端機畫面是否預設附帶、日誌和工具結果會傳到哪個模型，以及供應商的資料保留政策。涉及客戶或正式環境憑證時，先完成分類和去識別化；做不到就不要送出原始上下文。
3. **測試 Manual 核准。** 在測試機發起無害的檔案寫入，確認是否跳出核准卡片、是否留有「永遠允許」設定，以及能不能復原。Auto、Bypass、MCP 和第三方 Skill 另外檢查。
4. **核實備份與復原。** 主密碼、復原碼分開安全保管，確認遺失裝置時可如何撤銷 session，並驗證可用的匯出與離線備份。兩者都遺失時，Hatoba 按其設計無法恢復原有 Vault。

即使寫入動作必須按下確認，惡意的遠端檔案或終端機輸出仍可能引導模型提出下一步操作。權限核准與防止提示注入，必須分別處理。

我認為 AI SSH 工具真正吸引人的，是減少手工複製日誌與切換視窗的成本。能不能採用，則應看**資料送給誰、誰能下指令、發生錯誤後如何復原**，而不是只看 E2EE 字樣。你也可以收藏前面的四類風險檢查表，對照其他 AI 終端機工具的原始碼與實際測試。

如果你關心的是把 AI 產生的操作交由獨立部署流程核准，而非直接授予 SSH 存取，另一篇 [ChatGPT Web + GitHub Self-hosted Runner](/zh-tw/posts/chatgpt-web-github-self-hosted-runner/) 討論了不同的做法。

<details>
<summary>展開六項獨立 Python 測試的完整原始碼</summary>

這份腳本只使用合成資料，不會連接 Hatoba 或外部服務。可在隔離的 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
