# Un client SSH con IA è sicuro? E2EE, dati inviati al modello e autorizzazioni nel codice di Hatoba

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

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](https://github.com/scarletkc/Hatoba) è un esempio concreto da esaminare nel codice. Presentato su [Show HN](https://news.ycombinator.com/item?id=50076388), è 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_file` possono 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`](https://github.com/scarletkc/Hatoba/tree/5e9b353020a43fbb7560dcb0abf5e071c986ee7a)**: 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`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/crates/hatoba-core/src/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:

```rust
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/src/routes/auth.ts), 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/workers/sync/migrations/0001_init.sql), riportando soltanto le colonne fondamentali, senza commenti e indici:

```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` 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md) 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/hatoba-spec.md) riconosce che un Worker malevolo può cancellare elementi, negarli o fornire una versione precedente. Il [documento sullo stato dell'implementazione](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/README.md) 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`](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/apps/desktop/src/features/ai/tools.ts) non elenca tra le operazioni soggette a conferma ordinaria le letture comuni:

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

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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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:

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

```text
----------------------------------------------------------------------
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](https://github.com/scarletkc/Hatoba/blob/5e9b353020a43fbb7560dcb0abf5e071c986ee7a/docs/status.md) 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:

1. **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.
2. **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.
3. **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.
4. **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](/en/posts/chatgpt-web-github-self-hosted-runner/).

<details>
<summary>Codice completo dei sei test crittografici Python indipendenti</summary>

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

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