# Wie sicher ist ein KI-SSH-Client? Hatobas E2EE, Datenabfluss und Befehlsfreigaben im Quellcode

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

Ein KI-Assistent direkt im SSH-Terminal könnte die Fehlersuche bei einem Nginx-502 erheblich erleichtern. Man müsste Logs nicht ständig zwischen Fenstern kopieren. Allerdings zeigt ein Terminal nicht nur Fehlermeldungen: Umgebungsvariablen, interne Hostnamen, Konfigurationsdateien und versehentlich ausgegebene Zugriffstoken können im gleichen Kontext auftauchen.

Sobald der Assistent Dateien lesen und Befehle ausführen kann, reicht die Frage nach der Richtigkeit seiner Diagnose nicht mehr. **Wer erhält die Inhalte des Terminals – und welche Aktionen laufen ohne erneute Bestätigung?**

[Hatoba](https://github.com/scarletkc/Hatoba) eignet sich als konkretes Beispiel. Der über [Show HN](https://news.ycombinator.com/item?id=50076388) vorgestellte Open-Source-SSH-Client kombiniert einen KI-Assistenten, einen lokalen Vault und E2EE-Synchronisierung über Cloudflare Worker und D1 im eigenen Konto. Dass der synchronisierte Inhalt Ende-zu-Ende-verschlüsselt ist, beantwortet aber noch nicht, wie Daten bei KI-Anfragen oder Werkzeugaufrufen geschützt werden.

**Vier Ergebnisse vorweg:**

- **E2EE schützt Vault-Inhalte, nicht sämtliche Cloud-Metadaten.** Der Worker verarbeitet Authentifizierungsinformationen sowie Item-IDs, Revisionsnummern, Sequenzen und Zeitstempel.
- **Auch reine Leseoperationen können Daten an das KI-Modell senden.** Terminal-Ausgaben, Logs, Anhänge und `read_file`-Ergebnisse können Teil des Prompts werden. Die Cloudflare-Synchronisierung verschlüsselt diesen separaten Übertragungsweg nicht.
- **Manual bedeutet nicht, dass jeder Lesezugriff bestätigt wird.** Lesen, Schreiben, Auto, Bypass und MCP unterliegen unterschiedlichen Regeln.
- **Authentifizierter Chiffretext muss nicht aktuell sein.** Ein gültiger AES-GCM-Tag verhindert nicht automatisch die erneute Zustellung einer älteren, unveränderten Version.

| Risiko | Vertrauensgrenze | Was der feste Quellcode-Stand zeigt | Prüfung vor der Nutzung |
| --- | --- | --- | --- |
| Offenlegung des Cloud-Vaults | Lokaler Vault → Worker/D1 | Nutzdaten clientseitig verschlüsselt, Synchronisationsmetadaten teilweise sichtbar | Schlüssel, Metadaten, Wiederherstellung, Backup |
| Produktionslogs oder Zugangsdaten im Modell | SSH-Ausgabe/Datei → Modellanbieter | Bildschirm, Anhänge und Tool-Ergebnisse können unabhängig von Sync gesendet werden | Kontextoptionen, Redaktion, Anbieter |
| Gefährliche KI-Befehle | KI-Tool → SSH-Server | Leseaufrufe und Änderungen haben andere Freigaberegeln | Minimale SSH-Rechte, Manual testen |
| Wiederholung eines älteren Synchronisationsstands | Worker/D1 → Vault | Gültige Authentifizierung beweist nicht die neueste Version | Vertrauenswürdiger Versionsanker, Recovery |

Untersucht wurde **Hatoba v0.3.0 beim unveränderlichen [Commit `5e9b35302`](https://github.com/scarletkc/Hatoba/tree/5e9b353020a43fbb7560dcb0abf5e071c986ee7a)**. Ich habe Rust-Kryptocode, das Worker-Datenmodell und die Frontend-Freigabelogik gelesen. Die grundlegenden kryptografischen Eigenschaften wurden zusätzlich in einem **separaten** Python-Modell demonstriert. Das ist keine Installation, kein Live-SSH-Test und kein Penetrationstest von Hatoba.

## Was E2EE lokal schützt – und was D1 weiterhin sehen kann

Zuerst zur Schlüsselableitung. In [`crypto.rs`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/crates/hatoba-core/src/crypto.rs) erzeugt Argon2id aus dem Masterpasswort einen `master_key`. HKDF-SHA256 trennt anschließend Schlüssel für verschiedene Zwecke. Im Code stehen genau diese beiden Labels:

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

Der `enc_key` dient dazu, einen zufällig erzeugten `vault_key` lokal zu umhüllen. Jedes synchronisierte Item wird dann mit diesem Vault-Schlüssel per AES-256-GCM verschlüsselt. Die Additional Authenticated Data (AAD) binden den Umschlag an `hatoba/item/v1/{item_id}`. Selbst ein unveränderter Chiffretext lässt sich dadurch nicht ohne Authentifizierungsfehler unter einer fremden Item-ID verwenden.

Der `auth_key` hat einen anderen Zweck: die Anmeldung. Die [Worker-Login-Route](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/src/routes/auth.ts) vergleicht SHA-256 des vom Client übertragenen abgeleiteten Authentifizierungsschlüssels mit dem gespeicherten `auth_hash` und stellt anschließend ein Sitzungstoken aus. **Ein Anmeldeschlüssel ist nicht identisch mit dem Schlüssel, der den Vault-Inhalt entschlüsselt.** Trotzdem müssen Authentifizierungsgeheimnisse, Recovery-Codes und Sitzungstoken sorgfältig geschützt werden.

Die [D1-Tabelle `items`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/migrations/0001_init.sql) macht die Grenze der Verschlüsselung besonders anschaulich. Ohne Quellkommentare und Indizes sehen die entscheidenden Spalten so aus:

```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` enthält den verschlüsselten Item-Inhalt. `id`, `revision`, `seq`, `deleted` und `updated_at` sind hingegen sichtbare Felder für Synchronisierung und Konfliktbehandlung. Auch Anzahl der Datensätze, Größe der Chiffretexte und Zeitmuster der Nutzung gehören nicht zum geschützten Klartext. Das [Sicherheitsmodell, Abschnitt 4.5](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md), beschreibt diese serverseitige Sichtbarkeit ausdrücklich.

**E2EE ist eine Zusage über die Vertraulichkeit des Inhalts, nicht über die Unsichtbarkeit jeder Nutzungsmetadatei.** Eine Kopie von D1 sollte einen korrekt verschlüsselten privaten SSH-Schlüssel nicht unmittelbar offenlegen. Aktivitätsdaten, Sitzungen und Zugriffsmuster sind aber zusätzliche Angriffsflächen.

Außerdem garantiert Verschlüsselung weder Verfügbarkeit noch Aktualität. Das [Threat Model](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md) lässt zu, dass ein kompromittierter Worker Einträge entfernt, vorenthält oder ältere Stände ausliefert. Im [Statusdokument](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) ist die Rollback-Erkennung noch als offener P2-Punkt geführt. Das sind andere Schutzziele als die bloße Integrität eines Chiffretexts.

## Lesezugriff und Modell-Übertragung sind zwei verschiedene Entscheidungen

Angenommen, ein Nginx-Log auf einem Produktivserver enthält einen internen Hostnamen oder einen Teil eines Tokens. Die SSH-Verbindung kann verschlüsselt sein und der Vault kann E2EE verwenden. Sobald der Text aber in eine KI-Anfrage aufgenommen wird, entsteht ein weiterer Empfänger: der Modellanbieter.

Laut [README-Sicherheitshinweisen](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/README.md) kann der Terminalbildschirm beim Senden einer Nachricht beigefügt werden; das Standardverhalten ist konfigurierbar. Auch Benutzernachrichten, Anhänge und Ergebnisse der Tools können an den gewählten Anbieter gehen. Es geht **nicht** um die Behauptung, dass automatisch der gesamte Vault hochgeladen wird. Entscheidend ist, **welcher konkrete Kontext** in der jeweiligen Anfrage landet. Cloud-Sync abzuschalten deaktiviert nicht notwendigerweise Modellanfragen.

Die [Frontend-Funktion `needsApproval()` in `tools.ts`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/apps/desktop/src/features/ai/tools.ts) zeigt, dass gewöhnliche Leseoperationen nicht in der Standardliste freigabepflichtiger Tools erscheinen:

```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";
}
```

Im Manual-Modus benötigen `run_command`, `send_input`, `edit_file`, `write_file` und `fetch_url` gewöhnlich eine Freigabe. `read_terminal`, `read_file`, `grep` und `glob` lösen dagegen nicht zwingend bei jedem Aufruf eine Rückfrage aus. Bereits gespeicherte Ausnahmen können eine spätere Bestätigung ebenfalls unterdrücken. Für MCP gelten weitere Regeln; `mustAsk()` unterscheidet unter anderem `always_ask` und `always_allow`.

**Nur-Lese-Zugriff bedeutet nicht, dass gelesene Daten lokal bleiben.** Die Berechtigung zum Lesen und die Übertragung an einen KI-Anbieter sind zwei getrennte Vertrauensentscheidungen. Auf sensiblen Systemen würde ich zuerst die automatisch beigefügten Terminaldaten und die Datenverarbeitung beim Modellanbieter prüfen, statt nur auf eine Freigabemaske für Schreibbefehle zu vertrauen.

Auto verwendet einen Judge, der bei bestimmten Aufrufen über das Überspringen einer Freigabe entscheidet. Das [Projektstatusdokument](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) nennt Unit-Tests, Mock-Szenarien und begrenzte Prüfungen, aber keinen Beweis für vollständigen Schutz vor Prompt Injection über bösartige Dateien oder Terminalausgaben. Bypass überspringt normale Bestätigungen; MCP-`always_ask` bleibt eine mögliche Ausnahme. Betriebssystem- und SSH-Rechte sollten in jedem Modus unabhängig begrenzt bleiben.

## Gültiger Chiffretext kann trotzdem alt sein: sechs separate Python-Tests

Ich wollte einen oft übersehenen Unterschied isolieren: Eine veränderte AES-GCM-Nachricht sollte bei der Authentifizierung scheitern. **Ein älterer, korrekt erzeugter Umschlag** kann aber weiterhin authentisch sein, obwohl es inzwischen eine neuere Version gibt.

Dafür entstand ein eigenständiges Python-Modell mit Argon2id, HKDF, AES-GCM und item-ID-gebundenen AAD nach dem dokumentierten Design. Es importiert weder Hatobas Rust-Code noch den Worker. Die protokollierte Umgebung war Python 3.13.5 mit `argon2-cffi 25.1.0` und `cryptography 46.0.4`. **Alle sechs unittest-Fälle waren erfolgreich, null Fehler, Exit-Code 0.**

| Test im unabhängigen Modell | Tatsächlich beobachtet |
| --- | --- |
| Trennung von `enc_key` und `auth_key` | Zwei verschiedene Schlüssel mit je 32 Byte |
| Verschlüsseln, Entschlüsseln, Klartextprüfung | Testinhalt lässt sich wiederherstellen; im Umschlag ist kein Testklartext |
| Änderung an AES-GCM-Chiffretext | Entschlüsselung scheitert mit `InvalidTag` |
| Verwendung unter fremder `item_id` | AAD-Prüfung scheitert mit `InvalidTag` |
| Zwei gültige Versionen eines Items | Alte und neue Fassung lassen sich beide entschlüsseln |
| Simulierter Synchronisationsdatensatz | `revision`, `updated_at` sind sichtbar; der Item-Klartext nicht |

Der fünfte Fall enthält die entscheidende Aussage. Die folgenden Assertions stammen direkt aus dem tatsächlich ausgeführten Testskript:

```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 funktioniert hier korrekt: Beide authentischen Nachrichten werden angenommen. Der Authentifizierungs-Tag speichert aber nicht, welche Generation ein Client zuletzt als neu akzeptiert hat. Um die erneute Zustellung eines alten, gültigen Datensatzes zu erkennen, braucht das System vertrauenswürdige, nicht gleichzeitig zurücksetzbare Versionsinformationen oder eine andere Rollback-Abwehr.

**Gültiger Chiffretext ≠ aktueller Stand.** Das erklärt, warum Hatoba die Rollback-Erkennung gesondert aufführt. Das unabhängige Modell ist allerdings kein Nachweis, dass sich Hatobas echtes Synchronisationsprotokoll auf diese Weise ausnutzen lässt.

Der aufgezeichnete `unittest`-Auszug endet mit:

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

OK
```

Die angegebene Zeit ist ausschließlich die Laufzeit dieses kleinen Modells, keine Messung von Hatobas Kryptodurchsatz oder KI-Latenz. Der vollständige Quellcode steht unten.

## Vier Prüfungen vor dem Einsatz mit produktivem SSH

Hatoba implementiert SSH, SFTP, Vault, Datei-Tools und Cloud-Sync. Zwischen „implementiert“, „mit Mocks getestet“ und „auf einem echten Dienst nachgewiesen“ liegt allerdings ein Unterschied. Das [v0.3.0-Statusdokument](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) weist mehrere offene Punkte aus:

| Funktion | Dokumentierter Prüfstand |
| --- | --- |
| Worker-Sync | Implementierung und zugehörige Tests vorhanden; hier kein eigener Cloudflare-Lauf |
| D1 Direct | REST-Verhalten mit SQLite simuliert, nicht gegen echtes D1 geprüft |
| AI-Provider und ChatGPT-Login | Protokoll-/Mock-Tests; reale Konto- und Providerkombinationen noch offen |
| Datei-Tools und Freigabe-UI | Skript-, OpenSSH- oder Mock-Tests; echte modellgesteuerte Desktop-Kombinationen nicht vollständig geprüft |
| macOS | Experimenteller Apple-Silicon-Build, ad-hoc signiert, nicht notarisiert; weitere Gerätetests nötig |

Für einen Test im Team würde ich den Umfang in vier Schritten erweitern, statt sofort einen administrativen SSH-Zugang zu übergeben:

1. **Host und Konto isolieren.** Einen entbehrlichen Nicht-Produktivserver ohne sensible Daten und einen Nicht-root-Benutzer mit minimalen SSH-Rechten verwenden. Unnötige Synchronisierung deaktivieren und mit begrenzter Diagnose im Lesemodus beginnen.
2. **Ausgehende Inhalte prüfen.** Nachsehen, ob der Terminalbildschirm automatisch mitgesendet wird und welche Logs, Anhänge oder Tool-Ausgaben an welchen Modellanbieter gehen. Aufbewahrungsregeln beachten; Kunden- und Zugangsdaten vorher klassifizieren und maskieren. Falls das nicht möglich ist, keinen unbereinigten Kontext an ein externes Modell schicken.
3. **Manual-Freigaben verifizieren.** Auf dem Testhost eine harmlose Dateiänderung auslösen, Bestätigungsdialog und gespeicherte „immer erlauben“-Regeln prüfen und die Wiederherstellung testen. Auto, Bypass, MCP und externe Skills getrennt betrachten.
4. **Recovery und Backups überprüfen.** Masterpasswort und Recovery-Code getrennt sicher aufbewahren, Sitzungswiderruf bei Geräteverlust sowie mögliche Export-/Wiederherstellungsverfahren und Offline-Backups überprüfen. Gehen beide Zugangsfaktoren verloren, kann Hatoba nach seinem Design den alten Vault nicht entschlüsseln.

Selbst bei Freigabe jeder Schreibaktion können Fern-Dateien oder Terminalausgaben Anweisungen enthalten, die das Modell manipulieren sollen. Eine Schaltfläche zur Befehlsfreigabe ersetzt keine Abwehr von Prompt Injection.

KI-SSH-Clients können die Reibung zwischen Diagnose und Aktion reduzieren. Ob sie für ein wichtiges System passen, entscheidet sich jedoch danach, **wer die Daten erhält, wer Befehle ausführen darf und wie eine Wiederherstellung funktioniert**, nicht nach einem E2EE-Schriftzug. Die obige Matrix eignet sich auch als Checkliste für andere KI-Terminalwerkzeuge.

Für den alternativen Ansatz, KI-generierte Änderungen über einen getrennten GitHub-Deploymentpfad statt über direkten SSH-Zugriff freizugeben, gibt es einen verwandten Beitrag: [ChatGPT Web + GitHub Self-hosted Runner](/en/posts/chatgpt-web-github-self-hosted-runner/).

<details>
<summary>Vollständiger Quellcode der sechs unabhängigen Python-Kryptotests</summary>

Das Skript arbeitet nur mit synthetischen Daten, ohne Verbindung zu Hatoba oder einem externen Dienst. In einer isolierten Python-Umgebung `argon2-cffi` und `cryptography` installieren, die Datei als `hatoba_crypto_boundary_fixture.py` speichern und mit `python3 -m unittest -v hatoba_crypto_boundary_fixture.py` ausführen.

```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
