Blog ·
Come un aggiornamento firmato si rifiuta
Un checksum dimostra che il download è arrivato intatto, non che l’ha inviato lei. Che cosa costa davvero firmare un agente che si aggiorna da solo.
Di Provibr
Qualunque software che si aggiorni da solo è esecuzione di codice remoto che lei ha invitato in casa. Non è un argomento contro gli aggiornamenti automatici, che sono l’unico modo realistico per impedire a una flotta di agenti sull’hardware dei clienti di marcire. È un argomento a favore della precisione su ciò che il meccanismo di aggiornamento promette, perché quasi tutti promettono meno di quanto la loro documentazione lasci intendere.
Noi facciamo girare un agente sui server di altre persone. Parla con la nostra piattaforma, applica comandi al loro hypervisor e sostituisce il proprio binario quando pubblichiamo una versione nuova. Questo è ciò che abbiamo dovuto costruire prima di essere disposti a lasciarglielo fare.
Un checksum risponde alla domanda sbagliata
Quasi ogni strumento che si aggiorna da solo pubblica uno SHA-256 accanto al proprio download, e molti si fermano lì. Un checksum è utile, e non fa il lavoro che la gente crede.
| Controllo | Che cosa dimostra davvero | Che cosa non può vedere |
|---|---|---|
| TLS sul download | I byte provengono da quell’host, su quella connessione | Nulla su chi li abbia prodotti |
| SHA-256 pubblicato accanto al file | Il download non è troncato né corrotto | Chi può cambiare il file può cambiare il numero |
| Firma sui byte | Questi byte sono stati prodotti da chi detiene una chiave privata | Se quella chiave sia ancora sua |
La riga centrale è tutto il problema. Il checksum e l’artefatto di solito stanno nello stesso posto e di solito sono scrivibili dallo stesso processo. Se un attaccante può sostituire l’uno, può sostituire l’altro, e la verifica passerà con entusiasmo.
Quindi il checksum è una prova preliminare a basso costo ed esplicitamente non è il confine di fiducia. Intercetta un download troncato in un solo passaggio su byte che stavamo comunque leggendo in streaming. L’ancora è la firma.
Dove deve stare la chiave pubblica
Ecco la prima decisione che stabilisce se tutto questo sia reale: la chiave di verifica non può essere un file che viaggia insieme al binario, perché allora compromettere il percorso di consegna compromette anche la chiave.
Il nostro agente riceve la chiave pubblica Ed25519 della piattaforma compilata al proprio interno, da una variabile d’ambiente di build. In una build di release non esiste altra fonte. In una build di debug uno sviluppatore può puntarla a una chiave locale con una variabile d’ambiente, e quel percorso viene rimosso del tutto in compilazione da un attributo cfg, perché una chiave sovrascrivibile a runtime non è un’ancora ma un suggerimento.
Due dettagli intorno a questo contano più di quanto sembri.
Lo script di build dichiara di dover essere rieseguito quando quella variabile d’ambiente cambia. Senza quella riga, una build di release che condivide una cache di target con una build precedente riutilizza allegramente la chiave della compilazione anteriore, e nessuno se ne accorge finché non arriva a un cliente.
E il parser della chiave è severo sulla codifica anziché sulla lunghezza. Una chiave pubblica Ed25519 in forma SPKI è un prefisso fisso di 12 byte seguito da 32 byte; anche una chiave X25519 è di 32 byte, con un identificatore di algoritmo diverso. Accettare qualunque cosa lunga 32 byte significherebbe prendere una chiave di un altro algoritmo e fallire più tardi, in un punto che non ha alcun senso.
Rifiutare prima di scaricare
Quando la piattaforma dice all’agente di aggiornarsi, l’agente percorre l’intero elenco delle precondizioni prima di scaricare un solo byte:
- C’è uno SHA-256 utilizzabile nella direttiva?
- C’è un URL di download?
- C’è una firma, ed è esattamente di 64 byte?
- Stiamo già eseguendo questa versione?
- Lo script privilegiato di applicazione esiste su questo host?
- Il file dell’ancora di fiducia locale esiste?
- Questo binario ha una chiave della piattaforma compilata al suo interno?
Il terzo punto merita attenzione. Una piattaforma più vecchia che non popola il campo della firma deve fallire qui, in modo rumoroso, anziché ripiegare in silenzio sul controllo più debole che facevamo un tempo. Una verifica facoltativa non è una verifica. Rendere il campo mancante un errore bloccante è la differenza fra un meccanismo e un’abitudine.
Anche l’ordine degli ultimi tre è voluto. I messaggi «al suo host manca il passo privilegiato, esegua di nuovo l’installer» sono più utilizzabili di «questo binario non ha una chiave della piattaforma», quindi vengono prima.
Firmare i byte, non una loro descrizione
La firma copre l’artefatto esattamente come atterra su disco. Ed25519 puro, nessun pre-hashing, nessuna busta canonica, nessun metadato incluso. La ragione non è la purezza. È che due implementazioni completamente diverse devono arrivare allo stesso verdetto.
Sull’agente è ed25519-dalek con verify_strict, che rifiuta le firme con componenti di torsione. Con una chiave e un file dovrebbe esistere esattamente una firma valida, e la variante non stretta non lo garantisce.
Sull’host, lo script privilegiato che installa davvero l’aggiornamento verifica gli stessi byte con openssl pkeyutl -verify -pubin -rawin. Stessa chiave, stessi byte, stessa risposta. Se avessimo fatto il pre-hashing, o firmato un manifest anziché l’artefatto, il controllo a livello di shell dovrebbe reimplementare la nostra struttura, e i due controlli divergerebbero.
È anche il controllo che eseguiamo a mano sul server dopo ogni release, contro la chiave pubblica di produzione, prima di credere che una release sia avvenuta.
Il manifest accanto all’artefatto porta la versione, l’identificatore di build, il checksum, la dimensione, l’orario di rilascio, il tipo di artefatto e la firma. Sono metadati per il pannello, e nessuno di essi è affidabile: la firma che contiene è sul binario, non sul manifest.
L’agente non può installare il proprio aggiornamento
L’agente gira come utente non privilegiato. Non può scrivere nella directory in cui vive il suo stesso binario, e non deve poterlo fare.
Perciò passa per un’area di staging. Scrive prima la firma e il checksum in una directory di staging, e soltanto allora rinomina al suo posto, accanto a essi, il binario verificato, perché lo script privilegiato usa la presenza di quel binario come segnale di avvio e gli altri due file devono essere già lì. Poi esce in modo pulito, e il gestore dei servizi lo riavvia, eseguendo prima un passo privilegiato di pre-avvio.
Vale la pena descrivere quello script, perché le sue premesse sono l’opposto di quanto si immaginerebbe.
Esce sempre con zero. Un passo di pre-avvio fallito impedirebbe del tutto l’avvio del servizio, e un aggiornamento rotto che mette offline un cliente è molto peggio di un aggiornamento rotto che semplicemente non viene applicato.
La directory di staging non è affidabile. Tutto ciò che contiene è stato scritto dall’utente non privilegiato dell’agente: il binario, il checksum, la firma. Chiunque prenda il controllo di quell’utente può sostituirli tutti e tre. Ecco perché la firma, verificata con la chiave sul lato privilegiato, è l’unica cosa che decide.
Copia prima di controllare. I byte in staging vengono copiati esattamente una volta in un file temporaneo nuovo, di proprietà di root, accanto alla destinazione, e ogni controllo successivo legge la copia. Il file che viene verificato è esattamente lo stesso inode che viene rinominato al suo posto, quindi non esiste una finestra in cui un file diverso venga inserito fra il controllo e l’installazione.
Il resto è deliberatamente noioso: rifiutare i collegamenti simbolici, esigere che il checksum sia esattamente di 64 caratteri esadecimali, esigere che il file inizi con il numero magico ELF, ampliare i permessi a eseguibile solo dopo che la firma è passata, avviarlo una volta come utente del servizio anziché come root, e soltanto allora rinominarlo sopra il binario vivo. Una firma dice da dove vengono i byte. Non dice che meritino i privilegi di root.
La release che abbiamo firmato con la chiave sbagliata
Il meccanismo qui sopra funziona. Abbiamo comunque pubblicato una release rotta, e il modo in cui ha fallito è la parte utile.
Il nostro script di build accettava un flag per «questa è una release di produzione». All’epoca quel flag governava soltanto la pubblicazione, non quale chiave venisse usata per firmare. Una release costruita senza indicare anche la chiave di produzione sul server veniva firmata con la chiave di sviluppo, aveva la chiave di licenza di sviluppo compilata al proprio interno, e veniva poi verificata in locale contro quella stessa chiave di sviluppo. Il controllo diceva che l’artefatto andava bene, perché era internamente coerente. Semplicemente non era il nostro.
Ogni agente reale ha rifiutato l’aggiornamento, correttamente, perché la firma non corrispondeva alla chiave compilata al suo interno. Il meccanismo ha fatto esattamente ciò per cui era stato costruito. L’errore stava a monte.
Ne sono usciti due presidi, entrambi nel codice anziché in un documento. Costruire per la produzione senza indicare la chiave di produzione è ora un fallimento bloccante che ferma lo script. E la chiave di firma e la chiave di verifica devono stare sulla stessa macchina: controllare una firma con una chiave recuperata da qualche altra parte non dimostra proprio nulla.
Vale la pena enunciare con chiarezza la lezione generale. Un passo di verifica a cui vengono consegnate entrambe le metà di una coppia corrispondente proveniente dall’ambiente sbagliato passerà, e passerà con sicurezza. Gli schemi di firma proteggono dagli attaccanti. Non proteggono da lei.
Una licenza non è un binario, ed entrambi sono firmati
Firmiamo due cose diverse con la stessa chiave: i binari dell’agente e i file di licenza. È un pericolo, perché una firma sull’uno non dovrebbe mai essere accettata come firma sull’altro.
La separazione è strutturale anziché essere una convenzione di denominazione. Un file di licenza firma una busta di testo costruita dall’identificatore di formato, dal salt, dal nonce e dal testo cifrato, uniti da ritorni a capo. Un binario firma byte ELF grezzi. Lo script di installazione cerca il numero magico ELF prima di verificare qualunque cosa, quindi una firma di licenza valida non può mai essere presentata come firma valida di un binario.
Il flusso della licenza ha un’altra proprietà che vale la pena rubare: verifica la firma prima di derivare la chiave di decifratura. Argon2id con un costo di memoria di 64 MiB è costoso per costruzione, e fare quel lavoro per un file che non è mai stato nostro è un denial of service gratuito. I controlli di autenticità a basso costo vanno prima della crittografia costosa.
Che cosa non si ottiene
Firmare lega un artefatto a una chiave. Non dice nulla sul fatto che quella chiave sia ancora sua, e nulla sul fatto che la versione firmata valga qualcosa. Se la nostra chiave di firma trapela, ogni agente accetta ciò che l’attaccante costruisce, e ruotarla significa ricostruire ogni binario e sostituire l’ancora di fiducia su ogni host, perché la chiave è compilata all’interno. È il prezzo del fatto che la chiave sia un’ancora anziché un suggerimento, ed è il compromesso giusto, ma è un costo reale e va messo nel piano anziché in una nota a piè di pagina.
Ecco come funziona l’aggiornamento dell’agente in Provibr, dove l’agente gira sull’hardware del cliente, si connette solo verso l’esterno e conserva le credenziali dell’hypervisor su quell’host.