Files
git-hardening/docs/specs/2026-06-09-agent-backed-keys.md
T
Flo 382a35c47e fix(security): harden destructive paths and add audit tiers (v0.6.0)
Address critical and high findings from an external security review.

Critical/high fixes:
- reset-signing no longer treats general-purpose keys (id_ed25519, etc.)
  as deletion candidates, defaults the delete prompt to No, and never
  deletes files in -y mode
- FIDO2 retry now re-runs the same attempt (for-loop reassignment bug
  silently advanced to the next fallback key type)
- core.hooksPath redirection installs dispatch stubs for all client-side
  hook types so repo-local hooks (husky, lefthook, pre-commit) keep
  running; pre-commit combines gitleaks with dispatch and warns loudly
  when gitleaks is absent
- public-key validation everywhere a key path is consumed, preventing
  private key material in allowed_signers or user.signingkey
- config backups written mode 600 (may contain tokens)
- SSH config audit/apply is scope-aware (global vs host-specific),
  appends new directives at EOF to preserve precedence, scans Include-d
  files for keys
- pubkey algorithm restriction guarded against RSA/DSA-only lockout and
  chooses the directive name by OpenSSH version

Added:
- audit tiers (security/hygiene/preference); --audit exit 2 reflects
  security-tier issues only
- signing smoke test catching No-principal-matched at setup time
- http.sslVerify audit distinguishes unset from insecure override

Docs: correct fsmonitor precedence, log.showSignature and fsckObjects
breakage, SSH scoping semantics in REASONING.md; plan for agent-backed
keys (1Password/Bitwarden/forwarded agents) in docs/specs.

126/126 BATS tests pass; shellcheck clean.

Closes #53

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:55:31 +02:00

113 lines
9.8 KiB
Markdown

# git-harden.sh — Agent-Backed Keys: 1Password / Bitwarden / Forwarded Agents, Zero Plaintext Keys on Disk
Status: **proposed** (plan for v0.7.0)
Depends on: v0.6.0 (scope-aware SSH config handling, key inventory via `ssh-add -L`, signing smoke test)
## Motivation
The script currently assumes file-based keys: it scans `~/.ssh/*.pub`, generates keys to disk, and treats "no key files" as "no keys". That assumption breaks — and in some places actively fights — the strongest available key-storage model:
- **1Password / Bitwarden SSH agents** hold private keys encrypted in a vault and expose them only through an agent socket, with per-use approval prompts. No private key ever touches disk.
- **Hardware keys** (FIDO2 resident keys) keep the private key in the authenticator.
- **Forwarded agents** (`SSH_AUTH_SOCK` in a remote/container session) let agent-less environments — including agent containers used for AI-assisted development — sign and authenticate without holding any identity locally.
The end state this plan targets: **a machine can pass the full audit and have working signing + authentication with zero plaintext private keys on disk.** Today it can't: the audit reports "No SSH public keys found", the signing wizard only offers to generate file-based keys, and `IdentitiesOnly yes` (which we set) can silently break agent-only setups.
## Current gaps
| # | Gap | Where |
|---|-----|-------|
| 1 | Key hygiene audit ignores agent-held keys ("No SSH public keys found" on a fully provisioned 1Password machine) | `audit_ssh_key_hygiene` |
| 2 | Signing wizard offers only on-disk generation (software file or FIDO2 file handle) | `signing_wizard` |
| 3 | `IdentitiesOnly yes` is applied without checking that configured `IdentityFile` stubs exist — agent-only keys stop being offered | `apply_ssh_config` |
| 4 | No detection of *unencrypted* private keys on disk — the exact artifact this plan wants to eliminate | missing audit |
| 5 | No `ForwardAgent` policy; forwarded-agent risk/benefit is undocumented | missing audit + docs |
| 6 | Signing smoke test requires a private key file next to the `.pub` | `verify_signing_setup` |
## Design
### Phase 1 — Detection & audit (no behavior changes)
**1a. Agent detection helper.** `detect_ssh_agents` reports every reachable agent:
- `SSH_AUTH_SOCK` (generic; also covers forwarded agents — detect forwarding via `SSH_CONNECTION`/`SSH_TTY` being set alongside it)
- 1Password: `~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock` (macOS), `~/.1password/agent.sock` (Linux)
- Bitwarden: `~/.bitwarden-ssh-agent.sock`
- gpg-agent: `gpgconf --list-dirs agent-ssh-socket`
Probe each socket with `SSH_AUTH_SOCK=<sock> ssh-add -L`; report agent type, reachability, and key count. Sockets are *probed read-only*; nothing is written.
**1b. Agent keys join the key-hygiene audit.** `audit_ssh_key_hygiene` merges `ssh-add -L` output (per detected agent) with on-disk `.pub` files, deduplicated by key blob. Agent keys get the same weak-algorithm checks and are labeled `(agent: 1password)` etc. "No SSH public keys found" only appears when disk *and* agents are empty.
**1c. New audit: plaintext private keys on disk** (tier: security).
- Enumerate candidate private keys in `~/.ssh` (files whose first line is an OpenSSH/PEM private key header — never by filename guessing).
- Classify encrypted vs. unencrypted: `ssh-keygen -y -P "" -f <key>` succeeding in batch mode ⇒ **unencrypted**.
- `[WARN security]` unencrypted private key on disk → recommend vault import + passphrase or deletion after migration.
- `[INFO]` encrypted private key on disk when an agent holds the same public key → candidate for cleanup (Phase 4).
- Audit-only, like the existing credential-hygiene section: never modify or delete.
**1d. `ForwardAgent` audit** (tier: security). `[OK]` when unset/`no` globally; `[WARN]` when `yes` in global scope ("any root user on any host you ssh to can use your keys while connected — scope it per-host, prefer agents with per-use confirmation"). Uses the v0.6.0 scope-aware reader.
### Phase 2 — Signing without key files
**2a. Wizard option 3: "Use a key from your SSH agent".** Lists agent keys (`ssh-add -L`, modern algorithms only), lets the user pick one, then:
- `user.signingkey = key::<keytype> <blob> <comment>` (literal pub key, no file needed; supported since Git 2.34 for `gpg.format=ssh`)
- allowed_signers entry written from the same literal key (existing `setup_allowed_signers` path, fed by string instead of file — refactor it to accept key material, keeping the public-key validation guard)
- In `-y` mode with no file-based key found: if exactly one modern agent key exists, use it (consistent with the existing "found existing key" auto-pick); otherwise skip with the current message.
**2b. Smoke test via agent.** `verify_signing_setup` learns a second path: when no private key file exists, sign with `ssh-keygen -Y sign -U -f <tmp pubkey file>` (`-U`: private half lives in the agent). Same prompt gating — 1Password/Bitwarden will pop an approval dialog, which is exactly what we want the user to see once at setup time.
**2c. Audit accepts `key::` signing keys.** `audit_signing` currently prints `(inline key)` for `ssh-*` values; extend the case to `key::*` and verify the *allowed_signers* file contains the same blob.
### Phase 3 — SSH config integration
**3a. `IdentitiesOnly` guard.** Before applying `IdentitiesOnly yes`, check: does any `IdentityFile` directive exist in global scope, or do on-disk `.pub` stubs match agent keys? If the user is agent-only with no stubs, offer to write **public-key stubs** (`~/.ssh/<name>.pub` — public material only, consistent with the zero-plaintext goal) plus matching `IdentityFile` lines, so `IdentitiesOnly yes` keeps working with the agent. Decline ⇒ skip the directive with a warning instead of applying a lockout.
**3b. `IdentityAgent` offering.** When a 1Password/Bitwarden socket is detected and `SSH_AUTH_SOCK` doesn't already point at it, offer a global `IdentityAgent <socket>` directive (written with the v0.6.0 append-at-EOF semantics). Never overwrite an existing `IdentityAgent`.
**3c. `ForwardAgent no` as an applied default** (matching the Phase 1d audit), with documentation of the per-host override pattern and a note that confirmation-prompting agents (1Password, `ssh-add -c`) are the safe way to use forwarding when it's unavoidable.
### Phase 4 — Migration assistant (interactive only, opt-in)
Goal: walk an existing file-based user to zero plaintext keys. Strictly guided, never automatic:
1. Inventory on-disk private keys (from 1c) and show which are already represented in an agent.
2. Print vault-import instructions per detected agent (1Password and Bitwarden import flows differ; we print, we don't drive their CLIs).
3. After the user confirms a key is imported **and** the agent probe shows its public key, offer deletion of the on-disk private key under the v0.6.0 reset-signing safety rules: interactive only, default **No**, rename-to-`.bak` middle option, never in `-y` mode, keep the `.pub` stub for `IdentitiesOnly`.
4. Re-run the relevant audits to show the end state.
### Documentation
- New REASONING.md section "Agent-Backed Keys" covering: why vault agents beat files, forwarded-agent threat model, `key::` signing, pub-stub pattern for `IdentitiesOnly`.
- README quick-start for 1Password/Bitwarden users.
## Acceptance criteria
1. On a machine whose only keys live in a 1Password agent: full audit passes (given config applied), `audit_ssh_key_hygiene` lists the agent keys, signing works via `key::`, and the smoke test round-trips through the agent.
2. `git-harden.sh -y` never blocks on an agent approval prompt and never deletes/moves any key file.
3. Applying `IdentitiesOnly yes` is impossible in a state that would stop agent keys from being offered (stub-write or skip-with-warning).
4. An unencrypted private key in `~/.ssh` is flagged as a security-tier finding; an encrypted one is not flagged red.
5. Forwarded-agent sessions (`SSH_AUTH_SOCK` + `SSH_CONNECTION`) are detected and labeled; no file-based assumptions break inside such a session or an agent container.
6. All existing BATS tests keep passing; new tests use a throwaway `ssh-agent` (start, `ssh-add` a generated key, delete the private file, point `SSH_AUTH_SOCK` at it) so CI needs no vault products.
## Test plan
- **Unit (BATS):** agent detection with fake sockets; key inventory merge/dedup; unencrypted-key classifier (generated with/without `-N`); `key::` signingkey audit; `IdentitiesOnly` guard paths (stubs present / agent-only / decline).
- **Agent integration (BATS, real `ssh-agent`):** sign + verify via `-U` with the private key file removed — proves the zero-plaintext path end to end.
- **Container e2e:** extend `test/containers/*` with an `ssh-agent`-only variant (no `~/.ssh/id_*` private files) running `--audit` and `-y`.
- **Interactive:** new `test/interactive/test-signing-agent.sh` covering wizard option 3.
## Non-goals
- Driving 1Password/Bitwarden CLIs (`op`, `bws`) — instructions only; their CLIs change and require auth sessions.
- gpg-agent-backed *GPG* signing (this project standardizes on SSH signing).
- Hardware-token provisioning changes (FIDO2 path already exists and is orthogonal).
## Open questions
1. Should `-y` auto-adopt a single agent key for signing (2a) or stay fully passive? Current plan: adopt only when exactly one modern key exists, mirroring file-based behavior. Approval prompts make even this debatable in headless runs — the smoke test stays interactive-only either way.
2. Bitwarden's agent socket path is configurable; do we read its `data.json` to find it, or only check the default path and document the rest? Plan: default path + docs.
3. Stub naming for written `.pub` stubs (3a): derive from the key comment (sanitized) or `agent_<fingerprint-prefix>.pub`? Plan: comment-derived with fingerprint fallback.