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:/'
Expected Output
        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).

Use the automated script:

vault-ssh-sign

This script:

  1. Loads Vault credentials from gopass

  2. Signs with all required principals

  3. 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

Too many authentication failures

Empty principals - re-sign with principals

Permission denied (publickey)

Principal mismatch - check cert principals vs login user

Certificate not yet valid

Clock skew - run timedatectl on both ends, enable NTP

PTY allocation request failed

Missing permit-pty - check role default_extensions

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
Expected Output
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".

Authentication Flow

Vault SSH CA Flow
Figure 1. Vault SSH CA Authentication Flow

Key Order (Fallback Chain)

SSH attempts authentication in this order:

  1. Vault cert (id_ed25519_vault-cert.pub) - Short-lived, auto-expires

  2. YubiKey nano (id_ed25519_sk_rk_d000_nano) - Hardware token fallback

  3. YubiKey primary (id_ed25519_sk_rk_d000) - Hardware token fallback

  4. YubiKey secondary (id_ed25519_sk_rk_d000_secondary) - Hardware token fallback

  5. 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.

Installation
cp docs/asciidoc/modules/ROOT/examples/vault-ssh-sign ~/.local/bin/
chmod +x ~/.local/bin/vault-ssh-sign
Usage
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.

Installation
cp docs/asciidoc/modules/ROOT/examples/vault-ssh-test.sh ~/.local/bin/vault-ssh-test
chmod +x ~/.local/bin/vault-ssh-test
Usage
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
Document Description

Vault KV Secrets

KV v2 secrets engine deployment

PKI Strategy

Enterprise PKI architecture

YubiKey SSH Validation

Hardware key authentication

Architecture Overview

SSH Certificate Flow

SSH Certificate Flow

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'
Expected Output (Unsealed)
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.

1.7 Enable SSH Engine

vault secrets enable -path=ssh ssh
Expected Output
Success! Enabled the ssh secrets engine at: ssh/

1.8 Verify Mount

vault secrets list | awk '/ssh/ {print $1, $2}'
Expected Output
ssh/ ssh

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
Expected Output
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.2 Export CA Public Key

vault read -field=public_key ssh/config/ca > /tmp/vault-ssh-ca.pub

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
Expected Output
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'"
Table 1. Role Parameters Explained
Parameter Purpose

key_type: "ca"

This role signs certificates (not OTP)

allow_user_certificates: true

Required - enables user certificate signing

allow_host_certificates: true

Required for host roles - enables host certificate signing

default_user

Username if not specified in sign request

allowed_users

Comma-separated list of permitted principals

allowed_extensions

SSH certificate extensions that can be requested

default_extensions

Extensions included by default (JSON map)

ttl / max_ttl

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
Table 2. TTL Reference
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).

3.3 Verify Roles

vault list ssh/roles
Expected Output
Keys
domus-client
domus-host
Vault output includes a ---- separator line between "Keys" and the role names - omitted here to avoid AsciiDoc conflicts.

View role configuration:

vault read ssh/roles/domus-client | awk 'NR <= 15'

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/
Example: modestus-aw
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>
Example
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
Expected Output
-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.

4.4 Validate sshd Configuration

sudo sshd -t

No output = valid configuration. Any errors must be resolved before restart.

4.5 Restart SSH

sudo systemctl restart sshd

Verify sshd is running:

systemctl is-active sshd && echo "sshd running"
Keep your current SSH session open. Test certificate auth in a NEW terminal before closing.

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.

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.

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.

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:/'
Expected Output
        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:

  1. SSH offers id_ed25519_vault + its cert first

  2. If server trusts the CA → cert auth succeeds

  3. If not → falls back to YubiKey keys in order

  4. 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

-o ControlPath=none

Bypass SSH multiplexing (force fresh connection)

-o IdentitiesOnly=yes

ONLY use specified key, ignore agent and config

-i ~/.ssh/id_ed25519_vault

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}'
Example: kvm-01
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}'
Expected Output
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/'
Expected Output
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

7.2 Verify Policy

vault policy read ssh-client

7.3 Enable AppRole Auth Method

AppRole must be enabled before creating roles. This is a one-time setup.
vault auth enable approle
Expected Output
Success! Enabled approle auth method at: approle/

7.4 Create AppRole for SSH Signing

vault write auth/approle/role/ssh-user \
  token_policies="ssh-client" \
  token_ttl="1h" \
  token_max_ttl="4h"
Expected Output
Success! Data written to: auth/approle/role/ssh-user

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

evanusmodestus

modestus-p50 workstation

gabriel

gabriel

Synology NAS

adminerosado

adminerosado

Ansible automation

ansible

ansible

Emergency root access

root

root

VyOS / network devices

vyos

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
Expected Output
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
Expected Output
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

Configure Host to Present Certificate

Add to /etc/ssh/sshd_config:

echo "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub" | sudo tee -a /etc/ssh/sshd_config
sudo systemctl restart sshd

Client Trust Configuration

On client workstation, add CA to known_hosts:

echo "@cert-authority *.inside.domusdigitalis.dev $(< /tmp/vault-ssh-ca.pub)" >> ~/.ssh/known_hosts
This eliminates "authenticity of host can’t be established" prompts for any host with a valid Vault-signed certificate.

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.

Restore Host Configuration

On each configured host:

sudo rm -f /etc/ssh/sshd_config.d/vault-ca.conf
sudo rm -f /etc/ssh/vault-ca.pub
sudo systemctl restart sshd

Re-enable Password Auth (if disabled)

sudo sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
sudo systemctl restart sshd

Completion Checklist

Phase Task Status

1

SSH secrets engine enabled at ssh/

[ ]

2

CA key pair generated, public key exported

[ ]

3

domus-client and domus-host roles created

[ ]

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
Broken Output (no extensions)
        Extensions: (none)
Working Output (has permit-pty)
        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)

adminerosado

vyos-01 / vyos-02

vyos

Network devices

admin

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:/'
Expected Output (Certificate Authentication)
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

  • TrustedUserCAKeys directive 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}'
Example Output
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:

  1. Adding u0_a361 to Vault role principals

  2. Copying Vault CA pubkey to phone

  3. 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 openssh package

  • 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 2: Get Workstation Pubkey

On workstation:

cat ~/.ssh/id_ed25519_vault.pub

Copy the output.

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

Step 4: Configure Workstation SSH

On workstation, add to ~/.ssh/config:

Host fold7
    HostName 10.50.10.100
    Port 8022
    User u0_a361
    IdentityFile ~/.ssh/id_ed25519_vault
    IdentitiesOnly yes

Step 5: Test Connection

ssh fold7

Should connect using Vault key without YubiKey prompts.

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 vault-ssh-sign

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:

Expected Output
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
Expected Output
#    ForceCommand cvs server
TrustedUserCAKeys /data/data/com.termux/files/home/.ssh/vault-ca.pub

Step 6: Restart Termux sshd

On phone:

pkill sshd; sshd

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"
Expected Output (Success)
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 just ED25519) - certificate, not raw pubkey

  • Server accepts key on 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:

  1. Signs your public key with Vault CA

  2. Runs ssh-add to load the private key + certificate into the SSH agent

Since the agent holds the key, SSH uses it automatically without prompting.

Verify agent has the cert
ssh-add -l | grep vault
Expected Output
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].