# SSH Hardening Reference

## What This Page Covers

This page walks through every directive in `roles/ssh-baseline/templates/sshd_hardening.conf.j2` and explains how it lands on the target host. The deployed file is `/etc/ssh/sshd_config.d/10-pbr-hardening.conf`.

The hardening is aligned with CIS Ubuntu Linux 22.04 Benchmark v2.0.0. Where we deviate, it's documented inline and below.

---

## How the Config Reaches sshd

### Drop-in directory pattern

Ubuntu's `sshd_config` reads drop-in files from `/etc/ssh/sshd_config.d/` via an `Include` directive. Cloud-init images have this by default; some ISO installs do not. The role ensures the include is present:

```yaml
- name: Ensure sshd_config has Include directive for drop-ins
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    line: "Include /etc/ssh/sshd_config.d/*.conf"
    insertbefore: BOF
    state: present
    validate: "/usr/sbin/sshd -t -f %s"
  notify: Restart sshd
```

**Why insert at BOF (beginning of file):** sshd uses first-match-wins semantics for most directives. Placing the Include directive at the top of `sshd_config` means drop-ins are evaluated first — our hardening directives win over any conflicting directive later in the base config.

### Filename prefix: 10-

The deployed file is named `10-pbr-hardening.conf`. Drop-ins are loaded in lexicographic order. The `10-` prefix ensures our file loads before Ubuntu's default `50-cloud-init.conf`, which sets `PasswordAuthentication yes`. Without the `10-` prefix and first-match-wins, cloud-init's value could win.

### Validation gating

Both the Include line and the hardening file are written with `validate: "/usr/sbin/sshd -t -f %s"`. Ansible writes to a temp file, runs `sshd -t -f <tempfile>` against it, and only moves the temp file into place if validation passes. After the file is in place, the role also runs a final `sshd -t` against the live combined config (defence in depth).

---

## The Hardening File: Full Source

Template: `roles/ssh-baseline/templates/sshd_hardening.conf.j2`. Rendered output (all variables substituted with their defaults):

```ini
# PBR SSH Hardening - Managed by Ansible, do not edit manually
# CIS Ubuntu Linux 22.04 Benchmark v2.0.0 aligned

Port 22
LogLevel VERBOSE
LoginGraceTime 60

# === Authentication ===
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
MaxAuthTries 3
GSSAPIAuthentication no
UsePAM yes
UseDNS no

# === Compliance affirmations (defaults made explicit for audit evidence) ===
IgnoreRhosts yes
HostbasedAuthentication no
PermitEmptyPasswords no
PermitUserEnvironment no

# === Session management ===
MaxSessions 4
MaxStartups 10:30:60
ClientAliveInterval 300
ClientAliveCountMax 2

# === Forwarding ===
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no

# === Other hardening ===
Compression no
TCPKeepAlive no

# === Modern crypto ===
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256

# === Legal banner ===
Banner /etc/issue.net

# === Access control ===
AllowGroups sudo sg_serveraccess sg_sudo

# === SSH key retrieval ===
AuthorizedKeysFile none
AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u
AuthorizedKeysCommandUser nobody

# === Break-glass: pbr_admin ===
Match User pbr_admin Address 10.1.0.0/16,192.168.0.0/16
    PasswordAuthentication yes
    AuthenticationMethods password

# === Ansible automation account ===
Match User ansible
    AuthorizedKeysFile .ssh/authorized_keys
    AuthenticationMethods publickey
    KbdInteractiveAuthentication no
```

---

## Directive Walkthrough

### Authentication block

<table id="bkmrk-directivevaluenotes-"><thead><tr><th>Directive</th><th>Value</th><th>Notes</th></tr></thead><tbody><tr><td>`PermitRootLogin`</td><td>`no`</td><td>Root never logs in directly. Use `pbr_admin` + sudo or AD user + sudo.</td></tr><tr><td>`PasswordAuthentication`</td><td>`no`</td><td>Disabled globally. Re-enabled only inside the `pbr_admin` Match block.</td></tr><tr><td>`PubkeyAuthentication`</td><td>`yes`</td><td>Required by all flows except `pbr_admin`.</td></tr><tr><td>`KbdInteractiveAuthentication`</td><td>`yes`</td><td>Required for Duo PAM keyboard-interactive challenge. Disabled in `ansible` Match block.</td></tr><tr><td>`AuthenticationMethods`</td><td>`publickey,keyboard-interactive`</td><td>Both required. Overridden per-user in Match blocks for `pbr_admin` (password) and `ansible` (publickey only).</td></tr><tr><td>`MaxAuthTries`</td><td>`3`</td><td>Per-connection auth attempt limit.</td></tr><tr><td>`GSSAPIAuthentication`</td><td>`no`</td><td>We don't use GSSAPI/Kerberos for SSH auth. AD password validation happens via PAM/SSSD, not via Kerberos ticket forwarding.</td></tr><tr><td>`UsePAM`</td><td>`yes`</td><td>Required — Duo and pam\_sss live in PAM.</td></tr><tr><td>`UseDNS`</td><td>`no`</td><td>Don't reverse-resolve client IPs into hostnames. Eliminates a slow DNS lookup on every connection and avoids confusion when client reverse-DNS is broken.</td></tr></tbody></table>

### Compliance affirmations

These four directives are defaults in OpenSSH but stated explicitly for audit evidence:

<table id="bkmrk-directivevaluewhat-i"><thead><tr><th>Directive</th><th>Value</th><th>What it prevents</th></tr></thead><tbody><tr><td>`IgnoreRhosts`</td><td>`yes`</td><td>.rhosts / .shosts trust files cannot be used for auth.</td></tr><tr><td>`HostbasedAuthentication`</td><td>`no`</td><td>Trust-by-host-key auth disabled.</td></tr><tr><td>`PermitEmptyPasswords`</td><td>`no`</td><td>Empty passwords cannot authenticate. (Belt-and-braces; `PasswordAuthentication no` already disallows.)</td></tr><tr><td>`PermitUserEnvironment`</td><td>`no`</td><td>Users cannot inject environment vars via `~/.ssh/environment` — prevents PATH/LD\_PRELOAD-style attacks.</td></tr></tbody></table>

### Session management

<table id="bkmrk-directivevaluenotes--1"><thead><tr><th>Directive</th><th>Value</th><th>Notes</th></tr></thead><tbody><tr><td>`MaxSessions`</td><td>`4`</td><td>Concurrent multiplexed sessions per SSH connection. CIS recommendation.</td></tr><tr><td>`MaxStartups`</td><td>`10:30:60`</td><td>Up to 10 unauth'd connections; from 10-60, drop 30% randomly; reject at 60. Mitigates connection-exhaustion DoS.</td></tr><tr><td>`ClientAliveInterval`</td><td>`300`</td><td>Send keepalive probes every 5 minutes.</td></tr><tr><td>`ClientAliveCountMax`</td><td>`2`</td><td>Drop the connection after 2 missed keepalives. Idle sessions die after 10 minutes.</td></tr></tbody></table>

### Forwarding (all disabled)

<table id="bkmrk-directivevaluewhat-i-1"><thead><tr><th>Directive</th><th>Value</th><th>What it prevents</th></tr></thead><tbody><tr><td>`AllowTcpForwarding`</td><td>`no`</td><td>Local/remote port forwarding. No tunnel-the-DB-over-ssh patterns.</td></tr><tr><td>`X11Forwarding`</td><td>`no`</td><td>Graphical apps via X over SSH. Unused at PBR.</td></tr><tr><td>`AllowAgentForwarding`</td><td>`no`</td><td>Forwarding ssh-agent to the remote host (would let a malicious admin on the remote pivot using your keys).</td></tr></tbody></table>

### Other hardening

<table id="bkmrk-directivevaluenotes--2"><thead><tr><th>Directive</th><th>Value</th><th>Notes</th></tr></thead><tbody><tr><td>`Compression`</td><td>`no`</td><td>Compression has historically been a source of side-channel attacks (CRIME-style).</td></tr><tr><td>`TCPKeepAlive`</td><td>`no`</td><td>Use SSH-level keep-alive (ClientAliveInterval) instead. TCPKeepAlive is unauthenticated and spoofable.</td></tr></tbody></table>

---

## Modern Crypto

### Ciphers

```ini
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
```

- AEAD ciphers preferred (chacha20-poly1305, aes-gcm) — encryption and integrity combined.
- aes-ctr modes retained for client compatibility with older OpenSSH releases (paired with hmac-sha2 in MACs).
- CBC modes and legacy 3DES/RC4/Blowfish/arcfour are all excluded.

### MACs

```ini
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
```

- `-etm` (Encrypt-then-MAC) only. Authenticates the ciphertext, preventing oracle attacks on the plaintext.
- SHA-2 family or umac-128. SHA-1 MACs are excluded.

### Key Exchange (with post-quantum hybrid)

```ini
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256
```

- `sntrup761x25519-sha512@openssh.com` — post-quantum hybrid KEX. Combines NTRU Prime (PQ) with X25519 (classical) so the resulting key is secure unless both are broken. Available in OpenSSH 9.0+.
- curve25519 fallbacks for clients without PQ support.
- ECDH (P-521, P-384, P-256) as classical fallbacks.
- SHA-1-based KEX, RSA-based KEX, and DH group 1/14 are all excluded.

---

## Access Control: AllowGroups

```ini
AllowGroups sudo sg_serveraccess sg_sudo
```

sshd's `AllowGroups` is a hard allow-list checked early in the connection. A user must be in **at least one** listed group to even reach the authentication phase. Users not in any listed group get rejected with "User &lt;user&gt; from &lt;ip&gt; not allowed because none of user's groups are listed in AllowGroups".

The three groups:

<table id="bkmrk-grouporiginmembers-s"><thead><tr><th>Group</th><th>Origin</th><th>Members</th></tr></thead><tbody><tr><td>`sudo`</td><td>Local Unix group</td><td>`ansible` (added by role preconditions), `pbr_admin` (added by manual bootstrap)</td></tr><tr><td>`sg_serveraccess`</td><td>AD group (SSSD-mapped)</td><td>AD users with SSH access (no sudo)</td></tr><tr><td>`sg_sudo`</td><td>AD group (SSSD-mapped)</td><td>AD users with sudo</td></tr></tbody></table>

Group names from AD are lowercased by SSSD when mapped to local POSIX groups, so the lowercase form is what sshd matches against.

**Why include local `sudo` rather than special-casing `ansible` and `pbr_admin` via Match blocks:** Match blocks override settings; they don't bypass `AllowGroups`. The user must qualify at the global level first. Listing `sudo` in `AllowGroups` is the simplest way to permit the two local accounts.

**v2.4.1 corollary:** Because `AllowGroups sudo` is what permits the `ansible` account to connect, the role must ensure `ansible` is in the local `sudo` group before the hardening config takes effect. That's done idempotently in `preconditions.yml`.

---

## Access Control: AuthorizedKeysCommand

```ini
AuthorizedKeysFile none
AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u
AuthorizedKeysCommandUser nobody
```

Three lines that change the default sshd key retrieval flow entirely:

- **`AuthorizedKeysFile none`** — disable the default file-based lookup (`~/.ssh/authorized_keys`). Critical: prevents AD users from bypassing AD-managed key revocation by writing their own key files.
- **`AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u`** — for each connection, sshd runs this command with the username, expects valid `authorized_keys`-format output on stdout.
- **`AuthorizedKeysCommandUser nobody`** — run the command as `nobody`. This is OpenSSH and SSSD's documented recommendation: the command should run as a low-privilege user.

The `sss_ssh_authorizedkeys` binary queries the SSSD ssh responder, which queries AD via LDAP for the user's `sshPublicKey` attribute. See **AD Integration &amp; SSSD** for the full flow.

---

## Match Block: pbr\_admin (break-glass)

```ini
Match User pbr_admin Address 10.1.0.0/16,192.168.0.0/16
    PasswordAuthentication yes
    AuthenticationMethods password
```

Match conditions are AND-ed: the user must be `pbr_admin` AND connecting from one of the listed CIDRs. If both match, the block's directives override the global config for this connection only.

The overrides:

- `PasswordAuthentication yes` — re-enable password auth (globally `no`).
- `AuthenticationMethods password` — this user authenticates with password only (globally `publickey,keyboard-interactive`).

The source address list is templated from `pbr_admin_allowed_sources` in defaults. CIDR list, comma-separated, no spaces — per sshd\_config(5) syntax.

Important: this Match block does *not* bypass `AllowGroups`. `pbr_admin` must still be in `sudo` (handled by manual bootstrap, verified by preflight).

---

## Match Block: ansible (automation)

```ini
Match User ansible
    AuthorizedKeysFile .ssh/authorized_keys
    AuthenticationMethods publickey
    KbdInteractiveAuthentication no
```

The `ansible` account is local-only and has no AD-side key. The overrides:

- **`AuthorizedKeysFile .ssh/authorized_keys`** — re-enable file-based key lookup (overrides global `none`). Bootstrap script installs the control node's public key here.
- **`AuthenticationMethods publickey`** — publickey is sufficient (overrides global `publickey,keyboard-interactive`). The ansible account skips PAM entirely on auth.
- **`KbdInteractiveAuthentication no`** — explicitly disable the keyboard-interactive flow for this user. Belt-and-braces with `AuthenticationMethods publickey`.

This is what lets Ansible run non-interactively, without Duo prompts, against every host.

---

## Banner

```ini
Banner /etc/issue.net
```

The banner file is deployed by `roles/ssh-baseline/tasks/sshd.yml` from `roles/ssh-baseline/files/issue.net`. The banner displays before authentication — useful for legal notice and unauthorised-access deterrence.

Note: the banner content is in `files/issue.net` — not templated and not currently in the code dump. To inspect the deployed banner: `cat /etc/issue.net` on any baselined host.

---

## Validation Flow

The role validates SSH config three times during deployment:

1. **During the Include directive write:** `lineinfile` validates via `sshd -t -f <tempfile>`. Catches a broken include line.
2. **During the hardening file write:** `template` validates via `sshd -t -f <tempfile>`. Catches a broken hardening directive before the file lands.
3. **After both files are in place:** `sshd -t` against the live combined config. Catches conflicts between the two files (which the per-file validation can't see).

Only after all three pass does the handler restart sshd.

---

## Notes on Port 22 vs Custom Ports

From the inline comment in `defaults/main.yml`:

> ssh\_port stays at 22. On Ubuntu 22.10+ and 24.04 LTS, OpenSSH uses systemd socket activation by default. If `ssh_port` is changed, `/etc/systemd/system/ssh.socket.d/` overrides must also be managed, or `ssh.socket` disabled in favour of `ssh.service`.

The role does not currently manage `ssh.socket` overrides. Changing `ssh_port` from 22 would require additional task work and is intentionally not supported until needed.

---

## Where to Read Next

- **Configuration Reference** — the full list of SSH-related variables and how to override them
- **Duo MFA Integration** — the keyboard-interactive challenge that this hardening enables
- **AD Integration &amp; SSSD** — how `sss_ssh_authorizedkeys` retrieves AD-stored keys