← Indice documentazione Microprogettazione › pairing

Metnos

pairing — legare un canale e un mittente a un livello di autonomia
Microprogettazione
Pubblico: chi vuole capire come Metnos riconosce chi gli scrive da fuori e con quale autorità.
Microprogettazione — allineata al codice. pairing testato + flusso daemon, con supporto multi-user: registro users.db in ~/.local/share/metnos/, pairing guest via /start <token> a vita breve emesso dal pannello /admin/users dell'http_api, accesso admin web via POST /admin/login con cookie 7 giorni. Riferimento di implementazione: /opt/metnos/runtime/pairing.py, /opt/metnos/runtime/users.py.

Indice

  1. Cos'è il pairing
  2. Tipi e costanti
  3. Formato del codice
  4. Schema SQLite
  5. API Python
  6. CLI
  7. Integrazione col daemon Telegram
  8. Sicurezza
  9. Test
  10. Limiti
  11. Multi-user: users.db e /start
  12. Accesso admin web
  13. Appaiamento di dispositivi per executor remoti

1. Cos'è il pairing

Il 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).

device tecnico /pair + Ed25519 familiare / guest /start <token> registro users.db host + guest resolve_recipients instradamento
Figura 1 — I due percorsi di pairing: codici firmati Ed25519 per i device tecnici e token a vita breve per i guest; entrambi confluiscono nel registro multi-utente.

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.

Pairing e accesso. Un sistema di accesso moderno autenticherebbe l'utente persona, poi assocerebbe le sue identità sui vari canali. Metnos fa il contrario: ogni canale ha la sua porta, la sua chiave, la sua autorizzazione, indipendenti. Più semplice da implementare, più sicuro per default (un canale compromesso non ne compromette altri), e adeguato al modello operativo "io a casa coi miei familiari".

Tre livelli di autonomia, una sola dimensione (vedi capitolo 4 dell'Architettura per la giustificazione):

2. Tipi e costanti

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:

CostanteValoreSignificato
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_S300Cinque minuti. Tempo di vita del codice fra emissione e consumo. Configurabile per chiamata, ma volutamente breve nel default.
PROTOCOL_VERSION1Versione 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.dbPosizione di default del registro. Override via env METNOS_PAIRINGS_DB (utile per test).

3. Formato del codice

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.

Perché un id da 12 caratteri esadecimali? Servono solo a deduplicare i consumi (un codice già usato non si ripresenta). Dodici caratteri = 48 bit di spazio: collisioni accidentali praticamente nulle nella vita di un'istanza, e la stringa rimane abbastanza compatta da non gonfiare la base64 finale.

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.

4. Schema SQLite

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
);
TabellaRuolo
pairingsUna 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_codesTracciamento 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.

5. API Python

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.

FunzioneFirmaCosa fa
generate_code(autonomy_level, *, ttl_seconds=300, issued_by="author") -> strCostruisce 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) -> PairingAtomico 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 | NoneRestituisce il pairing attivo (revoked_at IS NULL) o None.
is_paired(channel, sender_id, *, db_path=None) -> boolInvolucro booleano di get_pairing.
get_autonomy(channel, sender_id, *, db_path=None) -> str | NoneSolo il livello, per chi non vuole tutto il dataclass.
touch_last_seen(channel, sender_id, *, db_path=None) -> NoneAggiorna 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) -> boolSetta 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) -> PairingAuto-pair Full la prima volta che il default_chat_id scrive, solo se non esistono altri pairing per quel canale. Solleva PairingError altrimenti.
Atomicità di 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".

6. CLI

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.

Sessione tipica
# 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

7. Integrazione col daemon Telegram

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:

  1. Se il testo del messaggio inizia con /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.
  2. Altrimenti, si consulta 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.
  3. Se il pairing esiste, si chiama touch_last_seen per audit.
  4. Se il livello è in LEVEL_BLOCKS_RUN = {"ReadOnly"}: risposta cortese (LEVEL_REPLY_BLOCKED), niente run_turn.
  5. Altrimenti, si chiama run_turn(msg.text), si formatta il risultato e si risponde sul canale.

Bootstrap del default chat

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:

  1. la flag bootstrap_default_sender del daemon è True (default; si disabilita con --no-bootstrap);
  2. il sender_id del messaggio coincide col default_chat_id dichiarato dal canale;
  3. nessun pairing esiste su quel 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.

Bootstrap è per sviluppo. È comodo nel modello operativo "Roberto + casa", dove la prima istallazione corrisponde alla prima interazione di Roberto col proprio chat default. In contesti diversi (rilascio multi-tenant, server condiviso), il bootstrap andrebbe disattivato e ogni admin dovrebbe pairarsi via codice come tutti gli altri.

Errori in 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.

8. Sicurezza

Cinque garanzie, ciascuna esistente per una minaccia concreta:

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

9. Test

Cluster pairing: 9 case, tutti verdi. Coprono i flussi nominali e le rotture esplicite del modello.

#CasoCosa verifica
1ciclo completo generate + consumeCodice creato con generate_code("Full", 5m), consumato con consume_code(code, "telegram", "u1"), restituisce un Pairing valido con i campi attesi.
2codice scaduto fallisceTTL = -1 secondo: consume_code solleva PairingError("codice scaduto").
3doppio consumo fallisceLo stesso codice consumato la seconda volta solleva PairingError("codice gia' consumato").
4codice manomesso fallisceSostituendo un byte nel payload (e ri-base64), la firma non verifica e si solleva PairingError("firma codice non verificata").
5formato invalido fallisceStringhe arbitrarie (no prefisso, troppi punti, base64 rotto) sollevano PairingError con messaggi diversi.
6revoke rende is_paired falsoDopo revoke, is_paired sulla stessa coppia ritorna False; il record persiste ma con revoked_at valorizzato.
7bootstrap solo se canale vuotoPrima chiamata: pairing Full creato. Seconda chiamata sullo stesso canale (anche con sender_id diverso): PairingError("bootstrap rifiutato").
8list_pairings filtra revocatiDefault include_revoked=False: solo le righe con revoked_at IS NULL. Con True: tutte.
9livello invalido in generategenerate_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:

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.

10. Limiti

Cinque limiti dichiarati, alcuni di disegno volontari, altri rimandati a +:

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

Note finali

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.

11. Multi-user: 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à:

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.

Flusso di pairing /start <token>

Per i guest (familiari, ospiti) il flusso e' agevole e prescinde da chiavi Ed25519:

  1. Roberto apre /admin/users nel browser e crea un user name=lucia, role guest, autonomy restricted, owner Roberto.
  2. Bottone «Pair Telegram»: il server emette un token hex urlsafe da 32 caratteri (TTL 1 ora) memorizzato in user_channels.pairing_token.
  3. Roberto manda il token a Lucia (messaggio diretto Telegram, voce, qualsiasi mezzo).
  4. Lucia scrive al bot Metnos: /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).

PLANNER prompt — UTENTI NOTI

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.

12. Accesso admin web

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à:

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

13. Appaiamento di dispositivi per executor remoti

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.

Dispositivo non è canale. L'appaiamento di canale (capitoli precedenti) lega la coppia (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.

Prima l'identità: come il dispositivo dimostra chi è

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.

Il codice monouso: un invito valido una volta sola

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.

Il flusso, passo per passo

dispositivo genera chiave Ed25519 console /admin emette codice monouso register() server: device_id + chiave pubblica il dispositivo fissa la chiave server battito / revoca 403 = il client si ferma Appaiamento di un dispositivo ogni scambio è firmato; la firma non valida non viene mai eseguita
Figura 2 — Il ciclo di appaiamento: il dispositivo genera la propria chiave, la console emette un codice monouso, register lo scambia con l'identità e la chiave del server, il dispositivo la fissa; da lì in avanti battito e revoca.
  1. Roberto apre /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.
  2. Dalla stessa pagina si scarica MetnosClientSetup.cmd (Windows), che installa e avvia il programma sul PC.
  3. Il programma chiama 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).
  4. Il dispositivo fissa quella chiave pubblica del server (la memorizza una volta per tutte) e d'ora in poi la usa per verificare che ogni ordine arrivi davvero da quel server e non da un impostore.
Riappaiarsi è una ri-autorizzazione voluta. Ripresentarsi con la stessa chiave non crea un secondo dispositivo: aggiorna nome, proprietario, sistema operativo e la data di appaiamento, e cancella qualunque revoca precedente. È il modo esplicito per riammettere una macchina che era stata revocata.

Il battito: «sono ancora qui»

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.

Un battito che non blocca. Il battito gira in un compito separato. Così continua a segnalare «sono qui» anche mentre il programma, alla prima installazione, sta ancora scaricando il proprio ambiente di esecuzione.

La revoca: ritirare le chiavi

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

Perché è sicuro

Tre difese lavorano insieme. Le prime due sono firme crittografiche nei due sensi; la terza tiene il canale fuori da Internet.

DifesaDa 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).
Stato. Appaiamento, battito e revoca sono coperti dai test del client e del server; la versione distribuita è dichiarata dal pacchetto del client.