agents: Bootstrap-Logik ins Repo, ein Script für beide Maschinen-Keys
Die imperative Hälfte der Key-Architektur lag unversioniert in ~/.zshrc und ~/.local/bin, während die deklarative (.env.agents) getrackt war. Jetzt liegt alles Agent-bezogene hier: - agents/agent-env.zsh — SSH_AUTH_SOCK + agent-env(), gesourced von .zshrc - agents/ensure-machine-agent — startet den Maschinen-Agent und stellt beide Keys sicher (ersetzt load-signing-key, das nur den Signing-Key kannte, während agent-env separat den Agent-Key lud) - agents/README.md — Design-Doc, verschoben aus dem ai-Repo zum Code .env.agents bleibt rein deklarativ, weil op run --env-file die Datei nur parst und keinen Code ausführt — daher die Zweiteilung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aD7fG6BpN1HchyHeNXZb4
This commit is contained in:
parent
9a5dc887ce
commit
38f07179c4
4 changed files with 250 additions and 0 deletions
2
.zshrc
2
.zshrc
|
|
@ -40,3 +40,5 @@ bindkey '^[[A' up-line-or-beginning-search
|
||||||
bindkey '^[[B' down-line-or-beginning-search
|
bindkey '^[[B' down-line-or-beginning-search
|
||||||
bindkey '^[OA' up-line-or-beginning-search
|
bindkey '^[OA' up-line-or-beginning-search
|
||||||
bindkey '^[OB' down-line-or-beginning-search
|
bindkey '^[OB' down-line-or-beginning-search
|
||||||
|
|
||||||
|
source "${0:a:h}/agents/agent-env.zsh"
|
||||||
|
|
|
||||||
180
agents/README.md
Normal file
180
agents/README.md
Normal file
|
|
@ -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.
|
||||||
18
agents/agent-env.zsh
Normal file
18
agents/agent-env.zsh
Normal file
|
|
@ -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 -- "$@"
|
||||||
|
}
|
||||||
50
agents/ensure-machine-agent
Executable file
50
agents/ensure-machine-agent
Executable file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue