Vault SSH Certificate Authority
Configure HashiCorp Vault as an SSH Certificate Authority to replace password-based SSH authentication with short-lived certificates.
Quick Reference (Daily Operations)
Check Certificate Status
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | awk '/Type:|Valid:|Principals:/'
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Valid: from 2026-02-22T12:30:41 to 2026-02-22T20:31:11
Principals:
admin
adminerosado
ansible
evanusmodestus
root
If Principals: shows (none) or is missing a required user, cert will be rejected. See SSH Falls Back to YubiKey (Missing Principal).
|
Renew Certificate (Recommended)
Use the automated script:
vault-ssh-sign
This script:
-
Loads Vault credentials from gopass
-
Signs with all required principals
-
Reloads SSH agent
Manual Re-Sign (If Script Unavailable)
CRITICAL: You MUST specify valid_principals every time. Vault SSH roles do NOT support default_principals - the parameter is silently ignored.
# Load Vault credentials
dsource d000 dev/vault
# Sign with ALL principals (never omit any)
vault write -field=signed_key ssh/sign/domus-client \
public_key=@$HOME/.ssh/id_ed25519_vault.pub \
valid_principals="admin,adminerosado,ansible,evanusmodestus,gabriel,root" \
>| ~/.ssh/id_ed25519_vault-cert.pub
# Reload SSH agent
ssh-add -d ~/.ssh/id_ed25519_vault
ssh-add ~/.ssh/id_ed25519_vault
Prefer vault-ssh-sign script over manual signing. Manual signing risks forgetting principals.
|
Add New Host to SSH CA
# 1. Copy CA public key to host
scp /tmp/vault-ca.pub <new-host>:/tmp/
# 2. On target host
ssh <new-host>
sudo mv /tmp/vault-ca.pub /etc/ssh/vault-ca.pub
sudo chmod 644 /etc/ssh/vault-ca.pub
# 3. Add to sshd_config (BEFORE any Match blocks)
echo "TrustedUserCAKeys /etc/ssh/vault-ca.pub" | sudo tee -a /etc/ssh/sshd_config
# 4. Verify and restart
sudo sshd -t && sudo systemctl restart sshd
TrustedUserCAKeys must appear BEFORE any Match blocks in sshd_config. See Synology DSM Considerations for NAS-specific instructions.
|
Quick Troubleshooting
| Symptom | Solution |
|---|---|
|
Empty principals - re-sign with principals |
|
Principal mismatch - check cert principals vs login user |
|
Clock skew - run |
|
Missing permit-pty - check role |
Configured Hosts Status
Hosts with Vault SSH CA (TrustedUserCAKeys) installed:
| Host | User | Status | Notes |
|---|---|---|---|
vault-01 |
ansible |
✓ |
Vault server |
kvm-01 |
evanusmodestus |
✓ |
KVM hypervisor |
kvm-02 |
evanusmodestus |
✓ |
KVM hypervisor 02 |
k3s-master-01 |
evanusmodestus |
✓ |
Kubernetes |
ipa-01 |
evanusmodestus |
✓ |
FreeIPA |
keycloak-01 |
evanusmodestus |
✓ |
Keycloak IdP |
bind-01 |
evanusmodestus |
✓ |
BIND DNS |
vyos-01 / vyos-02 |
vyos |
✓ |
VyOS HA routers |
nas-01 |
adminerosado |
✓ |
Synology NAS |
modestus-p50 |
gabriel |
✓ |
ThinkPad P50 workstation |
modestus-aw |
evanusmodestus |
Pending |
Alienware workstation |
modestus-razer |
evanusmodestus |
Pending |
Razer Blade 18 workstation |
| Add new hosts with the Add New Host procedure. |
Team Workflow Example
This section demonstrates the complete daily workflow for certificate-based SSH authentication.
Step 1: Sign Certificate
vault-ssh-sign
Enter passphrase for /home/evanusmodestus/.ssh/id_ed25519_vault:
Identity added: /home/evanusmodestus/.ssh/id_ed25519_vault (vault-signed-20260219)
Certificate added: /home/evanusmodestus/.ssh/id_ed25519_vault-cert.pub (vault-root-4baadeb7...)
Lifetime set to 08:04:53
Certificate signed successfully:
Valid: from 2026-02-23T06:47:54 to 2026-02-23T14:48:24
Extensions:
Step 2: Connect to Host
ssh k3s-master-01
What Happens (debug view)
debug1: Will attempt key: .ssh/id_ed25519_vault-cert.pub ED25519-CERT SHA256:S6ret9... explicit
debug1: Will attempt key: .ssh/id_ed25519_vault ED25519 SHA256:S6ret9... explicit agent
debug1: Will attempt key: .ssh/id_ed25519_sk_rk_d000_nano ED25519-SK ... explicit authenticator
debug1: Will attempt key: .ssh/id_ed25519_sk_rk_d000 ED25519-SK ... explicit authenticator
...
debug1: Offering public key: .ssh/id_ed25519_vault-cert.pub ED25519-CERT ... explicit
debug1: Server accepts key: .ssh/id_ed25519_vault-cert.pub ED25519-CERT ...
debug2: sign_and_send_pubkey: using private key ".ssh/id_ed25519_vault" from agent for certificate
Authenticated to 10.50.1.120 ([10.50.1.120]:22) using "publickey".
Key Order (Fallback Chain)
SSH attempts authentication in this order:
-
Vault cert (
id_ed25519_vault-cert.pub) - Short-lived, auto-expires -
YubiKey nano (
id_ed25519_sk_rk_d000_nano) - Hardware token fallback -
YubiKey primary (
id_ed25519_sk_rk_d000) - Hardware token fallback -
YubiKey secondary (
id_ed25519_sk_rk_d000_secondary) - Hardware token fallback -
Static key (
id_ed25519_d000) - Last resort
If Vault cert is expired or missing principals, SSH automatically falls back to YubiKey.
Prerequisites
-
Vault cluster operational and unsealed
-
dsec credentials loaded:
dsource d000 dev/vault -
Root token access for initial setup
-
KV Secrets Engine deployed
Automation Scripts
These scripts are installed to ~/.local/bin/ and should be in your PATH.
vault-ssh-sign
Signs your SSH key with all required principals. Run this daily or when certificate expires.
cp docs/asciidoc/modules/ROOT/examples/vault-ssh-sign ~/.local/bin/
chmod +x ~/.local/bin/vault-ssh-sign
vault-ssh-sign # Sign default key
vault-ssh-sign ~/.ssh/other_key # Sign specific key
Script Source
#!/bin/bash
# =============================================================================
# vault-ssh-sign - Sign SSH key with Vault CA (HA-aware)
# =============================================================================
# Runbook: vault-ssh-ca.adoc
#
# Signs your SSH public key with Vault's SSH CA, creating an 8-hour certificate
# that grants access to all infrastructure hosts without password prompts.
#
# HA ARCHITECTURE:
# This script is aware of the 3-node Vault Raft cluster and will automatically
# failover to healthy nodes. Vault uses Raft consensus - any node can be leader.
#
# Nodes (in preference order):
# vault-01.inside.domusdigitalis.dev:8200 (kvm-01)
# vault-02.inside.domusdigitalis.dev:8200 (kvm-02)
# vault-03.inside.domusdigitalis.dev:8200 (kvm-02)
#
# Quorum: 2 of 3 nodes must be healthy for cluster to function.
# Failover: Automatic - script tries each node until one responds.
#
# CRITICAL NOTES:
# - Vault SSH roles do NOT support 'default_principals' - that parameter
# is silently ignored. You MUST specify valid_principals on every sign.
# - All nodes share the same data via Raft replication.
# - Standby nodes forward requests to leader automatically.
# - If all 3 nodes fail health check, signing fails.
#
# Prerequisites:
# - dsec configured with d000/dev/vault credentials (for VAULT_TOKEN)
# - SSH keypair at ~/.ssh/id_ed25519_vault{,.pub}
# - Vault SSH CA role 'domus-client' configured
# - /var/log/vault directory exists on all nodes (for audit logging)
#
# Usage:
# vault-ssh-sign # Sign default key
# vault-ssh-sign ~/.ssh/other_key # Sign specific key
# VAULT_ADDR=https://vault-02:8200 vault-ssh-sign # Force specific node
#
# Principals (who you can SSH as):
# - Administrator, domus\Administrator: Windows AD admin
# - adminerosado: Synology NAS
# - admin: VyOS, network devices
# - ansible: automation
# - evanusmodestus: Linux workstations/servers
# - gabriel: modestus-p50 workstation
# - root: emergency access
# - u0_a361: Android Termux
#
# Operations:
# Check cluster health:
# vault operator raft list-peers
# vault operator raft autopilot state
#
# Manual failover test:
# ssh vault-01 "sudo systemctl stop vault"
# # Wait 10s, vault-02 or vault-03 becomes leader
# ssh vault-01 "sudo systemctl start vault && vault operator unseal"
#
# Step down leader (force election):
# vault operator step-down
#
# =============================================================================
set -euo pipefail
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
KEY="${1:-$HOME/.ssh/id_ed25519_vault}"
CERT="${KEY}-cert.pub"
# All principals needed for infrastructure access
PRINCIPALS="Administrator,domus\\Administrator,adminerosado,admin,ansible,evanusmodestus,gabriel,root,u0_a361"
# Vault HA cluster nodes (order = preference, but any healthy node works)
VAULT_NODES=(
"https://vault-01.inside.domusdigitalis.dev:8200"
"https://vault-02.inside.domusdigitalis.dev:8200"
"https://vault-03.inside.domusdigitalis.dev:8200"
)
# Health check timeout (seconds)
HEALTH_TIMEOUT=3
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
log_warn() {
echo "[WARN] $*" >&2
}
# Check if a Vault node is healthy and responding
# Returns 0 if healthy, 1 if not
check_vault_health() {
local addr="$1"
local status
# Use /v1/sys/health endpoint
# Returns 200 if active, 429 if standby (both are OK for our purposes)
# Returns 500+ or timeout if unhealthy
status=$(curl -sk --max-time "$HEALTH_TIMEOUT" -o /dev/null -w "%{http_code}" "${addr}/v1/sys/health" 2>/dev/null) || return 1
# 200 = active leader, 429 = standby, 472 = DR secondary, 473 = perf standby
# All of these can serve requests (standbys forward to leader)
case "$status" in
200|429|472|473)
return 0
;;
*)
return 1
;;
esac
}
# Find a healthy Vault node from the cluster
# Sets VAULT_ADDR to the first healthy node
find_healthy_vault() {
local node
# If VAULT_ADDR is already set and healthy, use it
if [[ -n "${VAULT_ADDR:-}" ]]; then
if check_vault_health "$VAULT_ADDR"; then
log_info "Using specified VAULT_ADDR: $VAULT_ADDR"
return 0
else
log_warn "Specified VAULT_ADDR ($VAULT_ADDR) unhealthy, trying cluster nodes..."
fi
fi
# Try each node in the cluster
for node in "${VAULT_NODES[@]}"; do
if check_vault_health "$node"; then
export VAULT_ADDR="$node"
log_info "Connected to Vault: $VAULT_ADDR"
return 0
else
log_warn "Node unhealthy: $node"
fi
done
# No healthy nodes found
log_error "No healthy Vault nodes found!"
log_error "Cluster nodes checked:"
for node in "${VAULT_NODES[@]}"; do
log_error " - $node"
done
log_error ""
log_error "Troubleshooting:"
log_error " 1. Check if Vault is running: ssh vault-01 'systemctl status vault'"
log_error " 2. Check if sealed: ssh vault-01 'vault status'"
log_error " 3. Unseal if needed: ssh -t vault-01 'vault operator unseal'"
log_error " 4. Check cluster: vault operator raft list-peers"
return 1
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
# Load Vault token from dsec
eval "$(dsource d000 dev/vault 2>/dev/null)" || {
log_error "Failed to load Vault credentials from dsec"
log_error "Run: dsec show d000/dev/vault"
exit 1
}
# Verify token is set
[[ -z "${VAULT_TOKEN:-}" ]] && {
log_error "VAULT_TOKEN not set after loading dsec credentials"
exit 1
}
# Find a healthy Vault node (sets VAULT_ADDR)
find_healthy_vault || exit 1
# Verify public key exists
[[ ! -f "${KEY}.pub" ]] && {
log_error "Public key not found: ${KEY}.pub"
log_error "Generate with: ssh-keygen -t ed25519 -f $KEY"
exit 1
}
log_info "Signing SSH key with Vault CA..."
log_info " Key: ${KEY}.pub"
log_info " Principals: ${PRINCIPALS}"
# Sign the key
if ! vault write -field=signed_key ssh/sign/domus-client \
public_key=@"${KEY}.pub" \
valid_principals="${PRINCIPALS}" > "$CERT"; then
log_error "Failed to sign SSH key"
log_error ""
log_error "Common issues:"
log_error " - Token expired: Re-run 'dsource d000 dev/vault'"
log_error " - Wrong role: Check 'vault read ssh/roles/domus-client'"
log_error " - Cluster unhealthy: 'vault operator raft list-peers'"
exit 1
fi
# Remove old key from agent and reload with new cert
ssh-add -d "$KEY" 2>/dev/null || true
ssh-add "$KEY"
# Display certificate details
echo ""
log_info "Certificate signed successfully:"
ssh-keygen -Lf "$CERT" | awk '/Valid:|Principals:|Type:|Key ID:/ {print " " $0}'
echo ""
log_info "Test with: vault-ssh-test"
log_info "Cluster status: vault operator raft list-peers"
vault-ssh-test
Tests SSH connectivity to all infrastructure hosts. Use after signing to verify certificate works.
cp docs/asciidoc/modules/ROOT/examples/vault-ssh-test.sh ~/.local/bin/vault-ssh-test
chmod +x ~/.local/bin/vault-ssh-test
vault-ssh-test # Test all hosts
vault-ssh-test --quick # Skip slow hosts (Windows DC)
Script Source
#!/bin/bash
# =============================================================================
# vault-ssh-test - Test Vault SSH CA connectivity to all infrastructure hosts
# =============================================================================
# Runbook: vault-ssh-ca.adoc
#
# Tests SSH connectivity to all hosts that trust the Vault SSH CA. Use this
# after running vault-ssh-sign to verify your certificate works everywhere.
#
# Exit codes:
# 0 - All hosts reachable
# 1 - One or more hosts unreachable
#
# Usage:
# vault-ssh-test # Test all hosts
# vault-ssh-test --quick # Skip slow hosts (Windows DC)
#
# =============================================================================
set -uo pipefail
# Infrastructure hosts organized by category
declare -A HOSTS=(
# Security
["vault-01"]="Vault PKI + SSH CA"
["ise-01"]="ISE 3.4 (if active)"
# Identity
["home-dc01"]="Windows Server 2025 DC"
["ipa-01"]="FreeIPA"
["keycloak-01"]="Keycloak IAM"
# Network
["vyos-01"]="VyOS HA Master"
["bind-01"]="BIND DNS"
# Compute
["kvm-01"]="KVM Hypervisor"
["k3s-master-01"]="k3s Master"
# Storage
["nas-01"]="Synology NAS"
# Workstations
["modestus-p50"]="ThinkPad P50 (gabriel)"
)
# Hosts that are slower or may be offline
SLOW_HOSTS=("home-dc01" "ise-01")
QUICK_MODE=false
[[ "${1:-}" == "--quick" ]] && QUICK_MODE=true
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
echo "=============================================="
echo " Vault SSH CA Connectivity Test"
echo "=============================================="
echo ""
# Show certificate info
CERT="$HOME/.ssh/id_ed25519_vault-cert.pub"
if [[ -f "$CERT" ]]; then
VALID=$(ssh-keygen -Lf "$CERT" 2>/dev/null | awk '/Valid:/{print $3, $4, $5}')
PRINCIPALS=$(ssh-keygen -Lf "$CERT" 2>/dev/null | awk '/Principals:/{getline; print}' | tr -d ' ')
echo "Certificate: $CERT"
echo "Valid: $VALID"
echo "Principals: $PRINCIPALS"
else
echo -e "${RED}ERROR: Certificate not found: $CERT${NC}"
echo "Run: vault-ssh-sign"
exit 1
fi
echo ""
echo "Testing hosts..."
echo ""
PASSED=0
FAILED=0
SKIPPED=0
for host in "${!HOSTS[@]}"; do
desc="${HOSTS[$host]}"
# Skip slow hosts in quick mode
if $QUICK_MODE && [[ " ${SLOW_HOSTS[*]} " =~ " ${host} " ]]; then
printf " %-20s %-30s %s\n" "$host" "$desc" "[SKIP]"
((SKIPPED++))
continue
fi
# Use 'exit 0' - portable across Linux/Windows/Synology
if timeout 5 ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=accept-new "$host" "exit 0" &>/dev/null; then
printf " ${GREEN}%-20s${NC} %-30s ${GREEN}%s${NC}\n" "$host" "$desc" "[OK]"
((PASSED++))
else
printf " ${RED}%-20s${NC} %-30s ${RED}%s${NC}\n" "$host" "$desc" "[FAIL]"
((FAILED++))
fi
done
echo ""
echo "=============================================="
printf "Results: ${GREEN}%d passed${NC}, ${RED}%d failed${NC}" "$PASSED" "$FAILED"
[[ $SKIPPED -gt 0 ]] && printf ", ${YELLOW}%d skipped${NC}" "$SKIPPED"
echo ""
echo "=============================================="
# Troubleshooting hints
if [[ $FAILED -gt 0 ]]; then
echo ""
echo "Troubleshooting:"
echo " 1. Re-sign cert: vault-ssh-sign"
echo " 2. Check principals: ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub"
echo " 3. Check agent: ssh-add -l | grep vault"
echo " 4. Manual test: ssh -v <host>"
exit 1
fi
exit 0
Related Documentation
| Document | Description |
|---|---|
KV v2 secrets engine deployment |
|
Enterprise PKI architecture |
|
Hardware key authentication |
Architecture Overview
Benefits Over Password Auth
| Feature | Password Auth | Certificate Auth |
|---|---|---|
Credential Lifetime |
Permanent until changed |
Short-lived (8h default) |
Revocation |
Change password everywhere |
Certificates expire automatically |
Audit Trail |
Limited |
Full Vault audit log |
Key Management |
Manual |
Centralized in Vault |
Multi-Factor |
Separate system |
Integrated with Vault auth |
Phase 1: Enable SSH Secrets Engine
1.1 Get Vault Credentials (Workstation)
dsource d000 dev/vault
This exports:
-
VAULT_UNSEAL_KEY_1 -
VAULT_UNSEAL_KEY_2 -
VAULT_TOKEN
History avoidance - Use a leading space to prevent secrets from being saved to ~/.bash_history:
|
echo $VAULT_TOKEN
echo $VAULT_UNSEAL_KEY_1
echo $VAULT_UNSEAL_KEY_2
Leading space works when HISTCONTROL includes ignorespace (default on most systems). Verify with echo $HISTCONTROL.
|
1.2 SSH to Vault Server
ssh vault-01
| You are now on the Vault server. All subsequent commands in Phase 1-3 execute here. |
1.3 Set Vault Address
export VAULT_ADDR='http://127.0.0.1:8200'
| Vault API runs on HTTP locally (loopback). HTTPS is only required for remote access. |
1.4 Check Vault Status
vault status
Filter to essential fields with awk:
vault status | awk 'NR <= 5'
Key Value --- ----- Seal Type shamir Initialized true Sealed false
awk pattern: awk 'CONDITION' - condition OUTSIDE braces triggers implicit print.awk 'NR ⇐ 5' = print lines where NR (line number) ≤ 5.awk '\{NR ⇐ 5\}' = WRONG (condition inside braces evaluates but does nothing).
|
1.5 Unseal Vault (If Sealed)
If Sealed = true, Vault cannot process requests. Unseal requires 2 of 3 key shares (Shamir threshold).
|
vault operator unseal
Paste VAULT_UNSEAL_KEY_1, press Enter.
vault operator unseal
Paste VAULT_UNSEAL_KEY_2, press Enter.
Verify unsealed:
vault status | awk '/Sealed/ {print $1, $2}'
1.6 Set Vault Token
export VAULT_TOKEN='<paste-VAULT_TOKEN-from-dsec>'
| The root token provides full Vault access. Use AppRole tokens for production automation. |
Phase 2: Generate CA Key Pair
| This generates the SSH Certificate Authority signing key. The private key never leaves Vault. The public key is distributed to all hosts that trust this CA. |
2.1 Generate Signing Key
vault write ssh/config/ca generate_signing_key=true
Key Value --- ----- public_key ssh-rsa AAAAB3NzaC1yc2EAAAA...
| Vault generates an RSA-4096 key pair by default. The CA private key is stored encrypted in Vault’s storage backend. |
2.3 Verify CA Public Key
View with line numbers (distinguished approach):
awk '{print NR": "$0}' /tmp/vault-ssh-ca.pub
View key type and length:
awk '{print "Type:", $1, "Length:", length($2)}' /tmp/vault-ssh-ca.pub
Type: ssh-rsa Length: 716
| RSA keys have base64-encoded data ~716 chars for 4096-bit. Ed25519 would show ~68 chars. |
Phase 3: Create Signing Roles
Roles define constraints for certificate signing: who can request, what users are allowed, TTL limits, and extensions.
3.1 Client Role (User Authentication)
Use JSON via heredoc - Vault CLI mangles nested JSON when passed as string arguments. Specifically, default_extensions is a map type that requires JSON structure.
|
vault write ssh/roles/domus-client - <<EOF
{
"key_type": "ca",
"allow_user_certificates": true,
"default_user": "evanusmodestus",
"allowed_users": "evanusmodestus,adminerosado,ansible,root",
"allowed_extensions": "permit-pty,permit-port-forwarding",
"default_extensions": {
"permit-pty": ""
},
"ttl": "8h",
"max_ttl": "24h"
}
EOF
If you omit default_extensions or pass it as K=V on CLI, you get: "Field validation failed: error converting input for field 'default_extensions': '' expected a map, got 'string'"
|
Multiple Principals - The allowed_users list should include ALL usernames you might use across different hosts. For example, NAS devices often use different usernames (e.g., adminerosado on Synology) than Linux servers (evanusmodestus).
|
allow_user_certificates: true is required - without it Vault returns: "Either 'allow_user_certificates' or 'allow_host_certificates' must be set to 'true'"
|
| Parameter | Purpose |
|---|---|
|
This role signs certificates (not OTP) |
|
Required - enables user certificate signing |
|
Required for host roles - enables host certificate signing |
|
Username if not specified in sign request |
|
Comma-separated list of permitted principals |
|
SSH certificate extensions that can be requested |
|
Extensions included by default (JSON map) |
|
Certificate validity period limits |
permit-pty allows interactive shell. Without it, SSH connects but no shell spawns.
|
3.2 Host Role (Server Authentication)
vault write ssh/roles/domus-host - <<EOF
{
"key_type": "ca",
"allow_host_certificates": true,
"allowed_domains": "inside.domusdigitalis.dev",
"allow_subdomains": true,
"ttl": "87600h",
"max_ttl": "175200h"
}
EOF
| Value | Duration |
|---|---|
87600h |
10 years |
175200h |
20 years |
8760h |
1 year |
720h |
30 days |
| Host certificates typically have long TTLs because re-signing requires sshd restart. User certificates should be short-lived (hours). |
Phase 4: Configure Target Hosts
Deploy the CA public key to hosts that should trust Vault-signed certificates.
Location context matters. The CA public key exists on vault-01 at /tmp/vault-ssh-ca.pub. You must transfer it to each target host. The method depends on your SSH access.
|
Authentication Problem
The ansible user on vault-01 likely does NOT have SSH access to your target hosts (modestus-aw, etc.). This is by design - the Vault server shouldn’t have broad network access.
Solution: Use your workstation (modestus-razer) as the transfer point. Your workstation has SSH keys/credentials for both vault-01 and target hosts.
4.1 Two-Hop Transfer via Workstation
| Run these commands from your workstation, NOT from vault-01. |
Commands use hostname only (e.g., vault-01, modestus-aw). Your ~/.ssh/config defines the user/key for each host. If you get "Permission denied", verify your SSH config.
|
Step 1: Pull CA from vault-01 to workstation
scp vault-01:/tmp/vault-ssh-ca.pub /tmp/
Step 2: Push CA from workstation to target host
scp /tmp/vault-ssh-ca.pub <target-host>:/tmp/
scp /tmp/vault-ssh-ca.pub modestus-aw:/tmp/
| For multiple hosts, loop it: |
for host in modestus-aw modestus-p50 nas-01; do
scp /tmp/vault-ssh-ca.pub ${host}:/tmp/
done
4.2 Install CA on Target Host
Step 3: SSH to target host
ssh <target-host>
ssh modestus-aw
You are now on the target host (modestus-aw). The CA file is at /tmp/vault-ssh-ca.pub from Step 2.
|
Step 4: Move CA to trusted location
sudo mv /tmp/vault-ssh-ca.pub /etc/ssh/vault-ca.pub
Step 5: Set permissions
sudo chmod 644 /etc/ssh/vault-ca.pub
Step 6: Verify installation
ls -la /etc/ssh/vault-ca.pub && awk '{print "Installed:", $1}' /etc/ssh/vault-ca.pub
-rw-r--r--. 1 root root 742 Feb 19 21:00 /etc/ssh/vault-ca.pub Installed: ssh-rsa
4.3 Configure sshd
Create drop-in configuration:
sudo tee /etc/ssh/sshd_config.d/vault-ca.conf << 'EOF'
# Vault SSH CA - Trust certificates signed by this CA
TrustedUserCAKeys /etc/ssh/vault-ca.pub
# AuthorizedPrincipalsFile: Optional - restrict which principals can login
# AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
EOF
| Do NOT disable PasswordAuthentication until certificate auth is verified working. |
Phase 5: Sign User SSH Key
Return to your workstation (not vault-01).
5.1 Generate User Key (if needed)
Check if key exists first:
[ -f ~/.ssh/id_ed25519_vault ] && echo "Key exists - skip generation" || echo "No key - generate one"
Generate only if needed:
[ -f ~/.ssh/id_ed25519_vault ] || ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_vault -C "vault-signed-$(date +%Y%m%d)"
The [ -f file ] || command pattern: if file does NOT exist, run command. Prevents accidental overwrite.
|
| Ed25519 is preferred over RSA for user keys (faster, smaller, equivalent security). The comment includes date for tracking. |
Store Passphrase in gopass (Recommended)
Generate passphrase and insert with metadata (heredoc):
PASS=$(gopass pwgen 32) && cat << EOF | gopass insert v3/domains/d000/identity/ssh/id_ed25519_vault
${PASS}
---
key_file: ~/.ssh/id_ed25519_vault
purpose: Vault SSH CA signed key
algorithm: ed25519
created: $(date +%Y-%m-%d)
EOF
The heredoc includes YAML metadata below the --- separator. gopass parses this for gopass show output.
|
Print password for ssh-keygen prompt:
gopass show -o v3/domains/d000/identity/ssh/id_ed25519_vault
Copy to clipboard:
gopass show -c v3/domains/d000/identity/ssh/id_ed25519_vault
Add key to ssh-agent using gopass:
gopass show -o v3/domains/d000/identity/ssh/id_ed25519_vault | ssh-add ~/.ssh/id_ed25519_vault
Create an alias: alias vault-ssh-unlock='gopass show -o v3/domains/d000/identity/ssh/id_ed25519_vault | ssh-add ~/.ssh/id_ed25519_vault'
|
5.2 Sign Public Key with Vault
| Your private key never leaves your workstation. Only the public key is sent to Vault for signing. The signed certificate is returned. Multiple signing options below depending on your Vault access configuration. |
Option A: Sign from vault-01 (Recommended)
If Vault is localhost-only (default), sign from vault-01 directly.
Step 1: Copy public key to vault-01
cat ~/.ssh/id_ed25519_vault.pub | ssh vault-01 "cat > /tmp/id_ed25519_vault.pub"
Step 2: Sign on vault-01
ssh vault-01
export VAULT_ADDR='http://127.0.0.1:8200'
vault write -field=signed_key ssh/sign/domus-client \
public_key=@/tmp/id_ed25519_vault.pub > /tmp/id_ed25519_vault-cert.pub
exit
Step 3: Copy certificate back to workstation
scp vault-01:/tmp/id_ed25519_vault-cert.pub ~/.ssh/
Option B: SSH Tunnel (Remote Signing)
If you have a valid Vault token locally, use SSH tunnel:
ssh -L 8200:127.0.0.1:8200 vault-01 -N &
export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN="$VAULT_ROOT_TOKEN"
vault write -field=signed_key ssh/sign/domus-client \
public_key=@$HOME/.ssh/id_ed25519_vault.pub >| ~/.ssh/id_ed25519_vault-cert.pub
Requires valid token in dsec. If token is stale, use Option A or update dsec with current token from vault token lookup on vault-01.
|
Option C: External TLS (Recommended for Enterprise)
Configure Vault listener with TLS for direct external access. See Vault External TLS for configuration.
After completing external TLS setup:
dsource d000 dev/vault
vault write -field=signed_key ssh/sign/domus-client \
public_key=@$HOME/.ssh/id_ed25519_vault.pub >| ~/.ssh/id_ed25519_vault-cert.pub
With external TLS, dsec provides VAULT_ADDR and VAULT_CACERT. No SSH tunnels required.
|
5.3 Verify Certificate
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub
Extract specific fields:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | awk '/Type:|Valid:|Principals:/'
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Valid: from 2026-02-19T21:00:00 to 2026-02-20T05:00:00
Principals:
Valid: shows the 8-hour TTL window. Certificate auth fails outside this window.
|
Phase 6: Test Certificate Authentication
6.1 Configure SSH Client
Option A: Wildcard Config (New Setup)
For hosts without existing SSH config:
tee -a ~/.ssh/config << 'EOF'
# Vault-signed certificate authentication
Host *.inside.domusdigitalis.dev
IdentityFile ~/.ssh/id_ed25519_vault
CertificateFile ~/.ssh/id_ed25519_vault-cert.pub
User evanusmodestus
EOF
Option B: Integrate with Existing Config (YubiKey Fallback)
For hosts with existing YubiKey/hardware key authentication, add vault cert at TOP of IdentityFile list:
Host kvm-01
HostName 192.168.1.225
User evanusmodestus
IdentityFile ~/.ssh/id_ed25519_vault
CertificateFile ~/.ssh/id_ed25519_vault-cert.pub
IdentityFile ~/.ssh/id_ed25519_sk_rk_d000_nano
IdentityFile ~/.ssh/id_ed25519_sk_rk_d000
IdentityFile ~/.ssh/id_ed25519_sk_rk_d000_secondary
IdentityFile ~/.ssh/id_ed25519_d000
PasswordAuthentication yes
PreferredAuthentications publickey,password
How it works:
-
SSH offers
id_ed25519_vault+ its cert first -
If server trusts the CA → cert auth succeeds
-
If not → falls back to YubiKey keys in order
-
Last resort → password
CertificateFile tells SSH to offer the certificate when using that key. Without it, SSH only offers the public key.
|
Option C: Test Without Modifying Config
Force vault key only (bypass SSH config entirely):
ssh -o ControlPath=none -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_vault <target-host> 'hostname'
Flags explained:
| Flag | Purpose |
|---|---|
|
Bypass SSH multiplexing (force fresh connection) |
|
ONLY use specified key, ignore agent and config |
|
Specify vault-signed key explicitly |
6.2 Test Connection
Quick test:
ssh <target-host> 'hostname'
Detailed validation with awk:
ssh -o ControlPath=none -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_vault <target-host> 'hostnamectl' | awk '/Static hostname|Operating System|Kernel|Architecture/ {gsub(/^[[:space:]]+/, ""); print}'
ssh -o ControlPath=none -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_vault kvm-01 'hostnamectl' | awk '/Static hostname|Operating System|Kernel|Architecture/ {gsub(/^[[:space:]]+/, ""); print}'
Static hostname: supermicro300-9d1 Operating System: Arch Linux Kernel: Linux 6.17.5-arch1-1 Architecture: x86-64
6.3 Verify Certificate Was Used
On the target host, check sshd logs:
sudo journalctl -u sshd -n 20 | awk '/Accepted.*CERT/'
Accepted publickey for evanusmodestus from 10.x.x.x port xxxxx ssh2: ED25519-CERT ID vault-userkey-evanusmodestus
ED25519-CERT confirms certificate auth. ED25519 alone would indicate key-based (non-certificate) auth.
|
Phase 7: Create Access Policy
For production, create a restricted policy instead of using root token.
7.1 SSH Client Policy
vault policy write ssh-client - << 'EOF'
# Allow signing SSH keys with the client role
path "ssh/sign/domus-client" {
capabilities = ["create", "update"]
}
# Allow reading CA public key (for host trust setup)
path "ssh/config/ca" {
capabilities = ["read"]
}
EOF
Automation: Certificate Renewal Script
| This script requires Vault External TLS configuration. Without external TLS, use Option A (manual signing on vault-01). |
CRITICAL: Vault SSH roles do NOT support default_principals. The parameter is silently ignored. You MUST specify valid_principals on every sign request. This script centralizes that requirement.
Principal Requirements by Host
| Host Type | User | Required Principal |
|---|---|---|
Linux workstations/servers |
evanusmodestus |
|
modestus-p50 workstation |
gabriel |
|
Synology NAS |
adminerosado |
|
Ansible automation |
ansible |
|
Emergency root access |
root |
|
VyOS / network devices |
vyos |
|
| Missing a principal = SSH falls back to YubiKey or password. If you’re prompted for YubiKey passphrase unexpectedly, check your cert principals. |
Install the Script
Create ~/.local/bin/vault-ssh-sign:
mkdir -p ~/.local/bin
cat > ~/.local/bin/vault-ssh-sign << 'SCRIPT'
#!/bin/bash
set -euo pipefail
KEY="${1:-$HOME/.ssh/id_ed25519_vault}"
CERT="${KEY}-cert.pub"
# ALL principals needed across infrastructure
# - adminerosado: Synology NAS
# - vyos: VyOS routers
# - ansible: automation
# - evanusmodestus: Linux workstations/servers
# - gabriel: modestus-p50 workstation
# - root: emergency access
PRINCIPALS="adminerosado,vyos,ansible,evanusmodestus,gabriel,root"
# Load Vault credentials from dsec
eval "$(dsource d000 dev/vault 2>/dev/null)" || {
echo "ERROR: Failed to load Vault credentials from dsec" >&2
exit 1
}
# Verify required environment variables
[[ -z "${VAULT_ADDR:-}" ]] && { echo "ERROR: VAULT_ADDR not set" >&2; exit 1; }
[[ -z "${VAULT_TOKEN:-}" ]] && { echo "ERROR: VAULT_TOKEN not set" >&2; exit 1; }
# Verify public key exists
[[ ! -f "${KEY}.pub" ]] && { echo "ERROR: Public key not found: ${KEY}.pub" >&2; exit 1; }
# Sign the key with ALL principals
vault write -field=signed_key ssh/sign/domus-client \
public_key=@"${KEY}.pub" \
valid_principals="$PRINCIPALS" > "$CERT"
# Reload agent with new cert
ssh-add -d "$KEY" 2>/dev/null || true
ssh-add "$KEY"
# Display validity and principals
echo "Certificate signed successfully:"
ssh-keygen -Lf "$CERT" | awk '/Valid:|Principals:|Extensions:/' | head -20
SCRIPT
chmod +x ~/.local/bin/vault-ssh-sign
Add New Principal to Vault Role
When adding a new host that uses a different username (e.g., gabriel for modestus-p50), you must update BOTH the Vault role AND the signing script.
Step 1: Check current allowed_users
vault read ssh/roles/domus-client | grep allowed_users
Step 2: Update Vault role (requires admin token)
vault write ssh/roles/domus-client - <<'EOF'
{
"key_type": "ca",
"allow_user_certificates": true,
"default_user": "evanusmodestus",
"allowed_users": "adminerosado,admin,ansible,evanusmodestus,gabriel,root",
"allowed_extensions": "permit-pty,permit-port-forwarding",
"default_extensions": {
"permit-pty": "",
"permit-port-forwarding": ""
},
"ttl": "8h",
"max_ttl": "24h"
}
EOF
Step 3: Update vault-ssh-sign script (see Update Existing Script (Add Missing Principal))
Step 4: Re-sign and test
vault-ssh-sign
ssh <new-host>
If you only update the script but not the Vault role, signing will fail with: <principal> is not a valid value for valid_principals
|
Update Existing Script (Add Missing Principal)
If your script is missing a principal (e.g., vyos for VyOS routers):
# Check current principals in script
grep PRINCIPALS ~/.local/bin/vault-ssh-sign
# Add missing principal (example: adding 'admin')
sed -i 's/PRINCIPALS="adminerosado,ansible,evanusmodestus,root"/PRINCIPALS="adminerosado,vyos,ansible,evanusmodestus,gabriel,root"/' ~/.local/bin/vault-ssh-sign
# Verify
grep PRINCIPALS ~/.local/bin/vault-ssh-sign
Or for the old format (valid_principals inline):
sed -i 's/valid_principals="ansible,evanusmodestus,root"/valid_principals="adminerosado,admin,ansible,evanusmodestus,root"/' ~/.local/bin/vault-ssh-sign
Usage:
vault-ssh-sign # Default key
vault-ssh-sign ~/.ssh/id_ed25519_custom # Custom key
Enter passphrase for /home/evanusmodestus/.ssh/id_ed25519_vault:
Identity added: /home/evanusmodestus/.ssh/id_ed25519_vault (vault-signed-20260219)
Certificate added: /home/evanusmodestus/.ssh/id_ed25519_vault-cert.pub (...)
Certificate signed successfully:
Valid: from 2026-02-21T00:53:42 to 2026-02-21T08:54:12
Extensions:
Automatic Renewal (cron):
Add to crontab to renew every 6 hours (before 8h expiry):
crontab -e
Add line:
0 */6 * * * ~/.local/bin/vault-ssh-sign 2>/dev/null
Cron renewal requires the SSH key passphrase in the agent or no passphrase. For passphrase-protected keys, run vault-ssh-sign manually at start of workday.
|
If agent reload fails:
If the script completes but connections still use the old cert or fallback key, manually reload the agent:
SSH_AUTH_SOCK=/run/user/1000/ssh-agent.socket ssh-add ~/.ssh/id_ed25519_vault
Enter passphrase for /home/evanusmodestus/.ssh/id_ed25519_vault: Identity added: /home/evanusmodestus/.ssh/id_ed25519_vault (vault-signed-20260219) Certificate added: /home/evanusmodestus/.ssh/id_ed25519_vault-cert.pub (...) Lifetime set to 08:03:18
This happens when SSH_AUTH_SOCK isn’t set in the script’s environment (e.g., running from certain terminal contexts).
Sign Host Certificates
Optional: Sign host keys so clients can verify server identity without TOFU.
Generate Host Key Certificate
On the host (as root):
vault write -field=signed_key ssh/sign/domus-host \
cert_type=host \
public_key=@/etc/ssh/ssh_host_ed25519_key.pub \
valid_principals="$(hostname -f)" \
> /etc/ssh/ssh_host_ed25519_key-cert.pub
Rollback Procedure
| Only use rollback if certificate auth is not working and you need to restore access. |
Disable SSH Engine
vault secrets disable ssh
| This immediately invalidates ALL certificates signed by this CA. |
Completion Checklist
| Phase | Task | Status |
|---|---|---|
1 |
SSH secrets engine enabled at |
[ ] |
2 |
CA key pair generated, public key exported |
[ ] |
3 |
|
[ ] |
4 |
CA public key installed on target hosts |
[ ] |
5 |
User SSH key signed with Vault |
[ ] |
6 |
Certificate authentication tested successfully |
[ ] |
7 |
Access policy and AppRole created for non-root usage |
[ ] |
Troubleshooting
PTY Allocation Request Failed
CRITICAL: If you see PTY allocation request failed after successful SSH connection, your certificate is missing the permit-pty extension.
Diagnose:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | grep -A3 Extensions
Extensions: (none)
Extensions:
permit-port-forwarding
permit-pty
Root Cause: The signing role has allowed_extensions but empty default_extensions. Vault allows the extensions but doesn’t include them by default.
Fix the role (requires Vault admin/root token):
vault write ssh/roles/domus-client - <<'EOF'
{
"key_type": "ca",
"allow_user_certificates": true,
"default_user": "evanusmodestus",
"allowed_users": "evanusmodestus,adminerosado,admin,administrator,domus\\administrator,ansible,root",
"allowed_extensions": "permit-pty,permit-port-forwarding",
"default_extensions": {
"permit-pty": "",
"permit-port-forwarding": ""
},
"ttl": "8h",
"max_ttl": "24h"
}
EOF
Use heredoc with <<'EOF' (quoted) - the backslash in domus\\administrator requires literal interpretation. Unquoted EOF causes JSON parse errors.
|
Re-sign your certificate:
vault write -field=signed_key ssh/sign/domus-client \
public_key=@$HOME/.ssh/id_ed25519_vault.pub \
valid_principals="admin,adminerosado,ansible,evanusmodestus,gabriel,root" \
>| ~/.ssh/id_ed25519_vault-cert.pub
Clear cached connections and reload agent:
ssh -O exit <target-host> 2>/dev/null
ssh-add -d ~/.ssh/id_ed25519_vault 2>/dev/null
ssh-add ~/.ssh/id_ed25519_vault
Test:
ssh <target-host>
The SSH agent caches the old certificate. If you re-sign but don’t reload the agent (ssh-add), you’ll still get PTY failures.
|
Certificate Rejected
Check certificate validity window:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | awk '/Valid:/'
If expired, re-sign:
vault-ssh-sign
SSH Falls Back to YubiKey (Missing Principal)
Symptom: SSH prompts for YubiKey passphrase instead of using Vault cert:
Enter passphrase for key '/home/evanusmodestus/.ssh/id_ed25519_sk_rk_d000_nano':
Diagnosis: Cert is missing the principal for the target host’s user:
# Check what principals your cert has
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | grep -A6 "Principals:"
# Check what user the SSH config uses for this host
grep -A5 "Host <hostname>" ~/.ssh/config | grep -i user
Common missing principals:
| Host | Required Principal |
|---|---|
nas-01 (Synology) |
|
vyos-01 / vyos-02 |
|
Network devices |
|
Fix: Add missing principal to vault-ssh-sign script:
# Example: add 'vyos' for VyOS routers
sed -i 's/valid_principals="\([^"]*\)"/valid_principals="\1,vyos"/' ~/.local/bin/vault-ssh-sign
# Re-sign with updated principals
vault-ssh-sign
# Verify principal is now included
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | grep -A6 "Principals:"
CRITICAL: Vault SSH roles do NOT support default_principals. This parameter is silently ignored. You MUST specify valid_principals on every sign request. The vault-ssh-sign script centralizes this requirement.
Too Many Authentication Failures (Empty Principals)
Symptom: SSH tries all keys then fails:
Received disconnect from 10.50.1.70 port 22:2: Too many authentication failures
Diagnosis: Check if principals are empty:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | grep -A1 "Principals:"
If output shows empty principals:
Principals:
(none)
Cause: Cert was signed without valid_principals parameter, OR manual sign overrode the script.
Fix: Use the vault-ssh-sign script (never sign manually):
vault-ssh-sign
Or if script unavailable, sign with ALL principals:
dsource d000 dev/vault
vault write -field=signed_key ssh/sign/domus-client \
public_key=@$HOME/.ssh/id_ed25519_vault.pub \
valid_principals="adminerosado,admin,ansible,evanusmodestus,root" \
>| ~/.ssh/id_ed25519_vault-cert.pub
ssh-add -d ~/.ssh/id_ed25519_vault
ssh-add ~/.ssh/id_ed25519_vault
Always use vault-ssh-sign script. Manual signing risks forgetting principals.
|
Permission Denied (Principal Mismatch)
Verify certificate principals match the login user:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | awk '/Principals:/{getline; print}'
Check sshd logs for specific error:
sudo journalctl -u sshd --since "5 minutes ago" | awk '/error|refused|denied/i'
CA Not Trusted
Verify CA is installed on target host:
ssh <target-host> "ls -la /etc/ssh/vault-ca.pub && awk '{print \$1}' /etc/ssh/vault-ca.pub"
Check sshd recognizes the CA:
ssh <target-host> "sudo sshd -T | awk '/trustedusercakeys/'"
Debug SSH Connection
Verbose SSH output:
ssh -vvv <target-host> 2>&1 | awk '/Offering|Server accepts|certificate/'
Key lines to look for:
-
Offering public key: … ED25519-CERT- certificate being offered -
Server accepts key: … ED25519-CERT- server accepted certificate
Bypass SSH Multiplexing for Testing
If you have SSH multiplexing enabled (ControlMaster/ControlPersist), existing connections reuse authentication. This can mask whether certificate auth actually works.
|
Force fresh connection (bypass mux):
ssh -o ControlPath=none -vvv <target-host> 2>&1 | awk '/Offering|Server accepts|cert/'
Confirm certificate auth specifically:
ssh -o ControlPath=none <target-host> 'echo "Certificate auth verified: $(date)"'
Server-side confirmation:
Look for cert: key options: pty in debug output:
ssh -vvv <target-host> 2>&1 | awk '/Remote: cert:/'
debug1: Remote: cert: key options: pty
cert: key options: pty confirms the server accepted a certificate with permit-pty extension. Without this line, you’re using key-based (non-certificate) auth.
|
Synology DSM Considerations
Synology NAS (DSM) may have SSH CA limitations. DSM uses a modified OpenSSH that doesn’t always respect TrustedUserCAKeys in the same way as standard Linux distributions.
|
Known Issues:
-
Certificate offered but falls back to public key auth
-
TrustedUserCAKeysdirective may be ignored -
DSM SSH service runs with non-standard paths
Workaround: Use regular SSH key authentication for Synology. Focus SSH CA on standard Linux hosts.
| If you must use cert auth on Synology, test thoroughly and consider DSM version - newer versions have better OpenSSH compatibility. |
Kill Stuck SSH Sessions
When testing certificate auth, SSH sessions can hang waiting for authentication. Find and kill them:
Find stuck sessions:
ps aux | awk '/ssh.*<hostname>/ {print $2, $11, $12}'
3236333 | | 3245292 | | 3206218 | |
Kill by pattern:
pkill -f "ssh.*nas-01"
pkill -f "ssh.*kvm-01"
Inside a stuck session - SSH escape sequence:
~.
Press tilde then period to force disconnect.
The pkill -f pattern matches the full command line, so "ssh.*nas-01" catches any SSH process targeting that host.
|
Mobile Device Onboarding (Termux/Android)
SSH from workstation TO mobile device (e.g., ZFold7 with Termux).
Why Vault CA is Complex for Mobile
Termux runs as Android user u0_a361 (not evanusmodestus). Full Vault CA integration requires:
-
Adding
u0_a361to Vault role principals -
Copying Vault CA pubkey to phone
-
Configuring Termux sshd with
TrustedUserCAKeys
Recommendation: Use simple authorized_keys for 1-2 mobile devices. Consider MDM + SCEP for 5+ devices.
Simple Setup (Authorized Keys)
Prerequisites
-
Termux installed with
opensshpackage -
Phone on same network (e.g., 10.50.10.x)
-
sshd running on Termux (port 8022)
Step 1: Start Termux sshd
On phone (Termux):
pkg install openssh
sshd
whoami # Note the user (e.g., u0_a361)
ip addr show wlan0 | awk '/inet /{print $2}' # Note IP
Step 3: Add to Termux authorized_keys
On phone (Termux) - paste pubkey:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys << 'EOF'
ssh-ed25519 AAAAC3NzaC... your-pubkey-here
EOF
chmod 600 ~/.ssh/authorized_keys
Full Vault CA Integration (Recommended)
Full certificate-based auth with 8-hour TTL - more secure than static authorized_keys.
Why This Is Better
| Aspect | authorized_keys | Vault CA Certificate |
|---|---|---|
Expiration |
Never |
8 hours (configurable) |
Revocation |
Manual removal from each device |
Don’t re-sign (centralized) |
Rotation |
Manual |
Automatic with |
Audit trail |
None |
Vault audit logs |
Passwordless |
No (or passphrase) |
Yes (SSH agent holds signed cert) |
Step 1: Add Termux User to Vault Role
On workstation, update the Vault SSH role to allow u0_a361 principal:
# Load Vault credentials
eval "$(dsource d000 dev/vault)"
# Update role with u0_a361 (use heredoc for default_extensions map)
vault write ssh/roles/domus-client - <<'EOF'
{
"key_type": "ca",
"allow_user_certificates": true,
"default_user": "evanusmodestus",
"allowed_users": "Administrator,domus\\Administrator,admin,adminerosado,ansible,evanusmodestus,gabriel,root,u0_a361",
"allowed_extensions": "permit-pty,permit-port-forwarding",
"default_extensions": {
"permit-pty": "",
"permit-port-forwarding": ""
},
"ttl": "8h",
"max_ttl": "24h"
}
EOF
Verify:
vault read ssh/roles/domus-client -format=json | jq -r '.data.allowed_users' | tr ',' '\n' | grep u0_a361
Step 2: Update vault-ssh-sign Script
Add u0_a361 to the PRINCIPALS in ~/.local/bin/vault-ssh-sign:
PRINCIPALS="Administrator,domus\\Administrator,adminerosado,admin,ansible,evanusmodestus,gabriel,root,u0_a361"
Step 3: Re-Sign Certificate
vault-ssh-sign
Verify u0_a361 appears in principals:
Certificate signed successfully:
Valid: from 2026-02-26T18:43:55 to 2026-02-27T02:44:25
Principals:
Administrator
admin
...
u0_a361 <-- Must be here
Step 4: Get Vault CA Public Key
On workstation:
vault read -field=public_key ssh/config/ca
Copy the entire output (starts with ssh-rsa AAAA…).
Step 5: Configure Termux sshd
On phone (Termux):
# Save Vault CA pubkey
cat > ~/.ssh/vault-ca.pub << 'EOF'
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQ... (paste full key)
EOF
# Add TrustedUserCAKeys to sshd config
echo "TrustedUserCAKeys /data/data/com.termux/files/home/.ssh/vault-ca.pub" >> $PREFIX/etc/ssh/sshd_config
# Verify
tail -3 $PREFIX/etc/ssh/sshd_config
# ForceCommand cvs server
TrustedUserCAKeys /data/data/com.termux/files/home/.ssh/vault-ca.pub
Step 7: Test Certificate Auth
On workstation:
# Clear any existing mux connection
ssh -O exit fold7 2>/dev/null
# Test with verbose output
ssh -v fold7 2>&1 | grep -E "Server accepts|CERT|Authenticated"
debug1: Server accepts key: ...id_ed25519_vault-cert.pub ED25519-CERT SHA256:...
Authenticated to 10.50.10.100 ([10.50.10.100]:8022) using "publickey".
Key indicators of certificate auth:
-
ED25519-CERT(not justED25519) - certificate, not raw pubkey -
Server accepts keyon first attempt - no fallback to other keys -
No passphrase prompt - SSH agent has the signed cert loaded
Why No Password Prompt?
The vault-ssh-sign script does two things:
-
Signs your public key with Vault CA
-
Runs
ssh-addto load the private key + certificate into the SSH agent
Since the agent holds the key, SSH uses it automatically without prompting.
ssh-add -l | grep vault
256 SHA256:S6ret9c2fdBIXkeUE5JWUxTOQoeZ98DLWUNJ/+nTsGM vault-signed-20260219 (ED25519-CERT)
Note: ED25519-CERT confirms the agent has the certificate, not just the raw key.
Security Model
┌─────────────────────────────────────────────────────────────────────┐
│ WORKSTATION │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Private Key │───▶│ Vault │───▶│ Signed Certificate │ │
│ │ (encrypted) │ │ SSH CA │ │ (8h TTL, principals) │ │
│ └─────────────┘ └─────────────┘ └───────────┬─────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────▼──────────────┐ │
│ │ SSH Agent │ │
│ │ Holds: private key + certificate (no passphrase needed) │ │
│ └─────────────────────────────────────────────────┬──────────────┘ │
└────────────────────────────────────────────────────┼────────────────┘
│
│ SSH connection
│ (presents cert)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PHONE (Termux) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ sshd │ │
│ │ TrustedUserCAKeys: ~/.ssh/vault-ca.pub │ │
│ │ │ │
│ │ Validates: │ │
│ │ ✓ Certificate signed by trusted CA │ │
│ │ ✓ Certificate not expired (< 8h old) │ │
│ │ ✓ Principal 'u0_a361' in certificate │ │
│ │ ✓ Extensions permit-pty, permit-port-forwarding │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ NO secrets stored on phone - only the CA public key (not secret) │
└─────────────────────────────────────────────────────────────────────┘
Troubleshooting
Cert offered but rejected:
Check principals include u0_a361:
ssh-keygen -Lf ~/.ssh/id_ed25519_vault-cert.pub | grep -A20 Principals
Connection refused:
Restart sshd on phone:
pkill sshd; sshd
Falls back to other keys:
Verify CA pubkey on phone matches workstation:
# On workstation
vault read -field=public_key ssh/config/ca | sha256sum
# On phone
cat ~/.ssh/vault-ca.pub | sha256sum
Both should match.
| For 5+ mobile devices, consider Headwind MDM (free) or Microsoft Intune with SCEP for automated certificate enrollment. See [_mdm_integration_roadmap]. |