Add SSH CA integration: signed user + host certs, ssh-login helper

Turn the ssh/ engine into an SSH CA for cert-based access:

- ssh/ roles: "user" (8h user certs, principal-restricted) and "host"
  (long-lived host certs); mount max-lease-ttl raised for host certs
- scripts/ssh-login.sh: sign a fresh user cert via a scoped ssh/sign/user
  token (API, no bao binary) and connect — no authorized_keys on targets
- ca/openbao-ssh-ca.pub: the SSH CA public key (for TrustedUserCAKeys and
  client @cert-authority trust)
- README: usage, host onboarding, client trust
- gitignore generated per-host artifacts/

First host wired + verified end-to-end: 192.168.0.26 (pifour) — lutz cert
login and host-cert verification both confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:55:01 +02:00
parent 37a97cb0f4
commit 5a111b7a20
4 changed files with 81 additions and 0 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ config/seal.hcl
config/tls/
data/
.env
artifacts/

View File

@@ -167,6 +167,44 @@ sudo systemctl list-timers openbao-cert-renew.timer # next run
sudo /home/lutz/Projects/OpenBAO/scripts/renew-openbao-cert.sh --force # renew now
```
## SSH access via OpenBAO (signed certificates)
The `ssh/` engine is an SSH CA (RSA-4096; pubkey in
[ca/openbao-ssh-ca.pub](ca/openbao-ssh-ca.pub)) with two roles:
| Role | Signs | Used for |
|--------|------------------|----------|
| `user` | user certs (8h) | logging into hosts as a principal (e.g. `lutz`) — no `authorized_keys` |
| `host` | host certs (3y) | clients verify host identity — no "unknown host" prompts |
**Log in** (signs a fresh 8h cert, then connects):
```bash
scripts/ssh-login.sh 192.168.0.26 # user lutz by default
scripts/ssh-login.sh --sign-only HOST # just refresh the cert
```
Auth to OpenBAO uses a scoped `ssh-sign-user` periodic token at
`~/.config/openbao/ssh-sign.token` (can only call `ssh/sign/user`).
**Onboard a new host** (run on that host, as root): install the user CA +
its signed host cert and point sshd at them. `scripts/` generates a ready
self-contained installer per host — the pattern (additive, no lockout):
```
TrustedUserCAKeys /etc/ssh/openbao_user_ca.pub # trust user certs
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub # present host cert
```
**Client trust** for host verification — one line in `~/.ssh/known_hosts`:
```
@cert-authority <host-or-pattern> <contents of ca/openbao-ssh-ca.pub>
```
Currently wired: **192.168.0.26** (`pifour`) — user login as `lutz` + host
verification, both confirmed.
## Git credential via OpenBAO
The push credential for the gitea remote is stored in OpenBAO KV

1
ca/openbao-ssh-ca.pub Normal file
View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDTPUjjBap8y5vvfEhTnEbQgYIw7CKXl2dMSS/2IE0+CC3uMuAhfScfkxMOC00GoK0rBwaJTkyyDIAL1XrAQmUB098WfVQP01KKj/n924dAZkNIRy0X6DKd5V0G1eY3M+kPK61IvaH81i3oexdmVMS9ax9E+kLRnxNK0hfSbZrIkTdMp7jCpidoADo4gVKvqucIMSqSKOZduJYQj2WC1cNxIy2DND+ZyXlSlsavOSeZlIswiwIPiPmGbF2QxWwvRMk5NfQRed38eYN+YUJYIUm7gq0UEUq8vhT3+1pKbyyFXnNN5yaI90L8bSdML/H5039lfQcu+MsstUaOLmWcKD1D9EVYzyr2HX/am2oMOVWlefbLwsNXaHpECleGfGFAcOmnIo13RI8gCCDlAVNorGgwEFjgk8RZAW6Om+9d8ae1AhAwzbIx0GR5qi14A0dGua1zyL8HyTRiei5Qz6XZNLX9roHFd3AUfOBdFIXG3TdPi4wAaBCIXuIkg9uBcv63AdAOmUFeMJYlv/9xreZlN/lPJaJbMlBCowMbhqPRFYk02kt7dr+9EGtNhAtEp2PReW6Vca4osUPYmsSovgwZmavWWD3GUg6KY06dxypZIWHtUJIgq42RZ1TGrnvkq9q3gy28k/WcaXD5xJ8vZDAl1oYNVHcSwWu89zf3tMlcgj1BDQ==

41
scripts/ssh-login.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Sign the local SSH key with OpenBAO's SSH CA (role "user"), then connect.
# The cert is short-lived (8h) and refreshed on every call — no long-lived
# authorized_keys on the target.
#
# ssh-login [user@]host [extra ssh args...] # sign + connect
# ssh-login --sign-only [user@]host # just refresh the cert
#
# Auth to OpenBAO uses a scoped, periodic token (ssh/sign/user only).
set -euo pipefail
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
TOKF="${OPENBAO_SSH_TOKEN_FILE:-$HOME/.config/openbao/ssh-sign.token}"
ROLE="${OPENBAO_SSH_ROLE:-user}"
KEY="${OPENBAO_SSH_KEY:-$HOME/.ssh/id_ed25519}"
CERT="${KEY}-cert.pub"
TTL="${OPENBAO_SSH_TTL:-8h}"
sign_only=0
[ "${1:-}" = "--sign-only" ] && { sign_only=1; shift; }
target="${1:?usage: ssh-login [--sign-only] [user@]host [ssh args]}"; shift || true
if [[ "$target" == *@* ]]; then principal="${target%@*}"; host="${target#*@}"
else principal="${OPENBAO_SSH_USER:-lutz}"; host="$target"; fi
[ -r "$TOKF" ] || { echo "ssh-login: no OpenBAO token at $TOKF" >&2; exit 1; }
[ -r "${KEY}.pub" ] || { echo "ssh-login: no public key at ${KEY}.pub" >&2; exit 1; }
req="$(PUB="$(cat "${KEY}.pub")" PRIN="$principal" TTL="$TTL" python3 -c \
'import json,os;print(json.dumps({"public_key":os.environ["PUB"],"valid_principals":os.environ["PRIN"],"ttl":os.environ["TTL"]}))')"
resp="$(printf '%s' "$req" | curl -sS --max-time 10 -H "X-Vault-Token: $(cat "$TOKF")" \
--data @- "${ADDR}/v1/ssh/sign/${ROLE}")"
signed="$(printf '%s' "$resp" | jq -r '.data.signed_key // empty')"
[ -n "$signed" ] || { echo "ssh-login: signing failed: $(printf '%s' "$resp" | jq -c '.errors // .')" >&2; exit 1; }
printf '%s' "$signed" > "$CERT"; chmod 644 "$CERT"
exp="$(ssh-keygen -L -f "$CERT" 2>/dev/null | awk '/Valid:/{ $1=""; print }')"
echo "ssh-login: signed cert for '${principal}' (valid:${exp})" >&2
[ "$sign_only" = 1 ] && exit 0
exec ssh -i "$KEY" -o CertificateFile="$CERT" "${principal}@${host}" "$@"