pairing — legare un canale e un mittente a un livello di autonomiaIl pairing identifica un channel+sender, non una persona fisica. Lo stesso familiare che scrive a Metnos via Telegram e via Signal sono due pairing distinti, indipendenti, ciascuno col proprio livello di autonomia. Così nasce, naturalmente, la possibilità di pairare la stessa persona con livelli diversi a seconda del canale (Telegram = Full perché il telefono è sempre con lui; mail = ReadOnly perché potrebbe essere stata letta da qualcun altro).
Cap. 12 dell'Architettura tratta il pairing come ramo del nodo "identità e perimetro": Metnos non sa chi sei in senso anagrafico, sa solo che la coppia (channel, sender_id) è stata associata, una volta, a un livello di autonomia, e lavora su quello.
Tre livelli di autonomia, una sola dimensione (vedi capitolo 4 dell'Architettura per la giustificazione):
ReadOnly — il mittente può scrivere, Metnos riconosce ma non esegue. Pensato per familiari occasionali o canali sensibili.Supervised — il mittente ottiene un turno completo, ma le azioni con effetti esterni passano sotto controllo (sviluppo futuro: oggi, ai fini di run_turn, si comporta come Full, ma il livello è già persistito).Full — il mittente ha autorità piena: Metnos esegue il turno e risponde.
Tutto il modulo è in /opt/metnos/runtime/pairing.py (354 righe). I due tipi pubblici sono una dataclass e un'eccezione:
@dataclass class Pairing: channel: str sender_id: str autonomy_level: str paired_at: str paired_by: str last_seen: str | None = None revoked_at: str | None = None class PairingError(Exception): pass
La Pairing è quel che le query restituiscono e quel che il chiamante ispeziona. Il PairingError incapsula tutti gli errori del modulo (formato codice, firma, scadenza, doppio consumo) cosicché il chiamante possa gestirli con un solo except. Vedi la classe PairingError nel modulo.
Costanti rilevanti:
| Costante | Valore | Significato |
|---|---|---|
VALID_LEVELS | ("ReadOnly", "Supervised", "Full") | Tupla degli unici livelli accettati. Generare un codice con un livello fuori da questa tupla solleva ValueError. |
CODE_PREFIX | "PAIR." | Prefisso fisso del codice: serve a riconoscere a colpo d'occhio (umano o regex) che la stringa è un pairing code, non un altro tipo di token. |
DEFAULT_TTL_S | 300 | Cinque minuti. Tempo di vita del codice fra emissione e consumo. Configurabile per chiamata, ma volutamente breve nel default. |
PROTOCOL_VERSION | 1 | Versione del payload. Il verify rifiuta payload con v diverso. Quando la struttura cambierà (probabile in +) si incrementa qui e si gestisce la diramazione. |
DEFAULT_DB_PATH | ~/.local/state/metnos/pairings.db | Posizione di default del registro. Override via env METNOS_PAIRINGS_DB (utile per test). |
Il codice è una stringa stampabile, copiabile a mano se serve. La forma:
PAIR.<base64url(payload_json)>.<base64url(signature)>
Il payload è un JSON deterministico (chiavi ordinate, separatori senza spazi) con cinque campi:
{
"v": 1,
"id": "<uuid12>",
"autonomy": "<ReadOnly|Supervised|Full>",
"exp": <epoch unix in secondi>,
"iss": "author"
}
La firma è Ed25519 sui bytes del JSON serializzato, prodotta con la chiave author di ~/.config/metnos/keys/ (vedi sign.py:28-29, 58-60). La verifica scorre tutte le pubbliche trusted in quella stessa directory (list_trusted_publics in sign.py:66-76) e accetta il codice se almeno una verifica passa. Così un Metnos più vecchio resta capace di accettare codici firmati da una nuova chiave finché la pubblica nuova viene aggiunta alla directory.
Esempio di codice reale (TTL 5 minuti, livello ReadOnly):
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjoxNzA5OTk4ODg4LCJpZCI6IjA0YzNhYThiYjFiNCIsImlzcyI6ImF1dGhvciIsInYiOjF9.5xV3M7T...kWQ
Lunghezza tipica: ~180 caratteri. Si copia-incolla, si manda su iMessage o si dice a voce per le otto sillabe finali.
Due tabelle:
CREATE TABLE IF NOT EXISTS pairings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, sender_id TEXT NOT NULL, autonomy_level TEXT NOT NULL, paired_at TEXT NOT NULL, paired_by TEXT NOT NULL, last_seen TEXT, revoked_at TEXT, UNIQUE(channel, sender_id) ); CREATE TABLE IF NOT EXISTS consumed_codes ( code_id TEXT PRIMARY KEY, consumed_at TEXT NOT NULL, channel TEXT NOT NULL, sender_id TEXT NOT NULL );
| Tabella | Ruolo |
|---|---|
pairings | Una riga per ogni coppia (channel, sender_id) mai pairata. Il vincolo UNIQUE garantisce un solo pairing attivo per coppia; il consumo di un nuovo codice fa upsert e azzera revoked_at. Il campo last_seen è opzionale e viene aggiornato dal daemon a ogni messaggio (per audit). Un revoked_at valorizzato significa pairing inattivo. |
consumed_codes | Tracciamento a uso singolo. Ogni codice consumato lascia una riga col suo code_id (i 12 hex del payload). I tentativi di riuso falliscono dentro un BEGIN IMMEDIATE. Nessuna pulizia: la tabella cresce solo del numero di pairing fatti nella vita dell'istanza, una quantità irrisoria. |
Il file di default vive in ~/.local/state/metnos/pairings.db; la directory viene creata al primo accesso. Per i test si imposta METNOS_PAIRINGS_DB a un percorso temporaneo, ottenendo un database isolato per ogni test case.
Tutte le funzioni accettano un db_path opzionale come keyword-only, in modo che il chiamante possa puntare a un registro diverso (utile per test e per ipotesi future di registro-per-tenant). Senza db_path, si usa METNOS_PAIRINGS_DB oppure il default.
| Funzione | Firma | Cosa fa |
|---|---|---|
generate_code | (autonomy_level, *, ttl_seconds=300, issued_by="author") -> str | Costruisce il payload, firma con la chiave indicata da issued_by, restituisce la stringa PAIR.<...>.<...>. Solleva ValueError se il livello è fuori da VALID_LEVELS. |
consume_code | (code, channel, sender_id, *, db_path=None) -> Pairing | Atomico via BEGIN IMMEDIATE: verifica firma e scadenza, deduplica con consumed_codes, fa upsert in pairings. Restituisce il pairing risultante o solleva PairingError con la causa specifica. |
get_pairing | (channel, sender_id, *, db_path=None) -> Pairing | None | Restituisce il pairing attivo (revoked_at IS NULL) o None. |
is_paired | (channel, sender_id, *, db_path=None) -> bool | Involucro booleano di get_pairing. |
get_autonomy | (channel, sender_id, *, db_path=None) -> str | None | Solo il livello, per chi non vuole tutto il dataclass. |
touch_last_seen | (channel, sender_id, *, db_path=None) -> None | Aggiorna last_seen alla marca temporale ISO corrente. Chiamata dal daemon a ogni turno andato a buon fine. |
list_pairings | (*, include_revoked=False, db_path=None) -> list[Pairing] | Tutte le righe, opzionalmente includendo i revocati. Ordine: paired_at DESC. |
revoke | (channel, sender_id, *, db_path=None) -> bool | Setta revoked_at sul pairing attivo. Restituisce True se ha modificato qualcosa, False se la coppia non esisteva o era già revocata. |
bootstrap_default_chat_id | (channel, sender_id, *, db_path=None) -> Pairing | Auto-pair Full la prima volta che il default_chat_id scrive, solo se non esistono altri pairing per quel canale. Solleva PairingError altrimenti. |
consume_code. Il vincolo importante è che lo stesso codice non possa essere usato due volte, neanche da due thread o due processi che lo ricevono per coincidenza nello stesso istante. La transazione SQLite con BEGIN IMMEDIATE serializza qualunque tentativo concorrente: il primo vince, il secondo trova la riga in consumed_codes e fallisce con "codice gia' consumato".
Il modulo è eseguibile direttamente. Comandi:
python3 -m pairing generate <ReadOnly|Supervised|Full> [ttl=5m] python3 -m pairing consume <code> <channel> <sender_id> python3 -m pairing list [--include-revoked] python3 -m pairing revoke <channel> <sender_id>
Il TTL accetta tre suffissi : 60s, 5m, 1h. Senza suffisso si interpreta come secondi.
# 1. Roberto genera un codice ReadOnly da 10 minuti
$ python3 -m pairing generate ReadOnly 10m
PAIR.eyJhdXRvbm9teSI6IlJlYWRPbmx5IiwiZXhwIjox...
# 2. lo passa via iMessage al familiare
# 3. il familiare scrive su Telegram: /pair PAIR.eyJ...
# il daemon consuma e risponde "Pairato come ReadOnly. Benvenuto."
# 4. Roberto verifica
$ python3 -m pairing list
{"channel": "telegram", "sender_id": "12345678", "autonomy_level": "ReadOnly",...}
{"channel": "telegram", "sender_id": "99887766", "autonomy_level": "Full",...}
# 5. una settimana dopo, revoca
$ python3 -m pairing revoke telegram 12345678
revoked
Il daemon vive in /opt/metnos/runtime/channels/daemon.py. La funzione handle_message è il punto in cui pairing e runtime si incontrano. La sequenza di decisioni:
/pair (vedi costante PAIR_COMMAND), il daemon va in _handle_pair_command: consuma il codice, risponde "Pairato come <livello>. Benvenuto." in caso di successo, "Pairing fallito: <motivo>" altrimenti. Nessun turno di runtime parte qui, anche se il pairing ha avuto successo: l'utente deve scrivere un secondo messaggio per attivare Metnos.pairing.get_pairing(channel.name, sender_id). Se ritorna None, il daemon prova il bootstrap (vedi sotto). Se anche il bootstrap fallisce, risponde con UNPAIRED_REPLY e termina il turno.touch_last_seen per audit.LEVEL_BLOCKS_RUN = {"ReadOnly"}: risposta cortese (LEVEL_REPLY_BLOCKED), niente run_turn.run_turn(msg.text), si formatta il risultato e si risponde sul canale.
La funzione _try_bootstrap gestisce il caso "Roberto installa Metnos sul proprio nuovo telefono e si scrive": senza un pairing precedente, il primo messaggio del default_chat_id (preso da credentials.env via il TelegramChannel) viene auto-pairato come Full. Tre condizioni devono essere tutte vere:
bootstrap_default_sender del daemon è True (default; si disabilita con --no-bootstrap);sender_id del messaggio coincide col default_chat_id dichiarato dal canale;bootstrap_default_chat_id rifiuta altrimenti).Così il bootstrap è sicuro: dopo che esiste un solo pairing sul canale, ulteriori chiamate falliscono e il bootstrap perde efficacia. Roberto stesso, dopo essersi pairato la prima volta, non può più usare il bootstrap per pairare nessun altro: i familiari devono ricevere un codice esplicito.
run_turn
Se run_turn solleva un'eccezione, il daemon non muore: la cattura, registra la traccia dello stack, risponde al mittente con "(errore interno: <tipo>: <testo>)" e prosegue il ciclo col messaggio successivo. Il pairing non viene toccato: lo stesso utente, al messaggio dopo, è ancora pairato e ancora autorizzato a richiedere altri turni.
Cinque garanzie, ciascuna esistente per una minaccia concreta:
| Garanzia | Da chi protegge |
|---|---|
Firma Ed25519 sul payload: garantisce che il codice provenga davvero dalla chiave author di Roberto. | Da un attaccante che scriva al daemon mandando codici inventati. Anche se lo scopre un familiare con buone intenzioni, non può auto-elevarsi: per generare un codice servirebbe la chiave privata, che vive solo sul server di Roberto. |
TTL breve di default (5 minuti, DEFAULT_TTL_S). | Da un codice che cade in mani sbagliate dopo l'emissione (mail intercettata, schermata inviata per errore). Cinque minuti sono sufficienti per un consumo legittimo, troppo pochi per un riuso opportunistico ore o giorni dopo. |
Uso singolo via tabella consumed_codes. | Da uno stesso codice intercettato e usato da più sender. Il primo che riesce a inviarlo "vince": gli altri ricevono "codice gia' consumato". |
Revoke immediato e check revoked_at IS NULL in get_pairing. | Da un dispositivo perso o un familiare con cui i rapporti si sono rotti. La revoca ha effetto al primo messaggio successivo: nessuna sessione persistente da invalidare separatamente. |
| Niente diffusione del codice: Metnos non lo manda mai sul canale stesso del pairing, sta a Roberto trasportarlo fuori banda (iMessage, voce, foglietto di carta). | Dal canale stesso, se compromesso. Un'eventuale intercettazione del canale Telegram non scopre i codici prodotti. |
Manca volutamente: un limite di frequenza sui tentativi /pair falliti (un attaccante potrebbe spammare codici inventati cercando collisioni, ma con uno spazio di firma Ed25519 il successo è cosmologicamente improbabile e ogni tentativo lascia un log). Vedi limiti.
Cluster pairing: 9 case, tutti verdi. Coprono i flussi nominali e le rotture esplicite del modello.
| # | Caso | Cosa verifica |
|---|---|---|
| 1 | ciclo completo generate + consume | Codice creato con generate_code("Full", 5m), consumato con consume_code(code, "telegram", "u1"), restituisce un Pairing valido con i campi attesi. |
| 2 | codice scaduto fallisce | TTL = -1 secondo: consume_code solleva PairingError("codice scaduto"). |
| 3 | doppio consumo fallisce | Lo stesso codice consumato la seconda volta solleva PairingError("codice gia' consumato"). |
| 4 | codice manomesso fallisce | Sostituendo un byte nel payload (e ri-base64), la firma non verifica e si solleva PairingError("firma codice non verificata"). |
| 5 | formato invalido fallisce | Stringhe arbitrarie (no prefisso, troppi punti, base64 rotto) sollevano PairingError con messaggi diversi. |
| 6 | revoke rende is_paired falso | Dopo revoke, is_paired sulla stessa coppia ritorna False; il record persiste ma con revoked_at valorizzato. |
| 7 | bootstrap solo se canale vuoto | Prima chiamata: pairing Full creato. Seconda chiamata sullo stesso canale (anche con sender_id diverso): PairingError("bootstrap rifiutato"). |
| 8 | list_pairings filtra revocati | Default include_revoked=False: solo le righe con revoked_at IS NULL. Con True: tutte. |
| 9 | livello invalido in generate | generate_code("Admin",...) solleva ValueError prima ancora di firmare. |
Modulo channels (daemon): 9/9 con un sotto-cluster di 5 case che esercita esplicitamente il flusso di pairing fra daemon e modulo:
UNPAIRED_REPLY, niente run_turn;/pair <codice valido> → risposta "Pairato come <livello>. Benvenuto.", pairing scritto in DB, niente run_turn;/pair <codice scaduto/manomesso> → risposta "Pairing fallito: <causa>", nessun pairing scritto;run_turn; last_seen non viene toccato (l'aggiornamento parte solo dopo il check del livello).
Tutti i test girano isolati con un proprio METNOS_PAIRINGS_DB in /tmp, e con una coppia di chiavi di test creata ad hoc, così non si toccano né il registro né le chiavi di sviluppo dell'autore.
Cinque limiti dichiarati, alcuni di disegno volontari, altri rimandati a +:
| Limite | Quando si toglie |
|---|---|
Registro SQLite a processo singolo. Il file può essere aperto da più processi grazie ai lock di SQLite, ma non c'è una messa a punto particolare; il modello operativo presuppone un singolo metnos-server. | Quando ci sarà più di un processo che scrive (es. daemon Telegram + daemon mail in parallelo): si attiva esplicitamente WAL e si riconsiderano i tempi di attesa dei lock. |
Niente notifica push al pair generato. Roberto genera il codice via CLI; chi riceve il codice scopre l'esito solo quando manda /pair. Il "pair pendente" non è visibile sul daemon. | Quando esisterà una UI di amministrazione (mail di stato, comando /admin dal canale stesso). Per ora la list via CLI è sufficiente. |
| Niente innalzamento in loco del livello. Per alzare un familiare da ReadOnly a Full bisogna generare un nuovo codice e farlo consumare di nuovo (l'upsert sostituisce il livello). | Quando un comando promote della CLI o un flusso di lavoro specifico lo richiederà. Oggi il costo aggiuntivo è minimo (un comando in più) e il vantaggio di avere un solo punto di ingresso (la consume con codice firmato) è maggiore. |
| Niente scadenza automatica dei pairing. Una volta consumato il codice, il pairing vive finché non viene revocato a mano. | Quando ci sarà un'esigenza concreta (familiare temporaneo, ospite per il weekend): si potrebbe aggiungere un expires_at al pairing stesso (oggi solo il codice ha exp; il pairing che ne nasce è perpetuo). |
Audit minimo. paired_by contiene il nome della chiave che ha firmato il codice (oggi sempre "author"); non c'è un registro di chi ha generato i codici, né un log strutturato dei fallimenti di consume_code (vivono solo nei log del daemon). | Quando esisterà più di una chiave trusted (es. una chiave per dispositivo) e diventerà utile distinguere chi firma cosa. Si introdurranno tabelle code_audit e consume_audit. |
Il modulo è volutamente piccolo (354 righe in un file unico, una sola dipendenza esterna oltre alla standard library: cryptography via sign.py). Non c'è uno strato di astrazione "store" interscambiabile, non c'è un protocol formale del codice diverso da JSON+base64+firma. Le scelte sono guidate dal principio di semplicità (vedi memoria feedback_simplicity_first): la complessità va aggiunta quando il problema la chiede, non prima.
Il pairing è il primo pezzo del nodo "perimetro" che diventa eseguibile: tutto quel che si appoggia all'identificazione del mittente (limite di frequenza per livello, ambito di executor permessi per livello, audit di chi ha avviato cosa) si poggia su questa primitiva.
users.db e /start
A fianco di pairings.db, Metnos tiene
un secondo registro, users.db, in
~/.local/share/metnos/: due tabelle (users +
user_channels) che mappano utenti logici (host
e guest) a uno o più canali. La divisione di responsabilità:
pairing.py mappa (channel, sender_id) → autonomy + actor_string — canale-specifico, a basso livello.users.py mappa persone logiche → uno o più canali. Sa di chi e' «Lucia» indipendentemente dal canale, e supporta resolve_recipients per send_messages(to_user="lucia").
Bootstrap automatico: al primo users.list_users
init_db crea l'host con name=$USER,
role='host', autonomy_level='full'. Regola
applicativa a host singolo: un secondo host viene rifiutato.
autobind_host_telegram(default_chat_id) e' chiamato dal
daemon Telegram al primo poll che riconosce il default_chat_id
di config.
/start <token>Per i guest (familiari, ospiti) il flusso e' agevole e prescinde da chiavi Ed25519:
/admin/users nel browser e crea un user name=lucia, role guest, autonomy restricted, owner Roberto.user_channels.pairing_token./start a1b2c3.... Il daemon chiama users.consume_pairing_token('telegram', chat_id, token): se valido, sincronizza il pairing in pairings.db (autonomy='Supervised' per guest, actor=name) e risponde «Sei stato pairato come lucia (guest). Benvenuto/a in Metnos.».
Da quel momento send_messages(messages=[{"to_user":"lucia",...}])
risolve automaticamente il chat_id e invia. Il token e'
azzerato dopo l'uso, un secondo /start con lo stesso token
fallisce (ValueError).
agent_runtime.py::_render_users_known_block itera
users.list_users e inietta una sezione UTENTI NOTI nel
PLANNER prompt subito dopo PROJECT PATHS. Pattern equivalente a PROJECT
PATHS: dato deterministico nel prompt, modifica =
restart del runtime.
Per il pannello /admin dell'http_api
(porta 8770) l'accesso e' separato dal pairing: una sola admin key
(file ~/.config/metnos/admin.key, mode 0600, 256-bit hex
generato al primo avvio). Due modalità:
POST /admin/login con la admin key in chiaro → risposta set-cookie HttpOnly SameSite=Strict TTL 7 giorni.Authorization: Bearer <admin.key> a ogni chiamata.
La admin key e' anche la master della cifratura credenziali:
chi controlla il file decifra
~/.config/metnos/credentials/<domain>.json.age.
Copia di sicurezza offline raccomandata. Nei log compare solo l'impronta
sha256[:16].
Finora abbiamo parlato di appaiare una persona che scrive a Metnos. Qui appaiamo invece una macchina: un altro computer che esegue gli executor di Metnos al posto suo, lì dove i dati vivono davvero.
Facciamo un'analogia. Immagina di dare a un corriere di fiducia le chiavi di casa, perché ritiri un pacco quando tu non ci sei. Vuoi essere sicuro di tre cose: che sia davvero lui e non un impostore; che gli ordini che gli arrivano vengano davvero da te; e di poter ritirare le chiavi in qualsiasi momento. L'appaiamento di un dispositivo risolve esattamente questi tre problemi — identità, autenticità degli ordini, revoca — con la crittografia al posto delle chiavi di metallo.
Il «corriere» è un piccolo programma scritto in Rust (la
cartella client-rs/), che gira sul computer da appaiare — per
ora un PC Windows. Il lato Metnos che tiene il registro dei dispositivi vive in
runtime/devices.py, tabella devices nel database
devices.db. Riferimento di disegno: ADR 0007 / 0011 / 0046.
(canale, mittente) a un livello
di autonomia: dice chi può parlare a Metnos. L'appaiamento di un
dispositivo lega una macchina a Metnos perché questa
esegua il lavoro dove serve (per esempio un PC nella rete di casa). Sono
due cose diverse: due registri separati, due tabelle, due cicli di vita
indipendenti.
Al primissimo avvio, il programma sul PC genera da sé una coppia di chiavi crittografiche (con l'algoritmo Ed25519): una privata e una pubblica. La chiave privata è il segreto che dimostra l'identità e non lascia mai il computer. La chiave pubblica, invece, può essere mostrata a chiunque: serve solo a verificare le firme, non a produrle.
Da quella chiave pubblica si ricava un'impronta: un codice breve
(lo sha256 della chiave) che identifica il dispositivo in modo univoco. Nel
registro l'impronta è UNIQUE: due dispositivi non possono
avere la stessa identità, e ripresentarsi con la stessa chiave significa
essere riconosciuti come lo stesso dispositivo.
Come fa un PC appena arrivato a farsi accettare da Metnos? Con un codice monouso: un codice usa-e-getta, esattamente come un invito che vale per un solo ingresso. Appena qualcuno lo usa, il codice è bruciato e non funziona più.
Lo emette devices.generate_token. Ha una scadenza volutamente
breve: 30 minuti nel percorso guidato dal pannello, 10 minuti nella variante
manuale a una riga. Il consumo è atomico (parola tecnica per
«o tutto o niente»): devices.consume_token lo registra
dentro un vincolo UNIQUE di SQLite, così anche se due
richieste arrivassero nello stesso istante, una sola vince e la seconda
fallisce senza doppioni.
register lo scambia con l'identità e la chiave del server, il dispositivo la fissa; da lì in avanti battito e revoca./admin/devices nel pannello dell'http_api e sceglie «Installa su questo PC». Si apre una pagina di adesione con il codice monouso appena emesso.MetnosClientSetup.cmd (Windows), che installa e avvia il programma sul PC.register(server, codice): Metnos consuma il codice e risponde con due cose — il device_id (il nome interno del dispositivo) e la propria chiave pubblica di server (server_public_key).
Un dispositivo appaiato deve far sapere a Metnos di essere vivo e raggiungibile.
Lo fa con un battito: ogni 30 secondi manda un breve segnale a
/agent/heartbeat. Come tutto ciò che il dispositivo invia, il
battito è firmato con la chiave privata sui byte esatti del messaggio
(nell'intestazione X-Metnos-Device-Sig). Nel pannello il
dispositivo appare così come raggiungibile.
Se un PC viene perso, dismesso o semplicemente non deve più lavorare per
Metnos, si revoca. Basta una chiamata:
POST /admin/devices/{id}/revoke (dietro le quinte
devices.revoke_device), che segna la data di revoca. Da quel
momento ogni messaggio firmato in arrivo da quel dispositivo —
richiesta di lavoro o semplice battito — viene respinto con un errore
HTTP 403, e il programma si ferma da solo.
La revoca interrompe il battito e l'operazione è idempotente: ripeterla non cambia il risultato e una seconda richiesta risponde semplicemente «già revocato».
Tre difese lavorano insieme. Le prime due sono firme crittografiche nei due sensi; la terza tiene il canale fuori da Internet.
| Difesa | Da cosa protegge |
|---|---|
Firma dal dispositivo al server: ogni richiesta del programma è firmata sui byte esatti del messaggio (intestazione X-Metnos-Device-Sig). | Da un estraneo che finga di essere il dispositivo. Senza la chiave privata — che non lascia mai il PC — non può produrre una firma valida. |
Firma dal server al dispositivo: ogni ordine di Metnos porta una firma (server_sig), verificata contro la chiave fissata all'appaiamento prima di eseguire. Firma non valida = rifiuto e annotazione, nessuna esecuzione. | Da un finto server o da un ordine manomesso lungo la strada. Il dispositivo esegue solo comandi firmati dal server che ha memorizzato al momento dell'appaiamento. |
| Canale solo interno: il dialogo coi dispositivi usa la porta 8765 e non è mai esposto attraverso il tunnel pubblico Cloudflare. Verso l'esterno passa solo la console sulla porta 8770. | Dall'esposizione su Internet. La capacità di far eseguire lavoro a distanza resta confinata alla rete locale (o a una rete privata sovrapposta). |