Paketmanager machen die Softwareverwaltung zum Kinderspiel. Gelingt es allerdings einem Angreifer, Schadcode in die Paketquellen einzuschleusen, gerät der Paketmanager zur Malware-Schleuder. Ein Bot, der Pakete signiert, ermöglicht es, manipulierte Pakete zu erkennen.
Kaum jemand benutzt Linux ohne Paketmanager. Software samt ihrer Abhängigkeiten installieren zu können, ohne sie selbst kompilieren zu müssen, ist einer der Gründe, warum Linux-Distributionen überhaupt existieren. Um sicherzugehen, dass ein Paket wirklich vom Distributor stammt und nicht nach dem Bau manipuliert wurde – zum Beispiel durch einen Angreifer mit Zugriff auf den Paket-Mirror – kann man die Packages signieren.
Im Folgenden entwickeln wir einen Signbot, also einen Automaten, der die vom Debbot aus der letzten Folge dieser Serie gebauten Debian-Pakete mit einer digitalen Signatur versieht. Anhand dieser Signatur können Benutzer und andere Bots die Authentizität eines Pakets überprüfen und erkennen, ob es nach dem Kompilieren noch verändert wurde.
Echtheitszertifikat
Um zu verstehen, wie digitale Signaturen funktionieren, bietet sich ein kurzer Abstecher in die Kryptografie an. Beim Verschlüsseln von Daten unterscheidet man zwischen symmetrischer und asymmetrischer Verschlüsselung.
Bei symmetrischer Verschlüsselung kommt sowohl zum Ver- als auch zum Entschlüsseln einer Nachricht derselbe Key zum Einsatz. Bei asymmetrischer Verschlüsselung gibt es nicht nur einen, sondern zwei Schlüssel: einen öffentlichen und einen privaten (Public Key und Private Key), die zusammen ein Schlüsselpaar bilden. Daten, die mit dem einem Key verschlüsselt wurden, lassen sich nur mit dem anderen Schlüssel des Paars entschlüsseln.
Asymmetrische Verschlüsselung kann man daher in zwei Richtungen benutzen: Wer eine Nachricht mit dem öffentlichen Schlüssel des Adressaten verschlüsselt, stellt sicher, dass nur der Empfänger die Nachricht lesen kann, da nur er den passenden privaten Schlüssel besitzt. Umgekehrt kann man eine Nachricht auch mit dem eigenen privaten Schlüssel verschlüsseln. Da der zum Entschlüsseln nötige Key dann öffentlich ist, kann freilich jeder die Nachricht lesen. Deshalb spricht man hier nicht von Verschlüsselung, obwohl es sich mathematisch betrachtet um dieselbe Operation handelt. Da der zum Verschlüsseln verwendete Key privat ist, lässt sich aber durch das erfolgreiche Entschlüsseln schlussfolgern, dass der Besitzer des privaten Schlüssels die Nachricht erstellt hat. Asymmetrische Verschlüsselung bildet daher das Fundament für digitale Signaturen.
Asymmetrische Kryptografie hat allerdings den Nachteil, dass sie sehr langsam arbeitet. Beim Erstellen einer Signatur verschlüsselt man daher nicht die Daten selbst, sondern berechnet für sie einen Hash – eine Art kryptografisch sichere Prüfsumme – und verschlüsselt nur diesen. Ob eine digitale Signatur gültig ist, kann der Empfänger feststellen, indem er den Hash der signierten Daten selbst berechnet und das Ergebnis mit dem Hash in der entschlüsselten Signatur vergleicht.
Sendungsverfolgung
Ein Paket-Repository kann Signaturen an zwei Stellen verwenden. Zum einen lassen sich die Pakete im Repo direkt signieren. Das erlaubt dem Benutzer, die Echtheit jedes einzelnen Pakets zu prüfen. Zum anderen kann man die Metadaten des Repositorys signieren, also quasi das Inhaltsverzeichnis. Da die Metadaten auch die Hashes der Pakete enthalten, lässt sich so die Echtheit des Repositorys als Ganzes prüfen.
Beide Ansätze haben ihre Vorzüge und Nachteile. Bei unsignierten Metadaten könnte ein Angreifer mit Zugriff auf den Paket-Mirror einzelne Pakete – etwa Sicherheits-Updates – aus dem Repository entfernen, ohne dass der Benutzer es bemerkt. Sind nur die Metadaten signiert, könnte ein Angreifer Pakete auf dem Weg ins Repository manipulieren, etwa während sie hochgeladen werden oder in einem Incoming-Verzeichnis liegen und auf ihre Verarbeitung warten. Unser Build-System soll deshalb sowohl Pakete als auch Metadaten signieren.
Paketsignaturen erstellt oder verifiziert Debian mit dem Befehl »dpkg-sig« aus dem gleichnamigen Paket. Rufen Sie das Kommando mit dem Parameter »–sign« auf, signiert es die Pakete, die Sie auf der Befehlszeile übergeben. Um die Signatur eines oder mehrerer Pakete zu prüfen, rufen Sie Dpkg-sig mit dem Parameter »–verify« auf. Da man Debian-Pakete mehrfach signieren darf, unterscheidet man Signaturen anhand der sogenannten Rolle, die die Signatur erstellt hat. Der Erzeuger des Pakets hat die Rolle »builder«, die er Dpkg-sig mit einem Aufruf nach dem Schema »dpkg-sig –sign builder Paket.deb« mitteilt.
Dpkg-sig erstellt und prüft Signaturen allerdings nicht selbst, sondern ruft dazu GnuPG auf. In der Vorgabe generiert es die Signatur mit dem Schlüssel, den Sie als primären Schlüssel in GnuPG festgelegt haben. Durch Übergabe des Parameters »-k Schlüssel-ID« können Sie Dpkg-sig mitteilen, dass es einen anderen Schlüssel verwenden soll.
Wir schreiben also als Erstes eine Funktion »sign_packages()«, der wir eine Schlüssel-ID und ein oder mehrere Pakete übergeben. Die Funktion soll nichts weiter tun, als alle Pakete zu signieren (Listing 1).
Listing 1
Signieren von Paketen
sign_packages() {
local key="$1"
local packages=("${@:2}")
dpkg-sig --sign builder -k "$key" "${packages[@]}"
return "$?"
}
Zusammenhängend
Debbot speichert alle Daten, die beim Bau eines Pakets anfallen, in einem Verzeichnis, das sich Kontextverzeichnis oder einfach nur Kontext nennt. War Debbot erfolgreich, versendet es auch eine Build-Nachricht, die den Kontext sowie eine Liste aller gebauten Pakete enthält. Ein Beispiel für eine solche Nachricht zeigt Listing 2. Empfängt Signbot eine solche Nachricht, soll das Tool alle Pakete im Kontext signieren und wiederum eine Sign-Nachricht senden, um andere Prozesse auf die signierten Pakete aufmerksam zu machen.
Listing 2
Build-Nachricht
{
"type": "build",
"context": "toolbox-stable-20230228-231547-1833",
"repository": "/tmp/toolbox",
"branch": "stable",
"ref": "af8667386903620a8795d1e6b07ede36d6525631",
"artifacts": [ "/var/lib/dbs/contexts/toolbox-stable-20230228-231547-1833/toolbox_0.3.5-1677593747_all.deb" ]
}
Da die Bots mit PubSub-Nachrichten kommunizieren, empfangen aber nicht nur Signbots Build-Nachrichten, sondern alle Bots und Skripte, die das entsprechende Topic abonniert haben. Wir müssen also davon ausgehen, dass auch andere Prozesse gerade auf denselben Kontext zugreifen. Daher können wir die Pakete nicht direkt signieren, sondern müssen sie erst in ein Unterverzeichnis kopieren, das kein anderer Prozess verwendet.
Wir schreiben dazu eine Funktion »prepare_signdir()«, die in einem Kontext ein Signierverzeichnis erstellt und die Pakete dorthin kopiert. Der Name des Verzeichnisses umfasst den Host-Namen und die Prozess-ID des Signbots. Dadurch lässt sich sicherstellen, dass uns keine anderen Signbots – sollten welche laufen – in die Quere kommen. Alle Ausgaben in der Funktion wollen wir in einer Log-Datei speichern, um im Fehlerfall die Diagnose zu erleichtern.
Wir erledigen die Arbeit daher in einer Subshell und leiten mit »&>>« sowohl Standard- als auch Fehlerausgabe in eine Log-Datei um. Sie trägt denselben Namen wie das Signierverzeichnis und hat die Dateiendung ».log« (Listing 3).
Listing 3
Vorbereiten zum Signieren
prepare_signdir() {
local context="$1"
local packages=("${@:2}")
local contextdir
local signdir
contextdir="/var/lib/dbs/contexts/$context"
signdir="$contextdir/signbot-$HOSTNAME-$$"
( cd "$contextdir" &&
mkdir -p "$signdir" &&
cp "${packages[@]}" "$signdir/."
) &>> "$signdir.log"
return "$?"
}
Analog zu »prepare_context()« schreiben wir nun eine Funktion »sign_context()«, die alle Pakete in einem Kontext signiert. Die Funktion bekommt als Argumente den Namen des Kontexts sowie die Namen der Pakete übergeben. Zum Signieren der Pakete dient die zuvor implementierte Funktion »sign_packages()«, die wir aus dem Signierverzeichnis heraus aufrufen.
Außer den Pfaden der Pakete benötigt die Funktion aber auch den GPG-Schlüssel, mit dem sie die Pakete signieren soll. Da der Benutzer entscheiden muss, welcher Schlüssel zu verwenden ist, legen wir einfach fest, dass der Bot ihn auf der Befehlszeile mit der obligatorischen Option »–gpg-key« übergeben bekommt. Wir können hier deshalb davon ausgehen, dass »opt_get “gpg-key”« einen gültigen Schlüssel liefert. Die Funktion zum Signieren rufen wir mit einer Subshell aus dem Signierverzeichnis heraus auf und hängen wie gehabt alle Ausgaben der Subshell an die Log-Datei an (Listing 4).
Listing 4
Kontext signieren
sign_context() {
local context="$1"
local packages=("${@:2}")
local key
local signdir
key=$(opt_get "gpg-key")
signdir="/var/lib/dbs/contexts/$context/signbot-$HOSTNAME-$$"
( cd "$signdir" &&
sign_packages "$key" "${packages[@]}"
) &>> "$signdir.log"
return "$?"
}
360 Grad
Die beiden Funktionen kombinieren wir mithilfe einer weiteren Funktion namens »handle_build_message()«, die (wie der Name erahnen lässt) beim Empfang einer Build-Nachricht aufgerufen wird. Der Aufrufer übergibt der Funktion den IPC-Endpunkt, den Kontext sowie die Build-Nachricht.
Als Erstes wollen wir die Paketliste sowie die Attribute »repository«, »branch« und »ref« aus der Nachricht ziehen. Die Nachricht enthält die absoluten Pfade der Pakete, es sind aber nur die Paketnamen notwendig – also der Teil nach dem letzten Slash. Daher müssen wir die Ausgabe von Jq manipulieren, bevor wir sie an »readarray« weitergeben. Der Befehl »cut -d Trennzeichen -f Spaltennummer« extrahiert eine Spalte aus einer Tabelle von Daten. Beispielsweise zieht der Aufruf »cut -d ‘,’ -f 3« die dritte Spalte aus einer CSV-Datei.
Da Cut die Spalten von vorne zählt, können wir es aber nicht anweisen, die letzte Spalte auszugeben, ohne die Spalten vorher gezählt zu haben. Wir nutzen deshalb den Befehl »rev«, um die Ausgabe von Jq umzudrehen. Aus der Zeile »/home/tux/linux-image.deb« wird auf diese Weise »bed.egami-xunil/xut/emoh/«. Wir fischen daraus mit »cut -d ‘/’ -f 1« die gesuchte letzte Spalte ab, die wir mit einem weiteren »rev« in die ursprüngliche Richtung zurückdrehen.
Beim Auslesen der anderen Attribute aus der Nachricht benutzen wir einen weiteren Trick. Übergeben wir Jq einen Filter-String wie »”\(.repository) \(.branch)”« (die doppelten Anführungszeichen sind Teil des Strings), dann liest es mehrere Attribute auf einmal aus, die wir mit »read« den entsprechenden Variablen zuweisen. Nachdem wir sichergestellt haben, dass das gelesene Array beziehungsweise die übrigen Attribute nicht leer sind, können wir die Funktionen »prepare_signdir()« und dann »sign_context()« aufrufen.
Die Arbeit von Signbot ist damit aber noch nicht erledigt. Wenn die Pakete erfolgreich signiert wurden, müssen wir eine Sign-Nachricht versenden, damit andere Bots die signierten Pakete weiterverarbeiten. Das erledigen wir mithilfe der Funktion »send_sign_message()«, die eine Nachricht erzeugt und verschickt, die der von Debbot erhaltenen Build-Nachricht ähnelt. Im Fehlerfall versenden wir Nachrichten, die neben dem Kontext den Rückgabewert von Dpkg-sig (beziehungsweise den Wert »1«) sowie eine Fehlermeldung enthalten (Listing 5). Die beiden Funktionen zum Versenden von Nachrichten finden Sie im Download-Bereich zu diesem Artikel.
Listing 5
Build-Nachrichten behandeln
handle_build_message() {
local endpoint="$1"
local context="$2"
local build_msg="$3"
local packages
local repository
local branch
local ref
local -i err
readarray -t packages < <(jq -e -r '.artifacts[]' <<< "$build_msg" |
rev | cut -d '/' -f 1 | rev)
read -r repository branch ref < <(jq -e -r '"\(.repository) \(.branch) \(.ref)"' <<< "$build_msg")
if (( ${#packages[@]} == 0 )) || [[ -z "$repository" ]] ||
[[ -z "$branch" ]] || [[ -z "$ref" ]]; then
send_error_message "$endpoint" "$context" "1" "Build-Nachricht ist unvollständig"
return 1
fi
if ! prepare_signdir "$context" "${packages[@]}"; then
send_error_message "$endpoint" "$context" "1" "Konnte Pakete nicht kopieren"
return 1
fi
sign_context "$context" "${packages[@]}"
err="$?"
if (( err == 0 )); then
send_sign_message "$endpoint" "$context" "$repository" "$branch" "$ref" "${packages[@]}"
else
send_error_message "$endpoint" "$context" "$err" "Konnte Pakete nicht signieren"
fi
return "$err"
}
Aus dem Kontext gerissen
Da der oben geschriebene Code den Kontext aus der Nachricht benutzt, um Pfade zu konstruieren, muss der Aufrufer von »handle_build_message()« unbedingt den Kontext validieren. Anderenfalls könnte man den Bot mit einem Kontext wie »../../../../../etc/« dazu bringen, in ein Verzeichnis zu wechseln, in dem er nichts verloren hat.
Wir validieren den Kontext, indem wir ihn an den Pfad »/var/lib/dbs/contexts/« anhängen und mit »realpath« auflösen. Handelt es sich bei dem aufgelösten Pfad um ein Unterverzeichnis von »/var/lib/dbs/contexts/«, haben wir einen gültigen Kontext. Der Pfad »/var/lib/dbs/contexts/« könnte allerdings, je nachdem, wie der Benutzer sein Build-System konfiguriert hat, einen symbolischen Link enthalten. Daher müssen wir auch ihn mit »realpath« auflösen. Die Validierung implementieren wir in der Funktion »context_is_valid()« (Listing 6).
Listing 6
Valider Kontext
context_is_valid() {
local ctx="$1"
local root
local root_real
local ctx_real
root="/var/lib/dbs/contexts"
if root_real=$(realpath "$root" 2>/dev/null) &&
ctx_real=$(realpath "$root/$ctx" 2>/dev/null) &&
[[ "$root_real/$ctx" == "$ctx_real" ]] &&
[ -d "$ctx_real" ]; then
return 0
fi
return 1
}
Nun ist schon fast der gesamte Bot implementiert. Es fehlt nur noch der Code, um den Bot zu starten und Nachrichten zu empfangen. Wie schon im letzten Beitrag wollen wir unseren Bots mit den Parametern »–subscribe-to« und »–publish-to« auf der Befehlszeile mitteilen, von wo sie Nachrichten empfangen und wohin sie im Erfolgsfall Nachrichten senden sollen.
Da sich GnuPG nur bedingt parallel ausführen lässt, verzichten wir darauf, eine Option »–team« wie in Debbot zu implementieren. Stattdessen bekommt Signbot eine Option »–gpg-key«, mit der der Benutzer dem Bot mitteilen kann, welchen Schlüssel er zum Signieren von Paketen verwenden soll. Abgesehen von den unterschiedlichen Befehlszeilenoptionen und der Validierung des Kontexts enthält der Unterbau des neuen Bots – einschließlich der Funktionen zum Versenden von Nachrichten – keine Neuerungen gegenüber Watchbot und Debbot. Deshalb beleuchten wir den Code in diesem Beitrag nicht noch einmal gesondert.
Flugversuch
Bevor wir den neuen Bot aus dem Nest stoßen und ihm beim Fliegen (oder Abstürzen) zusehen, fällt noch etwas Vorbereitung an. Wie gehabt, speichern wir den Bot zunächst unter »/usr/local/share/dbs/« und erstellen einen Symlink auf den Bot unter »/usr/local/bin/signbot/«.
Das verteilte System besteht mittlerweile aus drei Bots, und es gibt vier PubSub-Topics, die es zu beobachten gilt. Da lohnt es sich, »readcommit.sh« etwas umzuschreiben, also das Skript, mit dem wir bisher die Kommunikation zwischen den Bots beobachtet haben. Zum einen soll das Skript nicht nur einen, sondern mehrere Topics abonnieren können. Den Trick, mit dem wir aus mehrfach übergebenen Optionen ein Array konstruieren, haben wir bereits in einem früheren Beitrag behandelt: Wir definieren die Option mit einem Callback.
Außerdem soll das Skript nicht nur eine Nachricht anzeigen, sondern so lange laufen, bis der Benutzer es per [Strg]+[C] beziehungsweise mit einem Signal beendet. Dazu weisen wir die Bash mit »trap« an, eine Funktion auszuführen, sobald ein »SIGINT«-Signal eintrifft. Diese Funktion setzt dann ein globales Flag, das die Hauptschleife des Skripts unterbricht. Sind Sie dieser Beitragsreihe bis hierhin gefolgt, sollten Sie bei der Umsetzung keine Schwierigkeiten haben. Eine Beispielimplementierung finden Sie in der Datei »listener.sh« im Download-Bereich zu diesem Artikel.
Um den neuen Bot zu starten, ist außerdem ein GPG-Schlüssel nötig, mit dem der Bot Pakete signieren kann. Einen neuen Schlüssel können wir mit dem Befehl »gpg –generate-key« generieren. Falls Signbot beim Signieren von Dpkg-sig beziehungsweise GPG nach dem Passwort des Schlüssels gefragt wird, vermag er der Aufforderung allerdings nicht nachzukommen. Wir müssen den Schlüssel daher ohne Passwort generieren, auch wenn GPG uns mehrfach davon abrät. Den Bot so zu schreiben, dass er ein Passwort an GPG übergibt, ist nicht unbedingt kompliziert. Wir würden dann aber vor der Frage stehen, wie wir das Passwort speichern. Verschlüsseln wir es nicht, erzielen wir keinen Sicherheitsgewinn. Speichern wir es verschlüsselt, brauchen wir wiederum ein Passwort, um das Passwort zu entschlüsseln – wir bewegen uns im Kreis.
Da wir den GPG-Schlüssel also unverschlüsselt speichern, müssen wir auf anderem Weg dafür sorgen, dass er geschützt bleibt. In einem Produktivsystem sollten Sie Signbot daher mindestens unter einem separaten Benutzerkonto ausführen und den Zugriff auf den Schlüssel nur diesem Benutzer gestatten. Die Entscheidung, welche Sicherheitsmaßnahmen darüber hinaus angebracht sind, hängt von der Betriebsumgebung, dem Inhalt der Pakete sowie vielen von weiteren Faktoren ab.
Haben wir den GPG-Schlüssel generiert, finden wir mit »gpg -k« seine Schlüssel-ID heraus und übergeben sie mit »signbot -k GPG-ID« an den Bot. Damit der etwas zu tun bekommt, müssen wir auch die übrigen Bots starten. Das erledigen wir mit denselben Befehlen wie beim letzten Mal: Zuerst klonen wir ein debianisiertes Git-Repository (Listing 7, Zeile 1). Dann starten wir Watchbot und Debbot. Zur Kommunikation benutzen wir die voreingestellten Topics (Zeile 2 und 3). Bevor wir das Repository verändern und damit die Bots in Bewegung versetzen, starten wir noch das neue Skript zum Beobachten der PubSub-Topics (Zeile 4).
Listing 7
Bots starten
$ git clone https://github.com/m10k/toolbox -b stable /tmp/toolbox $ watchbot -i 15 -r "/tmp/toolbox#stable" $ debbot -a -b stable $ ./listener.sh -t commits -t builds -t signs -t errors [...] $ cd /tmp/toolbox $ echo "" >> README.md $ git commit -a -m "DBS Test"
In einem separaten Terminal betreten wir das Repository und verändern es ein wenig. Die Befehle aus den letzten beiden Zeilen von Listing 7 hängen einen Zeilenumbruch an die README-Datei und fügen einen Commit an die Historie des Repositorys an. Kurz darauf sollte das Testskript erst eine Commit-Nachricht und etwas zeitverzögert eine Build-Nachricht ausgeben.
Bis hierhin läuft alles wie beim letzten Mal. Da Signbot mit den voreingestellten Topics gestartet wurde, sollte es die Build-Nachricht ebenfalls empfangen und sich in Bewegung setzen. Sofern wir keine Fehler gemacht haben, erhalten wir eine Nachricht vom Typ »sign« (Abbildung 1). Geht beim Signieren etwas schief, sollte ebenfalls eine Nachricht eintrudeln. Sie hat in diesem Fall den Typ »sign-error« und weist per Fehlermeldung darauf hin, warum der Bot gescheitert ist.
Zuletzt überprüfen wir noch einmal manuell, dass die Pakete korrekt signiert sind. Dazu öffnen wir ein Terminal und wechseln in das Kontextverzeichnis, das wir der Ausgabe des Skripts »listener.sh« entnehmen. Anschließend prüfen wir mit Dpkg-sig die Signatur des Pakets in der Nachricht (Listing 8, erste Zeile). In der Ausgabe des Befehls findet sich die Zeichenkette »GOODSIG«, gefolgt von der Schlüssel-ID, die wir an Signbot übergeben hatten. Wir wissen also, dass das Paket mit dem richtigen Schlüssel signiert und nach dem Signieren nicht mehr verändert wurde.
Listing 8
Signatur prüfen
$ dpkg-sig --verify Verzeichnis/Paket.deb $ echo "hallo" | dd seek=2048 bs=1 count=5 conv=notrunc of=Paket.deb
Für die Gegenprobe überschreiben wir an beliebiger Stelle einige Bytes im Paket (Listing 8, zweite Zeile). Bei der anschließenden Prüfung weist die Ausgabe »BADSIG« darauf hin, dass die Signatur nicht mehr stimmt (Abbildung 2). Wir können nun also Übertragungsfehler und böswillige Veränderungen einfach erkennen.
Ausblick
Unser Build-System ist mittlerweile imstande, Pakete automatisiert zu bauen und zu signieren. Damit wir sie auf einem System installieren können, brauchen wir nur noch ein Apt-Repository und einen Bot, der es verwaltet. Im nächsten Beitrag geht es daher um den Befehl »reprepro« und um einen Bot, der damit Apt-Repositories erstellt und ihnen Pakete hinzufügt. (jcb)







