Wie sicher ist ein KI-SSH-Client? Hatobas E2EE, Datenabfluss und Befehlsfreigaben im Quellcode
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 eignet sich als konkretes Beispiel. Der über Show HN 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. 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 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:
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 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 macht die Grenze der Verschlüsselung besonders anschaulich. Ohne Quellkommentare und Indizes sehen die entscheidenden Spalten so aus:
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, 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 lässt zu, dass ein kompromittierter Worker Einträge entfernt, vorenthält oder ältere Stände ausliefert. Im Statusdokument 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 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 zeigt, dass gewöhnliche Leseoperationen nicht in der Standardliste freigabepflichtiger Tools erscheinen:
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 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:
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:
----------------------------------------------------------------------
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 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:
- 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.
- 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.
- 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.
- 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.
Vollständiger Quellcode der sechs unabhängigen Python-Kryptotests
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.
"""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()