Blog ·
Hoe een getekende agent-update zichzelf weigert
Een checksum bewijst dat een download heel aankwam, niet dat je hem verstuurde. Wat het tekenen van een zelf bijwerkende agent werkelijk kost.
Door Provibr
Elk stuk software dat zichzelf bijwerkt is uitvoering van code op afstand die je zelf hebt binnengelaten. Dat is geen argument tegen automatische updates, want die zijn de enige realistische manier om te voorkomen dat een vloot agents op klanthardware wegrot. Het is een argument om precies te zijn over wat het updatemechanisme belooft, want de meeste beloven minder dan hun documentatie suggereert.
Wij draaien een agent op de servers van anderen. Hij praat met ons platform, hij voert commando's uit op hun hypervisor, en hij vervangt zijn eigen binary zodra wij een nieuwe versie uitbrengen. Dit is wat we moesten bouwen voordat we bereid waren hem dat te laten doen.
Een checksum beantwoordt de verkeerde vraag
Bijna elk zichzelf bijwerkend gereedschap publiceert een SHA-256 naast zijn download, en veel ervan houden het daarbij. Een checksum is nuttig, en hij doet niet het werk waarvan mensen denken dat hij het doet.
| Maatregel | Wat hij werkelijk bewijst | Wat hij niet kan zien |
|---|---|---|
| TLS op de download | De bytes kwamen van die host, over die verbinding | Niets over wie ze gemaakt heeft |
| SHA-256 naast het bestand gepubliceerd | De download is niet afgekapt of beschadigd | Wie het bestand kan wijzigen, kan het getal wijzigen |
| Handtekening over de bytes | Deze bytes zijn gemaakt door de houder van één privésleutel | Of die sleutel nog van jou is |
De middelste rij is het hele probleem. De checksum en het artefact staan meestal op dezelfde plek en zijn meestal beschrijfbaar door hetzelfde proces. Kan een aanvaller het ene vervangen, dan kan hij ook het andere vervangen, en de controle slaagt met enthousiasme.
De checksum is dus een goedkope controle vooraf en uitdrukkelijk niet de vertrouwensgrens. Hij vangt een afgekapte download in één keer doorlopen van bytes die we toch al aan het lezen waren. De handtekening is het anker.
Waar de publieke sleutel moet staan
Hier zit de eerste beslissing die bepaalt of dit alles werkelijk iets voorstelt: de sleutel waarmee gecontroleerd wordt kan geen bestand zijn dat naast de binary wordt meegeleverd, want dan levert het compromitteren van het afleverpad ook de sleutel op.
Onze agent krijgt de Ed25519-publieke sleutel van het platform in zich meegecompileerd, uit een omgevingsvariabele op het moment van bouwen. In een releasebuild is er geen andere bron. In een debugbuild kan een ontwikkelaar hem met een omgevingsvariabele naar een lokale sleutel wijzen, en dat pad wordt volledig weggecompileerd met een cfg-attribuut, want een sleutel die tijdens het draaien overschreven kan worden is geen anker maar een suggestie.
Twee details daaromheen doen er meer toe dan ze lijken.
Het buildscript verklaart dat het opnieuw moet draaien zodra die omgevingsvariabele verandert. Zonder die regel hergebruikt een releasebuild die een targetcache deelt met een eerdere build vrolijk de sleutel van de vorige compilatie, en niemand merkt het tot het bij een klant terechtkomt.
En de sleutelparser is streng op de codering in plaats van op de lengte. Een Ed25519-publieke sleutel in SPKI-vorm is een vaste prefix van 12 bytes gevolgd door 32 bytes; een X25519-sleutel is ook 32 bytes, met een andere algoritme-identificatie. Alles van 32 bytes accepteren zou een sleutel voor een ander algoritme aannemen en later falen, ergens waar het nergens op slaat.
Weiger voordat je downloadt
Zodra het platform de agent opdraagt bij te werken, loopt de agent de hele lijst met voorwaarden af voordat hij één byte ophaalt:
- Staat er een bruikbare SHA-256 in de opdracht?
- Staat er een download-URL in?
- Staat er een handtekening in, en is die precies 64 bytes?
- Draaien we deze versie al?
- Bestaat het geprivilegieerde installatiescript op deze host?
- Bestaat het lokale vertrouwensankerbestand?
- Heeft deze binary überhaupt een platformsleutel meegecompileerd?
Stap drie verdient de aandacht. Een ouder platform dat het handtekeningveld niet vult hoort hier luidruchtig te falen, in plaats van stilletjes terug te vallen op de zwakkere controle die we vroeger deden. Optionele verificatie is geen verificatie. Van het ontbrekende veld een harde fout maken is het verschil tussen een mechanisme en een gewoonte.
De volgorde van de laatste drie is ook bewust gekozen. De meldingen "op je host ontbreekt de geprivilegieerde stap, draai het installatieprogramma opnieuw" zijn bruikbaarder dan "deze binary heeft geen platformsleutel", dus komen ze eerst.
Teken de bytes, niet een beschrijving ervan
De handtekening dekt het artefact precies zoals het op schijf belandt. Zuiver Ed25519, geen voorafgaande hash, geen canonieke envelop, geen metadata erin. De reden is geen zuiverheid. De reden is dat twee volstrekt verschillende implementaties tot hetzelfde oordeel moeten komen.
Op de agent is dat ed25519-dalek met verify_strict, dat handtekeningen met torsiecomponenten weigert. Met één sleutel en één bestand hoort er precies één geldige handtekening te zijn, en de niet-strikte variant garandeert dat niet.
Op de host controleert het geprivilegieerde script dat de update werkelijk installeert dezelfde bytes met openssl pkeyutl -verify -pubin -rawin. Dezelfde sleutel, dezelfde bytes, hetzelfde antwoord. Hadden we vooraf gehasht, of een manifest getekend in plaats van het artefact, dan zou de controle op shellniveau onze omhulling moeten nabouwen, en zouden de twee controles uit elkaar gaan lopen.
Dat is ook de controle die we na elke release met de hand op de server draaien, tegen de publieke productiesleutel, voordat we geloven dat er een release heeft plaatsgevonden.
Het manifest naast het artefact draagt de versie, de buildaanduiding, de checksum, de omvang, het tijdstip van uitgifte, de soort artefact en de handtekening. Het is metadata voor het paneel, en niets ervan wordt vertrouwd: de handtekening erin staat over de binary, niet over het manifest.
De agent kan zijn eigen update niet installeren
De agent draait als een gebruiker zonder rechten. Hij kan niet schrijven in de map waar zijn eigen binary staat, en dat hoort hij ook niet te kunnen.
Dus zet hij klaar. Hij schrijft eerst de handtekening en de checksum in een wachtmap, en hernoemt pas daarna de geverifieerde binary ernaast op zijn plaats, want het geprivilegieerde script gebruikt de aanwezigheid van die binary als startsein en de andere twee bestanden moeten er op dat moment al staan. Daarna stopt hij netjes, en de servicebeheerder start hem opnieuw, met eerst een geprivilegieerde stap vooraf.
Dat script is het beschrijven waard, want zijn aannames zijn het tegenovergestelde van wat je zou raden.
Hij eindigt altijd met nul. Een falende stap vooraf zou verhinderen dat de dienst überhaupt start, en een kapotte update die een klant offline haalt is veel erger dan een kapotte update die stilletjes niet wordt toegepast.
De wachtmap wordt niet vertrouwd. Alles erin is geschreven door de agentgebruiker zonder rechten: de binary, de checksum, de handtekening. Wie die gebruiker overneemt kan alle drie vervangen. Daarom is de handtekening, gecontroleerd met de sleutel aan de geprivilegieerde kant, het enige dat beslist.
Hij kopieert voordat hij controleert. De klaargezette bytes worden precies één keer gekopieerd naar een vers tijdelijk bestand van root naast de bestemming, en elke controle daarna leest die kopie. Het bestand dat gecontroleerd wordt is exact de inode die op zijn plaats wordt hernoemd, dus er is geen venster waarin tussen de controle en de installatie een ander bestand wordt omgewisseld.
De rest is bewust saai: weiger symbolische koppelingen, eis dat de checksum precies 64 hexadecimale tekens is, eis dat het bestand begint met het ELF-magische getal, verruim de rechten pas naar uitvoerbaar nadat de handtekening klopt, start hem één keer als de servicegebruiker in plaats van als root, en hernoem hem pas daarna over de draaiende binary heen. Een handtekening zegt waar bytes vandaan komen. Hij zegt niet dat ze root verdienen.
De release die we met de verkeerde sleutel tekenden
Het mechanisme hierboven werkt. We hebben toch een kapotte release uitgebracht, en de manier waarop hij faalde is het nuttige deel.
Ons buildscript nam een vlag aan voor "dit is een productierelease". Die vlag bepaalde destijds alleen de publicatie, niet welke sleutel er getekend werd. Een release die gebouwd werd zonder ook de productiesleutel op de server te noemen, werd getekend met de ontwikkelsleutel, kreeg de licentiesleutel voor ontwikkeling ingebakken, en werd vervolgens lokaal gecontroleerd tegen diezelfde ontwikkelsleutel. De controle zei dat het artefact in orde was, want het was intern consistent. Het was alleen niet van ons.
Elke echte agent weigerde de update, terecht, want de handtekening kwam niet overeen met de sleutel die in hem gecompileerd zat. Het mechanisme deed precies waarvoor het gebouwd was. De fout zat er stroomopwaarts van.
Daar kwamen twee bewakingen uit, allebei in code en niet in een document. Bouwen voor productie zonder de productiesleutel te noemen is nu een harde fout die het script stopt. En de tekensleutel en de controlesleutel moeten op dezelfde machine staan: een handtekening controleren met een sleutel die ergens anders vandaan is gehaald bewijst helemaal niets.
De algemene les is het waard om ronduit te benoemen. Een verificatiestap die beide helften van een bij elkaar horend paar uit de verkeerde omgeving krijgt aangereikt, slaagt, en hij slaagt met overtuiging. Handtekeningen beschermen tegen aanvallers. Ze beschermen niet tegen jezelf.
Een licentie is geen binary, en allebei worden ze getekend
We tekenen twee verschillende dingen met dezelfde sleutel: agentbinaries en licentiebestanden. Dat is een risico, want een handtekening over het ene mag nooit worden geaccepteerd als een handtekening over het andere.
De scheiding is structureel in plaats van een naamafspraak. Een licentiebestand tekent een tekstenvelop die is opgebouwd uit de formaataanduiding, het zout, de nonce en de cijfertekst, samengevoegd met regeleindes. Een binary tekent rauwe ELF-bytes. Het installatiescript controleert op het ELF-magische getal voordat het iets verifieert, dus een geldige licentiehandtekening kan nooit worden gepresenteerd als een geldige binaryhandtekening.
De licentiestroom heeft nog een eigenschap die het stelen waard is: hij controleert de handtekening voordat hij de ontsleutelsleutel afleidt. Argon2id met een geheugenkosten van 64 MiB is met opzet duur, en dat werk doen voor een bestand dat nooit van ons was is een gratis vorm van dienstweigering. Goedkope echtheidscontroles horen vóór dure cryptografie.
Wat dit niet oplevert
Tekenen bindt een artefact aan een sleutel. Het zegt niets over de vraag of die sleutel nog van jou is, en niets over de vraag of de versie die je tekende ook maar iets waard is. Lekt onze tekensleutel, dan accepteert elke agent wat de aanvaller bouwt, en hem roteren betekent elke binary opnieuw bouwen en het vertrouwensanker op elke host vervangen, want de sleutel zit meegecompileerd. Dat is de prijs van een sleutel die een anker is in plaats van een suggestie, en het is de juiste ruil, maar het is een echte kostenpost en hij hoort in het plan te staan in plaats van in een voetnoot.
Zo werkt de agent-update bij Provibr, waar de agent op de eigen hardware van de klant draait, uitsluitend uitgaand verbindt, en de hypervisorgegevens op die host houdt.