Un client SSH con IA è sicuro? E2EE, dati inviati al modello e autorizzazioni nel codice di Hatoba
Un assistente IA dentro il terminale SSH può rendere più rapida la diagnosi di un errore Nginx 502: meno log copiati a mano e meno finestre tra cui passare. Il terminale, però, non mostra soltanto errori. Variabili d’ambiente, nomi di host interni, configurazioni e token stampati per sbaglio possono comparire nello stesso contesto.
Quando l’assistente può leggere file ed eseguire comandi, chiedersi se abbia individuato la causa giusta non basta. A chi vengono inviati i dati del terminale? Quali operazioni possono proseguire senza un’altra conferma?
Hatoba è un esempio concreto da esaminare nel codice. Presentato su Show HN, è un client SSH open source che combina un assistente IA, un vault locale e sincronizzazione E2EE mediante un Cloudflare Worker e D1 nell’account dell’utente. Ma proteggere i contenuti sincronizzati non significa che ogni dato trattato dall’IA rimanga nello stesso perimetro di fiducia.
Quattro conclusioni, prima dei dettagli:
- E2EE tutela il contenuto del vault, non tutti i dati osservabili nel cloud. Il Worker gestisce informazioni di autenticazione, ID degli elementi, revisioni, sequenze e timestamp.
- Anche una lettura può trasferire dati al modello. Schermate del terminale, log, allegati e risposte di
read_filepossono entrare nel contesto dell’IA: la cifratura della sincronizzazione Cloudflare non protegge automaticamente questo canale distinto. - Manual non richiede conferma per ogni lettura. Lettura, modifica, esecuzione, Auto, Bypass e MCP seguono condizioni diverse.
- Un testo cifrato autentico non è necessariamente il più recente. Un tag AES-GCM valido non basta a riconoscere il replay di una vecchia versione legittima.
| Rischio | Confine di fiducia | Cosa dimostra il codice fissato | Controllo prima dell’adozione |
|---|---|---|---|
| Esposizione del vault nel cloud | Vault locale → Worker/D1 | Contenuti cifrati sul client; alcuni metadati restano visibili | Chiavi, metadati, recupero, backup |
| Log o credenziali di produzione inviati all’IA | Schermo/file SSH → provider del modello | Schermo, allegati e risultati possono partire separatamente dalla sincronizzazione | Impostazioni del contesto, oscuramento, destinatario |
| Comandi rischiosi eseguiti dall’IA | Strumento IA → host SSH | Letture e modifiche seguono regole di approvazione differenti | Identità SSH con privilegi minimi, prova Manual |
| Replay di dati di sincronizzazione obsoleti | Worker/D1 → vault locale | L’autenticazione del cifrato non garantisce la versione più recente | Stato di versione attendibile, ripristino |
Ho esaminato Hatoba v0.3.0 al commit immutabile 5e9b35302: il modulo Rust per le chiavi, lo schema di archiviazione del Worker e la logica delle autorizzazioni. Un modello Python indipendente mostra alcune proprietà crittografiche di base. Si tratta di un’analisi del sorgente e dell’architettura, non di un’installazione di Hatoba, di una sessione SSH reale o di un penetration test.
Cosa protegge E2EE e quali informazioni D1 continua a vedere
Partiamo dalle chiavi. Nel file Rust crypto.rs, Argon2id deriva master_key dalla password principale; HKDF-SHA256 separa poi le chiavi per scopi diversi. Le due etichette importanti del sorgente sono:
enc_key: hkdf_sha256(master_key, "hatoba/enc/v1"),
auth_key: hkdf_sha256(master_key, "hatoba/auth/v1"),
enc_key viene usata per avvolgere localmente una vault_key generata in modo casuale. Gli elementi sincronizzati vengono poi cifrati con quella chiave tramite AES-256-GCM. I dati autenticati aggiuntivi (AAD) legano l’involucro a hatoba/item/v1/{item_id}: spostare un cifrato autentico sotto l’identità di un altro elemento fa fallire il controllo di autenticità.
auth_key serve invece all’accesso. Nella route di autenticazione del Worker, il client invia questa chiave derivata; il servizio confronta SHA-256 con l’auth_hash memorizzato e, in caso di successo, emette un token di sessione. La credenziale di login non coincide con la chiave che decifra il vault. Ciò non elimina l’obbligo di proteggere segreti di accesso, codici di recupero e sessioni valide.
Guardiamo ora la tabella items di D1, riportando soltanto le colonne fondamentali, senza commenti e indici:
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 contiene il payload cifrato dell’elemento. id, revision, seq, deleted e updated_at sono invece accessibili al server, necessari per sincronizzazione e conflitti. Il numero di record, le dimensioni dei cifrati e i tempi di utilizzo sono ulteriori dati distinti dal contenuto protetto. La sezione 4.5 del modello di sicurezza descrive espressamente queste informazioni visibili al server.
E2EE protegge la riservatezza dei contenuti, non rende invisibile ogni traccia d’uso. Una copia del database D1 non dovrebbe permettere di leggere subito una chiave SSH privata conservata in un elemento cifrato correttamente; attività di sincronizzazione e gestione delle sessioni restano però superfici da considerare.
C’è poi un obiettivo differente: la freschezza dei dati. Il threat model riconosce che un Worker malevolo può cancellare elementi, negarli o fornire una versione precedente. Il documento sullo stato dell’implementazione segnala ancora rollback detection come attività P2 incompleta. La validità del cifrato non implica disponibilità o protezione dal rollback.
Un’operazione di lettura può trasferire il contenuto a un altro provider
Immaginiamo un log Nginx di produzione con un nome di host interno o parte di un token. La connessione SSH può essere cifrata e la sincronizzazione del vault può usare E2EE; se quel testo entra però nella richiesta al modello, è coinvolto un altro destinatario: il provider di IA.
Il README di Hatoba precisa che il contenuto dello schermo del terminale può accompagnare un messaggio, con impostazioni che permettono di modificarne il comportamento predefinito. Anche i messaggi dell’utente, gli allegati e i risultati dei tool possono essere inoltrati. Non è corretto dire che tutto il vault venga caricato automaticamente: il punto è quali dati siano inclusi in ciascuna richiesta al modello. Disattivare la sincronizzazione Cloudflare non equivale a disattivare le chiamate all’IA.
La logica delle approvazioni è esplicita. La funzione needsApproval() in tools.ts non elenca tra le operazioni soggette a conferma ordinaria le letture comuni:
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";
}
In Manual, run_command, send_input, edit_file, write_file e fetch_url richiedono normalmente un’autorizzazione. read_terminal, read_file, grep e glob non mostrano necessariamente una finestra per ogni chiamata. I permessi concessi in precedenza possono ridurre ulteriormente le richieste di conferma. MCP segue regole aggiuntive: la funzione mustAsk() distingue tra always_ask e always_allow.
Sola lettura non significa dati confinati in locale. Avere il permesso di leggere e trasferire il risultato a un modello sono due decisioni diverse. Su un server sensibile controllerei la schermata inclusa nel contesto, i file accessibili e la gestione dei dati da parte del provider prima di affidarmi soltanto alla conferma per i comandi di scrittura.
Auto utilizza un judge per decidere se alcune chiamate possano saltare l’approvazione. Il documento di stato cita test unitari, simulazioni e verifiche limitate, non una prova di immunità da prompt injection tramite file ostili o output del terminale. Bypass omette le conferme ordinarie; MCP con always_ask può comunque costituire un’eccezione. I permessi SSH e del sistema operativo devono restare controlli indipendenti in ogni modalità.
Un cifrato valido può essere vecchio: sei test Python indipendenti
Volevo isolare due proprietà diverse: modificare una stringa AES-GCM deve invalidare l’autenticazione, mentre una vecchia busta crittografica legittima può continuare a essere decifrabile anche dopo l’arrivo di una nuova versione.
Ho scritto un modello Python separato basato sulle stesse famiglie di primitive ed etichette documentate: Argon2id, HKDF, AES-GCM e AAD con item ID. Non importa né esegue il codice Rust o Worker di Hatoba. L’ambiente registrato era Python 3.13.5, argon2-cffi 25.1.0, cryptography 46.0.4; tutti e sei gli unittest sono passati, senza errori, exit code 0.
| Test nel modello indipendente | Osservazione registrata |
|---|---|
Separazione di enc_key e auth_key | Due chiavi differenti da 32 byte |
| Cifratura/decifratura e verifica del testo | Ripristino del contenuto sintetico; niente testo di prova nell’envelope |
| Modifica di un byte AES-GCM | Decifratura respinta con InvalidTag |
Uso di un item_id differente | Errore InvalidTag per AAD non corrispondente |
| Due versioni autentiche dello stesso elemento | Entrambe, vecchia e nuova, possono essere decifrate |
| Riga di sincronizzazione simulata | revision e updated_at visibili, payload in chiaro non visibile |
Il quinto caso è quello più interessante. Queste asserzioni provengono dallo script effettivamente eseguito:
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 sta funzionando. Entrambe le buste sono autentiche, quindi entrambe vengono accettate. Il tag non ricorda quale generazione sia stata già riconosciuta come più recente dal client. Per rilevare la riproposizione di un elemento antico ma autentico occorre un riferimento di versione affidabile che l’attaccante non possa riportare indietro, oppure un altro meccanismo anti-rollback.
Cifrato valido ≠ dato più recente. Questo modello chiarisce perché Hatoba considera rollback detection un requisito distinto. Non dimostra però un attacco riuscito al protocollo di sincronizzazione reale di Hatoba.
Il risultato archiviato di unittest termina così:
----------------------------------------------------------------------
Ran 6 tests in 0.100s
OK
Il tempo indicato appartiene soltanto a questa piccola prova sintetica: non misura la velocità della cifratura o la latenza dell’IA in Hatoba. Il codice completo è riportato in fondo all’articolo.
Quattro verifiche prima di concedere SSH di produzione a un’IA
Hatoba include implementazioni per SSH, SFTP, Vault, operazioni su file e sincronizzazione. Ma «implementato», «provato con mock» e «verificato su servizi reali» non sono equivalenti. Il file docs/status.md di v0.3.0 riporta limiti precisi:
| Funzione | Verifiche indicate dagli sviluppatori |
|---|---|
| Sincronizzazione Worker | Implementazione e relativi test disponibili; nessun deployment Cloudflare eseguito per questo articolo |
| D1 Direct | Interfaccia REST simulata con SQLite, non verificata su D1 reale |
| Provider IA e login ChatGPT | Test di protocollo e mock; combinazioni reali di account e provider ancora da controllare |
| Operazioni su file e UI delle autorizzazioni | Test tramite script, OpenSSH o backend simulato; integrazioni reali modello-desktop non coperte interamente |
| macOS | Build sperimentale per Apple Silicon, firma ad-hoc, non notarizzata, verifiche su dispositivi ancora incomplete |
Prima di introdurlo in un team, procederei per livelli invece di consegnare subito un account amministrativo di produzione:
- Isolare host e identità. Usare una macchina di test senza dati sensibili, con utente SSH non root e privilegi minimi. Disattivare la sincronizzazione superflua e iniziare con una diagnosi limitata alla lettura.
- Controllare cosa esce. Verificare se lo schermo del terminale è allegato per impostazione predefinita, quali log, allegati e risultati raggiungono il provider e quali siano le regole di conservazione. Classificare e oscurare dati dei clienti e credenziali; se non è possibile, evitare di inviare il contesto originale a un modello esterno.
- Provare Manual. Sul server di test proporre una modifica innocua, verificare la schermata di consenso, controllare le autorizzazioni «consenti sempre» già salvate e la procedura di ripristino. Valutare Auto, Bypass, MCP e Skill di terze parti separatamente.
- Verificare backup e recupero. Conservare in modo sicuro e separato password principale e codice di recupero; controllare la revoca delle sessioni in caso di perdita del dispositivo, eventuali esportazioni/ripristini disponibili e copie offline. Senza entrambe le credenziali, il design di Hatoba non consente di recuperare il vecchio vault cifrato.
Anche se ogni scrittura richiede un consenso, un file remoto o l’output di un comando può contenere istruzioni ostili rivolte al modello. La finestra di approvazione non sostituisce le difese contro la prompt injection.
Gli assistenti SSH possono ridurre il lavoro di copiare log e passare dal problema all’azione. Prima di adottarli, però, conta chi riceve i dati, chi può eseguire i comandi e come si ripristina lo stato dopo un problema, non soltanto la dicitura E2EE. La matrice iniziale può essere riutilizzata per valutare altri client terminali con IA.
Per un approccio alternativo, che mette le modifiche generate dall’IA dietro un percorso GitHub di deployment separato invece di concedere accesso SSH diretto, si può leggere ChatGPT Web + GitHub Self-hosted Runner.
Codice completo dei sei test crittografici Python indipendenti
Lo script usa soltanto dati sintetici, senza collegarsi a Hatoba o a servizi esterni. In un ambiente Python isolato installare argon2-cffi e cryptography, salvare il codice come hatoba_crypto_boundary_fixture.py ed eseguire python3 -m unittest -v hatoba_crypto_boundary_fixture.py.
"""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()