# Design: SSH-Key- und Git-Signing-Architektur (Arbeitsrechner mwiegand) Stand 2026-08-04 — umgesetzt und verifiziert. Beschreibt das *Warum* hinter dem Code in diesem Verzeichnis (`agent-env.zsh`, `ensure-machine-agent`, `.env.agents`) plus `~/.ssh/config` und `~/.gitconfig`. Umsetzungsstand, Historie und die offenen Schritte auf ISAC-Seite stehen im ai-Repo: `tasks/2026-07-31-agent-key-isolation/HANDOFF.md`, Vorarbeit in `tasks/2026-06-13-isac-infra-agent-isolation/`. ## Problem Auf dem Rechner laufen zwei Arten von Akteuren mit unterschiedlichem Vertrauensniveau: - **Ich** (interaktive Shell) — brauche den persönlichen SSH-Key, der über LDAP auch auf **Kundensystemen** autorisiert ist. - **Agents** (Claude/OpenCode via `agent-env`) — sollen interne Systeme erreichen, aber **strukturell nicht unbemerkt** an den kundenfähigen Key kommen. Vor dem Umbau war die Trennung rein konventioniert: der private Haupt-Key lag unverschlüsselt als `~/.ssh/id_mwiegand` auf der Platte (für jede Agent-Session lesbar), der 1Password-SSH-Agent bot alle Keys jedem Prozess an, und ein Login-Script lud den Haupt-Key zusätzlich in den Apple-Agent. Git-Signing hing am Datei-Key. Dazu die Komfort-Anforderung: **Signieren darf im Alltag nicht nerven** — keine Fingerabdruck-Freigabe pro Commit. ## Anforderungen 1. Agents verwenden einen eigenen SSH-Key (nur interne Systeme); der kundenfähige Haupt-Key ist für sie nicht still nutzbar. 2. Jede Nutzung des Haupt-Keys erzeugt eine sichtbare, bewusste Freigabe (Fingerabdruck) — der Prompt ist die Schranke. 3. Kein privater Schlüssel liegt als Datei auf der Platte. 4. Git-Commits sind signiert, prompt-frei — Herkunft „diese Maschine" ist nachweisbar (Mensch/Agent unterscheiden die Commit-Trailer). 5. Kein Autoload beim Login (explizite Entscheidung): was in einem Agent liegt, wurde bewusst dorthin gelegt. ## Architektur: drei Schlüssel, drei Wege | Schlüssel (1Password-Item, Vault Employee) | Fingerprint | Lebt in | Zweck | Prompt-Verhalten | |---|---|---|---|---| | **SSH Key mwiegand** (Haupt-Key) | `SHA256:lIx1Na…` | nur 1Password | SSH überall, inkl. Kundensysteme | 1Password-Freigabe pro Key und Terminal-Session — bewusst spürbar | | **SSH isac agent** (Agent-Key) | `SHA256:77FiJM…` | geteilter Maschinen-ssh-agent `~/.ssh/isac-agent.sock` | SSH auf interne Systeme (User `mwiegand-agent`) | einmal `op read` pro Boot, danach still | | **SSH Signing Key mwiegand** (Signing-Key) | `SHA256:SvHSJQ…` | geteilter Maschinen-ssh-agent `~/.ssh/isac-agent.sock` | Git-Signing **aller** Commits der Maschine — meine und Agent-Commits | einmal `ensure-machine-agent` pro Boot (passiert automatisch mit der ersten Agent-Session), danach still | Die Auswahl des Schlüssels erfolgt **über den Agent-Socket**, nicht über Key-Dateien: - `~/.ssh/config` `Host *` → `IdentityAgent ~/.1password/agent.sock` (Haupt-Key, promptet pro Nutzung). - `Match exec USE_AGENT_SSH_USER=1` → `User mwiegand-agent` + `IdentityAgent ~/.ssh/isac-agent.sock` (Ein-Key-Agent; steht vor `Host *`, nach den ckn-Host-Blöcken). - Git-Signing läuft über `SSH_AUTH_SOCK`, der überall (interaktive Shell via `agent-env.zsh`, Agent-Sessions zusätzlich via `.env.agents`) auf den Maschinen-Agent zeigt — signiert wird immer mit dem Signing-Key. Fürs interaktive SSH ist `SSH_AUTH_SOCK` irrelevant, weil die `ssh_config` `IdentityAgent` explizit setzt. Der Sicherheitskern: **die Prompt-Semantik bleibt sauber.** Ein 1Password-Prompt bedeutet immer „jemand will den kundenfähigen Key" — er kommt selten und ist ernst zu nehmen. Signing und Agent-SSH erzeugen nie Prompts, also gibt es keine Gewöhnung an reflexhaftes Bestätigen. ## Komponenten Alles Agent-bezogene liegt versioniert in diesem Verzeichnis; `~/.zshrc` und `~/.local/bin` enthalten nur noch Verweise darauf. | Datei | Rolle | |---|---| | `agents/agent-env.zsh` (gesourced von `dotfiles/.zshrc`) | exportiert `SSH_AUTH_SOCK` fest auf `~/.ssh/isac-agent.sock`; `agent-env()` ruft `ensure-machine-agent` und dann `op run --env-file ~/.env.agents` | | `agents/ensure-machine-agent` (Symlink in `~/.local/bin/`) | idempotent: startet den Maschinen-Agent bei Bedarf und stellt **beide** Keys sicher (Signing + Agent). Wird von `agent-env` gerufen; manuell nur nötig, wenn man nach einem Reboot committen will, bevor die erste Agent-Session lief | | `agents/.env.agents` (Symlink `~/.env.agents`) | rein deklarativ — `op run` parst die Datei nur, führt keinen Code aus: `USE_AGENT_SSH_USER=1`, `SSH_AUTH_SOCK` (Robustheit: Konsumenten ohne `agent-env` finden den richtigen Socket), `BW_SSH_ARGS`, `GIT_CONFIG_*` → `user.signingkey` = Signing-Pubkey (Env schlägt alle git-Configs, auch lokale), Secret-Referenzen | | `~/.ssh/config` | Socket-Routing s. o.; keine `IdentityFile`-Einträge für persönliche Keys | | `~/.gitconfig` | `gpg.format=ssh`, `commit.gpgsign=true`, `user.signingkey` = Signing-Pubkey-String (kein Pfad), `gpg.ssh.allowedSignersFile` | | `~/.ssh/allowed_signers` | beide Pubkeys (Signing + Agent) für lokale Verifikation (`git log --format=%G?`) | Gelöscht: `~/.ssh/id_mwiegand` (unverschlüsselter Datei-Key), `add-ssh-key-to-agent` (Login-Autoload des Haupt-Keys), lokale `user.signingkey`-Overrides in 8 Repos. ## Entscheidungen und verworfene Alternativen **Signing-Key als eigener Schlüssel statt Haupt-Key.** Die Prompt-Frequenz hängt nicht am Schlüssel, sondern am Mechanismus — aber ein gemeinsamer Schlüssel hätte Haupt-Key-Prompts beim Committen alltäglich gemacht und die Schranke durch Gewöhnung entwertet. Außerdem: Forges kennen den Signing-Key nur als Signaturschlüssel; ein Leak ist kein Systemzugriff. **Ein gemeinsamer Signing-Key für Mensch und Agents** (statt Agent-Commits mit dem Agent-SSH-Key zu signieren, wie zuerst gebaut). Die Key-Trennung hätte nur *ehrliche* Agent-Commits von meinen unterschieden: die Agent-Sockets sind für jeden lokalen Prozess erreichbar, ein böswilliger Agent könnte ohnehin als ich signieren. Für ehrliche Commits leistet der `Co-Authored-By`-Trailer die Unterscheidung. Die Signatur bedeutet ehrlich „kam von meiner Maschine"; dafür halbiert sich die Forge-Pflege (ein Key registrieren/verifizieren statt zwei) und der Boot-Load läuft automatisch über die erste Agent-Session mit. Ältere Commits (2026-08-04) sind noch mit dem Agent-Key signiert — er bleibt dafür in `allowed_signers`. **`op-ssh-sign` erwogen und verworfen.** Zuerst umgesetzt (Key verlässt 1Password nie), dann zurückgebaut: die 1Password-Autorisierung gilt global pro App(+Terminal-Session) für *alle* Keys. Das Signing-Nerv-Problem hätte sich nur durch Lockern des Modells lösen lassen — was die Haupt-Key-Schranke mitgelockert hätte. Der Agent-Weg entkoppelt beides: Signing prompt-frei, 1Password-Modell bleibt streng. (Zwischenschritt Apple-Agent als Signing-Halter wurde durch den Maschinen-Agent ersetzt — ein Agent weniger.) **macOS-Keychain als Signer verworfen.** Die Keychain speichert Bytes, kann aber keine SSH-Signaturen erzeugen (`security` bietet nur `cms`). Jeder Keychain-Weg endet in einer Klartext-Tempdatei pro Commit — schlechter als jeder Agent. (`ssh-add --apple-use-keychain` speichert nur Passphrasen und setzt eine Key-Datei voraus.) **Kein Login-Autoload.** Bewusste Entscheidung: Keys werden nicht beim Login geladen, sondern lazy — mit der ersten Agent-Session (automatisch) bzw. per `ensure-machine-agent` von Hand. Symptom eines fehlenden Loads ist ein Commit-Fehler `Couldn't find key in agent?` — eindeutig und folgenlos. **Geteilter Agent statt Agent pro Session.** Ein fester Socket (`~/.ssh/isac-agent.sock`) statt mktemp pro Session: ein `op read` pro Key und Boot statt pro Session, kein Cleanup verwaister Agents, und der Match-Block kann auf einen stabilen Pfad zeigen (wirkt auch für Subprozesse mit bereinigtem Env). Beide Bewohner (Agent-Key, Signing-Key) sind per Definition die Schlüssel, die Agents zugänglich sein dürfen — Langlebigkeit weicht nichts auf. **`SSH_AUTH_SOCK` zusätzlich fest in `.env.agents`.** Lektion aus dem Umbau: eine Shell mit veralteter `agent-env`-Funktion lud die neue `.env.agents` (Signing-Config auf Agent-Key), zeigte aber noch auf den leeren Apple-Agent → `Couldn't find key in agent?`. Signing-Config und Socket dürfen nicht auseinanderfallen; beide stehen jetzt in derselben Datei. ## Grenzen (bewusst offen) Die Architektur ist eine **Zustimmungs- und Erkennungsschranke, keine technische Unerreichbarkeit**: - Der 1Password-Socket bleibt für Agent-Prozesse erreichbar (`allowAllUnixSockets: true` in der Claude-Sandbox-Config; 1Password kann Keys nicht pro Client filtern). Ein Agent, der den Haupt-Key anfordert, löst aber einen sichtbaren Prompt aus, den ich ablehnen kann. - **`op`-CLI-Pfad** (empirisch geprüft 2026-08-04): ein Agent kann `op read` auf beliebige Items *versuchen* — u. a. den Haupt-Key — aber nicht unbemerkt. Vier Versuche aus einer Agent-Session ergaben vier 1Password-Dialoge; die Freigabe für das `op run` beim `agent-env`-Start deckt nachgelagerte `op`-Aufrufe **nicht** mit ab (ob eine *bestätigte* Freigabe danach ein 10-Minuten-Fenster öffnet, ist unverifiziert — der Dialog behauptet das, gemessen wurde es nicht). Die verbleibende Schwäche ist die **Blindheit des Dialogs**: er sagt „full access to your account", nennt aber nicht das angefragte Item. Beim Bestätigen ist nicht unterscheidbar, ob ein Agent ein harmloses API-Passwort oder den kundenfähigen SSH-Key liest. Das ist systematisch, nicht nur eine Eigenschaft offener Abfragen: auch `op read` mit einer punktgenauen `op://Employee//`-Referenz zeigt denselben Dialog (geprüft 2026-08-04) — 1Password autorisiert das Werkzeug, nie den Zugriff, selbst wenn dieser vollständig spezifiziert ist. Gerade die Secrets in `.env.agents` sind ausschließlich solche Referenzen — und weil legitime Agent-Starts denselben Dialog erzeugen, trainiert der Alltag reflexhaftes Bestätigen. Genau das macht der gescopte Service-Account (Schritt 1) überflüssig: was nicht anfragbar ist, muss nicht beurteilt werden. - Vollständige Schließung = HANDOFF-Schritte 1/3: gescopter Service-Account mit eigenem Vault für den Agent-Key, Socket-Sperre in der Sandbox, Seatbelt-Variante für unsandboxte Kommandos. ## Betrieb - **Nach Reboot:** die erste `agent-env`-Session lädt beide Keys automatisch; nur wer vorher committen will, ruft einmal `ensure-machine-agent` (Symptom sonst: `Couldn't find key in agent?`). - **Verifikation:** `git log --format='%h %G? %GK'` — `G` + `SvHSJQ…` für alle Maschinen-Commits (Commits vom 2026-08-04 noch mit `77FiJM…`). `ssh-add -l` in einer Agent-Session zeigt genau die zwei Maschinen-Keys. - **Forges:** der Signing-Pubkey muss bei Bitbucket DC/Forgejo als *Signing* Key registriert sein; bei Forgejo zusätzlich ownership-verifiziert (Challenge signieren, nur via Web-UI), sonst „unverified" (offen). - **Rotation:** neuer Key = neues 1Password-Item + Pubkey in `~/.gitconfig`/`allowed_signers`/`.env.agents` bzw. ISAC (`admin_agent_users`) nachziehen; es gibt keine Key-Dateien zu tauschen.