diff --git a/.zshrc b/.zshrc index 3798f82..bdd6ee8 100644 --- a/.zshrc +++ b/.zshrc @@ -40,3 +40,5 @@ bindkey '^[[A' up-line-or-beginning-search bindkey '^[[B' down-line-or-beginning-search bindkey '^[OA' up-line-or-beginning-search bindkey '^[OB' down-line-or-beginning-search + +source "${0:a:h}/agents/agent-env.zsh" diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..e41c603 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,180 @@ +# 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 — 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. diff --git a/agents/agent-env.zsh b/agents/agent-env.zsh new file mode 100644 index 0000000..625fd97 --- /dev/null +++ b/agents/agent-env.zsh @@ -0,0 +1,18 @@ +# Agent-Sessions (Claude, OpenCode): Secrets aus 1Password und SSH-Identität +# des Agent-Users. Wird von dotfiles/.zshrc gesourced. +# Architektur und Begründungen: agents/README.md. + +# Git-Signing und ssh-add greifen über SSH_AUTH_SOCK auf den geteilten +# Maschinen-Agent zu — interaktiv wie in Agent-Sessions. Fürs interaktive SSH +# ist die Variable irrelevant, weil ~/.ssh/config IdentityAgent explizit setzt +# (Haupt-Key via 1Password, Agent-User via diesem Socket). +export SSH_AUTH_SOCK="$HOME/.ssh/isac-agent.sock" + +agent-env() { + # Agent + beide Maschinen-Keys sicherstellen; der op-read darin teilt sich + # die 1Password-Autorisierung mit dem op-run unten, also ein Prompt. + ensure-machine-agent || return 1 + + op run --account WHZOJQOJPZHTFPJC6JZAOCUCBI \ + --env-file ~/.env.agents --no-masking -- "$@" +} diff --git a/agents/ensure-machine-agent b/agents/ensure-machine-agent new file mode 100755 index 0000000..a1d4632 --- /dev/null +++ b/agents/ensure-machine-agent @@ -0,0 +1,50 @@ +#!/bin/sh +# Stellt den geteilten Maschinen-ssh-agent sicher: Socket läuft und hält beide +# Keys, die auf dieser Maschine gebraucht werden. Idempotent — mehrfach +# aufrufbar, macht nur was fehlt. +# +# SSH isac agent SSH auf interne Systeme (User mwiegand-agent) +# SSH Signing Key Git-Signing, für mich UND Agent-Sessions +# +# Wird von agent-env bei jedem Start gerufen; manuell nur nötig, wenn man nach +# einem Reboot committen will, bevor die erste Agent-Session lief (Symptom: +# "error: Couldn't find key in agent?"). +# +# Der kundenfähige Haupt-Key ("SSH Key mwiegand") gehört bewusst NICHT hierher: +# er lebt nur hinter der 1Password-Freigabe (~/.1password/agent.sock, siehe +# Host * in ~/.ssh/config), damit Agents ihn nicht unbemerkt nutzen können. +# Architektur und Begründungen: agents/README.md im dotfiles-Repo. + +PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH" +SOCK="$HOME/.ssh/isac-agent.sock" +OP_ACCOUNT="WHZOJQOJPZHTFPJC6JZAOCUCBI" +export SSH_AUTH_SOCK="$SOCK" + +# Agent tot oder nie gestartet? (ssh-add exit 2 = Socket antwortet nicht) +ssh-add -l >/dev/null 2>&1 +if [ $? -eq 2 ]; then + rm -f "$SOCK" + /usr/bin/ssh-agent -a "$SOCK" >/dev/null || exit 1 +fi + +# $1 = Fingerprint (schon geladen?), $2 = op-Referenz, $3 = Anzeigename +ensure_key() { + if ssh-add -l 2>/dev/null | grep -q "$1"; then + return 0 + fi + if op read --account "$OP_ACCOUNT" "$2" | ssh-add -q -; then + echo "$3 geladen (bis zum nächsten Reboot)." >&2 + else + echo "$3 konnte nicht geladen werden (1Password gesperrt?)" >&2 + return 1 + fi +} + +rc=0 +ensure_key "SHA256:SvHSJQcHlAWUmF8EnflNsrv0mhWGARvAKLRPSBQ460I" \ + "op://Employee/37z2tbpilrjrgyrhckb5cbrhsq/private key?ssh-format=openssh" \ + "Signing-Key" || rc=1 +ensure_key "SHA256:77FiJM6Z1eN7Ka7YpoCKIn9xVJFQUj6nBQa/g1rFya4" \ + "op://Employee/ec2mtn4kkxnypyc4gxutg7lajq/private key?ssh-format=openssh" \ + "Agent-Key" || rc=1 +exit $rc