Kai Ole Hartwig
15 Min. Lesezeit
Niedrig

Wer hat diesen Commit wirklich signiert? Wie ich Mensch, KI-Agent und Bot mit eigener Identität committen lasse

Wer steckt wirklich hinter einem Commit? Eine Signatur beantwortet genau diese eine Frage. Auf einer Plattform, die ich allein betreibe, aber konsequent automatisiere, gibt es darauf mehrere ehrliche Antworten zugleich: Ich committe von Hand. Ein KI-Coding-Tool committet in meinem Auftrag, den ganzen Tag. Ein Dependency-Bot öffnet wöchentlich Dutzende Merge Requests. Träten alle drei in der Historie als „ich“ auf, wäre der Audit-Trail reine Fiktion — dabei soll eine Signatur genau das verhindern. Also signiert bei mir jeder Akteur als er selbst, mit einem eigenen Schlüssel. Eine einzige Regel entscheidet dabei, wie viel Freiraum jeder bekommt: Signieren beweist Identität, verleiht aber keine Autorität.commit-Autonomie ≠ Publish-Autorität. Eine Identität darf durchaus eigenständig Änderungen autorisieren, ohne deshalb auch das Recht zu haben, sie zu veröffentlichen — genau diese Trennung lässt einen Agenten unbeaufsichtigt arbeiten, während weder ein gestohlener Schlüssel noch ein kompromittierter Bot je eine Version schneiden kann.

Der Cast: drei Identitäten, drei Schlüssel

IdentitätRolleMechanismusStatus
Kai (ich)Mensched25519-sk, resident auf YubiKey, PIN + Touch pro Signatur, privater Schlüssel verlässt nie die HardwareVerified
KOH Coding AgentKI-Coding-Agented25519, Software-Schlüssel auf dediziertem Service-Account, Token nur mit write_repositoryVerified
KOH Renovate BotDependency-BotGPG statt SSH (weil API-Commits), privater Schlüssel in geschützter CI-VariableVerified

Für mich selbst gilt der strengste Maßstab, alles andere ist als Ausnahme davon definiert. Zwar schreibt die KI einen erheblichen Teil meines Codes — auftreten darf sie dabei aber nie unter meinem Namen, und veröffentlichen darf sie schon gar nicht auf eigene Faust. Der Bot wiederum öffnet pro Woche Dutzende Merge Requests und signiert jeden einzelnen — nur eben auf einem technisch anderen Weg als die beiden anderen, weil er über die API committet statt lokal per git commit.

Teil I — Der Mensch: ein Schlüssel, der in Hardware lebt

Für mich selbst gilt der strengste Maßstab. Mein Signing-Key liegt als resident credential direkt auf einer YubiKey, mit gesetztem verify-required — PIN und physisches Anfassen sind damit bei jeder einzelnen Signatur Pflicht, und das private Schlüsselmaterial verlässt die Hardware nie. Der Effekt: Ohne die Hardware in der Hand lässt sich schlicht nicht signieren — selbst mit einem entsperrten, gestohlenen Notebook nicht.

macOS-Umweg

Direkt der erste Versuch scheiterte: Key enrollment failed: invalid format. Der Grund: Apples mitgeliefertes ssh-keygen bringt keinen FIDO-Provider mit und kann deshalb grundsätzlich nicht mit einem Sicherheitsschlüssel sprechen. Abhilfe schafft Homebrews OpenSSH-Paket, das gegen libfido2 gebaut ist — die eigentliche Security-Key-Logik steckt in dessen ssh-sk-helper. Liegt der einmal im PATH, funktioniert die Resident-Key-Erzeugung ohne weiteres Zutun.

 

# FIDO-fähiges ssh-keygen installieren
brew install openssh

# Resident Key wird DIREKT auf der YubiKey erzeugt; PIN + Touch pro Nutzung erzwungen
ssh-keygen -t ed25519-sk -O resident -O verify-required \
  -f ~/.ssh/signing_sk -C "du@example.com"

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/signing_sk.pub
git config --global commit.gpgsign true

 

Der öffentliche Schlüssel wandert danach als SSH-Signing-Key in den GitLab-Account — nicht als Auth-Key, GitLab unterscheidet und trackt beides separat. Wichtig ist außerdem: Die Committer-E-Mail in der Git-Config muss eine bestätigte Adresse auf genau diesem Account sein. Fehlt eines von beidem, bleibt die Signatur zwar gültig, GitLab zeigt aber trotzdem „Unverified“. Offline verifizieren lässt sich das Ganze über eine repository-lokale allowed_signers-Datei, ganz ohne dem Server vertrauen zu müssen:

 

# .gitsigners / allowed_signers — eine Zeile pro vertrauenswürdigem Signierer
du@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...

# lokal prüfen, ohne GitLab zu fragen
git config gpg.ssh.allowedSignersFile ~/.gitsigners
git log --show-signature -1

 

Den alten Schlüssel in Rente schicken

Bis dahin hatte ich mit einem gewöhnlichen, auf der Platte liegenden ed25519-Schlüssel signiert. Der flog raus, sobald der Hardware-Schlüssel lief — nur für Authentifizierung blieb er noch im Einsatz. Damit bleibt weltweit genau ein Ding übrig, das als „ich“ committen kann, und es ist ein physisches Objekt. Praktischer Nebeneffekt: Ein Resident Key ist portabel. ssh-keygen -K leitet ihn auf jeder Maschine neu her, an der die YubiKey steckt.

Teil II — Der Coding-Agent: Autonomie ohne Person

Ein gutes Stück meines Codes schreibt inzwischen ein KI-Coding-Tool. Eine YubiKey kann es nicht anfassen, und meine Identität soll es sich schon gar nicht ausleihen — was ein Agent produziert hat, soll die Historie auch so ausweisen. Also bekommt er eine eigene: einen dedizierten GitLab-Service-Account („KOH Coding Agent“) mit bestätigter E-Mail, einen Software-ed25519-Signing-Key auf diesem Account, und ein Token, das strikt auf write_repository begrenzt ist.

 

# reiner Software-Key: keine Passphrase, damit er unbeaufsichtigt funktioniert
ssh-keygen -t ed25519 -f ~/.ssh/agent -N "" -C "agent@example.com"
# ~/.ssh/agent.pub als SIGNING-Key auf dem Service-Account registrieren

 

Das Publish-Gate

Hier wird aus commit-Autonomie ≠ Publish-Autorität echte Konfiguration. Der Agent trägt die Developer-Rolle: branchen, pushen, Merge Requests öffnen — alles erlaubt. Nicht erlaubt ist:

Selbst vollständig kompromittiert richtet er im schlimmsten Fall überprüfbare, ungemergte Feature-Branches an. Autorisierte Automatisierung mit echter Aufgabe — Renovate, der Release-Bot — behält dabei ihre höheren Rechte; die Grenze verläuft über die Rolle, nicht über ein pauschales Bot-Verbot.

Damit wirklich immer er selbst signiert

Hier saß der Knoten, an dem ich zweimal ansetzen musste. Im ersten Anlauf verwies ich den Agenten per GIT_CONFIG_GLOBAL auf seine eigene globale Git-Config — die Commits liefen trotzdem unter meiner E-Mail auf. Der Grund: Viele Repos pinnen lokal eine eigene user.email in .git/config, und lokale Config sticht eine ausgetauschte globale immer aus. Signiert hat der Agent damit technisch korrekt, nur eben der falschen Identität zugeordnet — was für genau diese Identität als „Unverified“ erscheint.

Die Lösung liegt eine Ebene höher: Config auf Umgebungsvariablen-Niveau injizieren, dieselbe Priorität wie git -c — höher geht es nicht, das schlägt jede Config-Datei:

 

export GIT_AUTHOR_NAME="Agent"    GIT_AUTHOR_EMAIL="agent@example.com"
export GIT_COMMITTER_NAME="Agent" GIT_COMMITTER_EMAIL="agent@example.com"

# GIT_CONFIG_* Env-Variablen == `git -c`, schlägt lokale/globale/System-Dateien
export GIT_CONFIG_COUNT=3 \
  GIT_CONFIG_KEY_0=user.signingkey GIT_CONFIG_VALUE_0=~/.ssh/agent.pub \
  GIT_CONFIG_KEY_1=gpg.format      GIT_CONFIG_VALUE_1=ssh \
  GIT_CONFIG_KEY_2=commit.gpgsign  GIT_CONFIG_VALUE_2=true

 

Überall würde ich das trotzdem nicht anwenden wollen — weder soll die Agent-Identität in ein fremdes Repo durchsickern, noch in mein eigenes Terminal bluten. Ein Pre-Commit-Tool-Hook grenzt das deshalb ein: Bevor der Agent git commit, tag oder push ausführt, prüft der Hook den Remote des Repos und injiziert die Identität nur dann, wenn der Remote mein eigener Host ist. Überall sonst — und in meinem eigenen Terminal, das der Hook nie zu Gesicht bekommt — bleibt alles beim Alten.

 

cmd=$(jq -r '.command')
# nur git commit/tag/push, nur mein Host
echo "$cmd" | grep -qE 'git .*(commit|tag|push)' || exit 0
git -C "$dir" remote -v | grep -q 'git.example.com' || exit 0
# Kommando mit vorangestelltem Agent-Env neu schreiben, dann erlauben
emit_updated_command "$AGENT_ENV $cmd"

 

Bei Pushes gilt dieselbe Eingrenzung: Ein host-gepinnter Credential-Helper reicht Git das gescopte Token des Agenten, der Bot pusht also als Bot. Läuft das Token einmal ab, schlagen Pushes laut fehl — statt still auf meine eigenen Zugangsdaten zurückzufallen.

Teil III — Renovate: ein Bot, der seine eigenen Updates signiert

Bei Renovate liegt der Fall unbequemer, und zwar aus rein mechanischen Gründen: Standardmäßig entstehen Renovates Commits über die GitLab-API — serverseitig, wo es kein lokales git commit gibt, an das sich überhaupt ein Schlüssel hängen ließe. Welchen Key man dem Bot auch gibt, die Commits landen erst einmal unsigniert. Die Lösung braucht zwei Einstellungen und einen Schlüssel, der auf einem Umweg übergeben wird.

Zunächst zwingt man Renovate, lokal zu committen — nur so lässt sich überhaupt eine Signatur anbringen. Dann bekommt der Bot einen GPG-Key, dessen UID exakt der bestätigten E-Mail seines Accounts entspricht. Öffentlich landet der Key unter „GPG Keys“ auf dem GitLab-Account des Bots, privat ausschließlich in einer geschützten CI-Variable.

 

# die UID-E-Mail MUSS eine bestätigte Adresse auf dem Bot-Account sein
gpg --quick-generate-key "Renovate Bot <bot@example.com>" ed25519 sign never

# öffentlicher Schlüssel  → GitLab „GPG Keys“ des Bots
# armored private key     → geschützte CI-Variable: RENOVATE_GIT_PRIVATE_KEY

 

variables:
  RENOVATE_GIT_AUTHOR: 'Renovate Bot <bot@example.com>'   # muss der GPG-UID entsprechen
  RENOVATE_PLATFORM_COMMIT: 'false'                       # lokal committen, damit signiert werden kann
  # Renovate mappt RENOVATE_GIT_PRIVATE_KEY automatisch auf config.gitPrivateKey

 

Die Falle dabei: Weichen Author-E-Mail und GPG-UID auch nur minimal voneinander ab — oder ist die E-Mail auf dem Account schlicht nicht bestätigt —, markiert GitLab jeden betroffenen Commit still als „Unverified“, ganz ohne Fehlermeldung. Und ein Detail, das mir damals wie reine Trivia vorkam: GPG-Signaturen laufen bei der Verifikation über einen komplett anderen Code-Pfad als SSH-Signaturen. Diesen Gedanken bitte im Kopf behalten — er wird im nächsten Teil zum entscheidenden Puzzlestück.

Teil IV — Der Tag, an dem jeder grüne Haken erlosch

Kaum war der Hardware-Key fertig verdrahtet, machte ich einen Test-Commit und öffnete ihn in GitLab. „Unverified“. Genauso der Commit des Agenten. Genauso ein Commit, den ich einen Tag zuvor noch mit dem alten Schlüssel signiert hatte. Drei verschiedene Schlüssel, zwei Identitäten — alle drei auf einen Schlag abgelehnt. Lokal dagegen völlige Zufriedenheit:

 

git log --show-signature

Good "git" signature for du@example.com with ED25519 key
# … und GitLab sagt: Unverified

 

Kryptografisch lokal einwandfrei. Serverseitig trotzdem abgelehnt. Genau in dieser Lücke steckte das ganze Rätsel.

Ich prüfte die naheliegenden Dinge, zweimal. Der Schlüssel war korrekt als Signing-Key registriert. Die Committer-E-Mail war bestätigt und dem richtigen Account zugeordnet. Die Signatur verifizierte lokal einwandfrei. GitLab identifizierte sogar den richtigen Schlüssel und den richtigen Nutzer — weigerte sich aber trotzdem, das Ganze als verifiziert zu bezeichnen. Die Dokumentation nennt genau einen Grund, warum eine gültige SSH-Signatur „Unverified“ zeigt: eine nicht übereinstimmende Committer-E-Mail. Traf hier nachweislich nicht zu.

Also blieb nur noch der Quellcode. Und tatsächlich: GitLab Enterprise liefert einen Override der SSH-Prüfung mit, der noch vor der eigentlichen Logik greift:

 

# EE-Override — der CA-Check hat das erste Wort
def calculate_verification_status
  ca_verification_status || super
end

# und dieser CA-Service:
if verified? then :verified_ca
elsif root_namespace.enforce_ssh_certificates? then :unverified
end

 

Da war es: Eine Top-Level-Gruppe hatte enforce_ssh_certificates aktiviert — nur ohne dass dahinter je eine Certificate Authority konfiguriert worden wäre. Für jeden mit einem gewöhnlichen Schlüssel signierten Commit lieferte die Enforcement-Prüfung deshalb pauschal :unverified und brach die gesamte weitere Logik ab, noch bevor GitLab überhaupt Schlüssel oder E-Mail ansah. Mein Setup war nie das Problem. Eine einzige Gruppen-Einstellung überstimmte still alle anderen.

Zur Einordnung noch ein wichtiger Punkt: Dieses konkrete Feature — gruppenweite SSH-Zertifikats-Durchsetzung mit eigener Certificate Authority — gibt es nur in Premium/Ultimate, und aktuell ausschließlich auf GitLab.com (SaaS), nicht auf self-managed-Instanzen. Self-managed-Setups haben stattdessen ein separates, instanzweites SSH-Zertifikats-Feature (gitlab-sshd), das anders funktioniert. Wer nur Community Edition oder eine ältere self-managed-Instanz betreibt, kann diesen exakten Bug also gar nicht treffen — die allgemeine Lehre („Verifikations-Overrides oberhalb der eigentlichen Prüfung lesen“) gilt trotzdem uneingeschränkt.

Zwei Wendungen kamen noch dazu. Erstens betraf es gleich zwei Top-Level-Gruppen — die zweite fiel erst auf, als ein Bot-Commit in einer anderen Gruppe rot blieb, obwohl ich die erste längst „repariert“ hatte. Zweitens taucht der Enforcement-Schalter selbst überhaupt nicht in der REST-API auf — für die CA-Zertifikate existiert zwar eine dokumentierte Gruppen-API, das bloße Ein/Aus des Enforcements sitzt aber bislang ausschließlich unter Settings → General → Permissions oder in der Rails-Konsole. Und bestehende Commits behalten ihr gecachtes Urteil, das Umlegen des Flags allein reicht also nicht — die gespeicherten Signaturen für bereits geprüfte Commits müssen zusätzlich invalidiert werden.

Die Behebung

enforce_ssh_certificates auf jeder Top-Level-Gruppe abschalten, die keine CA dahinter hat, dann die gecachten CommitSignature-Datensätze der betroffenen Commits invalidieren, damit sie neu verifizieren. Neue Commits rechnen von selbst neu. In derselben Sekunde stand alles auf Grün — Mensch, Agent und alter Schlüssel gleichermaßen.

Und Renovate? Blieb die ganze Zeit unberührt. GPG läuft über einen völlig eigenen Verifikationspfad, den der SSH-Zertifikats-Check gar nicht erst streift. Genau in diesem Moment hörte „SSH und GPG nehmen unterschiedliche Wege“ auf, bloße Trivia zu sein, und wurde zur Erklärung, warum eine ganze Klasse von Commits durchging, während eine andere es nicht tat.

Alles verifizieren

Zwei Prüfebenen gibt es, und sie können sich durchaus widersprechen — genau das hat mich oben in die War Story geschickt. Lokal, gegen die eigene Allowed-Signers-Liste:

 

git log --show-signature -1      # Good "git" signature for …
git verify-commit HEAD

 

Serverseitig zeigt GitLab ein „Verified“-Badge am Commit und liefert denselben Status auch über die API. Widersprechen sich beide trotz gültiger lokaler Signatur: keine neuen Schlüssel erzeugen, der Schlüssel ist in Ordnung. Stattdessen den Verifikationspfad des Servers ansehen — Enforcement-Einstellungen, gecachte Urteile, das Mapping von E-Mail zu Account. Die Dokumentation beschreibt den Happy Path, die Edge Cases stecken im Code.

Häufige Fragen

Brauche ich zwingend eine YubiKey, oder reicht ein Software-Schlüssel für den Menschen-Teil?+

Für den strengsten Schutz führt an Hardware kein Weg vorbei. Ein Software-Schlüssel auf der Festplatte ist zwar besser als gar keine Signatur, teilt aber das Schicksal der Workstation — wird die kompromittiert, kann jemand als Sie committen. Der Kernpunkt eines Resident Keys mit verify-required liegt genau darin: PIN plus physisches Anfassen sind Pflicht, und der private Schlüssel verlässt die Hardware nie. Für die KI-Agent- und Bot-Identitäten dagegen ist ein Software-Schlüssel die richtige Wahl — die müssen schließlich unbeaufsichtigt laufen können.

Funktioniert dieser Aufbau auch mit GitHub statt GitLab?+

Im Grundsatz ja — eigene Identität pro Akteur, Signing getrennt von Publish-Autorität, das funktioniert plattformunabhängig. Die konkreten Mechanismen unterscheiden sich aber: GitHub unterstützt zwar ebenfalls SSH- und GPG-Commit-Signaturen, hat aber kein direktes Gegenstück zum hier beschriebenen GitLab-Bug mit gruppenweiten SSH-Zertifikaten. Renovate committet auf GitHub in aller Regel ebenfalls über die Plattform-API, sodass dieselbe gitPrivateKey-Logik mit lokalem Committen greift.

Ist die gruppenweite SSH-Zertifikats-Durchsetzung, die den Bug ausgelöst hat, nur in bestimmten GitLab-Editionen verfügbar?+

Ja, tatsächlich. Dieses konkrete Feature — Gruppen-Owner richten eine Certificate Authority ein, die SSH-Keys und Access-Token für reguläre Nutzer-Accounts ersetzt — gibt es nur in Premium/Ultimate, und aktuell ausschließlich auf GitLab.com (SaaS), nicht für self-managed-Instanzen. Self-managed-Setups haben stattdessen ein separates, instanzweites SSH-Zertifikats-Feature (gitlab-sshd), das anders funktioniert. Wer nur Community Edition betreibt, kann exakt diesen Bug also nicht treffen — trotzdem lohnt ein Blick, ob andere Verifikations-Overrides ähnlich wirken.

Was passiert, wenn der GPG-Schlüssel des Renovate-Bots kompromittiert wird?+

Publish-Autorität verschafft ein gestohlener Signing-Key allein nicht — der Bot-Account bleibt strikt auf seine Rolle beschränkt, typischerweise Developer mit Merge-Rechten nur auf nicht-geschützte Branches für Dependency-Updates. Ein Angreifer könnte damit zwar signierte, aber inhaltlich manipulierte Merge Requests öffnen — die müssen trotzdem ein Review durchlaufen, bevor sie überhaupt einen geschützten Branch erreichen. Pflicht bleibt trotzdem: neuen GPG-Key erzeugen, alten vom Bot-Account entfernen, CI-Variable ersetzen.

Kann sich der Coding-Agent selbst höhere Rechte verschaffen, wenn sein Token kompromittiert wird?+

Über Git-Mechanismen allein: nein. Sein Token ist strikt auf write_repository beschränkt, seine Rolle ist Developer — beides erzwingt GitLab selbst, nicht der Agent. Ein Merge auf geschützte Branches braucht Maintainer-Rolle plus Code-Owner-Approval, Release-Tags und Package-Registry sind separat geschützt, und der Release-Signing-Key sitzt hinter einem KMS, auf das der Agent-Account keinen Zugriff hat. Schlimmstenfalls entstehen also überprüfbare, ungemergte Branches — mehr nicht.

Muss ich die allowed_signers-Datei manuell pflegen?+

Ja, zumindest für die lokale bzw. CI-Offline-Verifikation. GitLab selbst kümmert sich zwar automatisch um die serverseitige Zuordnung von Schlüssel zu Account, sobald ein Signing-Key registriert ist — eine allowed_signers-Datei im Repo ist aber eine eigene, git-native Liste, die Sie selbst pflegen (oder aus den auf GitLab registrierten Signing-Keys der erlaubten Accounts per Skript generieren), damit git verify-commit und CI-Checks auch ohne Rückfrage beim Server funktionieren.

Fazit

Drei Identitäten, drei Schlüssel — und eine einzige Regel, die festlegt, was jede davon darf. Der grüne Haken sieht gut aus, aber um das Badge ging es nie. Es geht darum, dass die Historie ehrlich erzählt, wer was getan hat, und dass kein einzelner Schlüssel im Alleingang still einen Release verschieben kann.

Was ich beibehalten würde

Ich richte Signing-Pipelines für Menschen, KI-Agenten und Bots so ein, dass die Historie ehrlich bleibt und kein einzelner Schlüssel im Alleingang einen Release verschieben kann.

YubiKey-Resident-Keys, gescopte Service-Accounts, GPG-Signing für API-committende Bots — und die GitLab-internen Verifikations-Edge-Cases, von denen die Doku schweigt.

Plattform-Betrieb statt Beratung auf Papier: Ich richte Ihre Signing-Pipeline ein, härte sie und debugge sie laufend.

Termin buchen →

Über den Autor

Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Freiberuflicher DevSecOps-Berater · OnlyOle Consulting

Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.