Geprüft: op read "op://Employee/<item>/password" zeigt denselben generischen full-access-Dialog wie op item get. 1Password autorisiert das Werkzeug, nie den Zugriff — auch wenn dieser vollständig spezifiziert ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aD7fG6BpN1HchyHeNXZb4
185 lines
11 KiB
Markdown
185 lines
11 KiB
Markdown
# 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/<item>/<feld>`-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.
|