r/linux • u/kellyjonbrazil • 5h ago
Tips and Tricks Auditing every SSH key sshd accepts, including keys outside ~/.ssh
If you audit SSH keys by checking ~/.ssh/authorized_keys, you can miss keys sshd will happily accept. AuthorizedKeysFile can point anywhere: lots of shops use something like /etc/ssh/auth_keys/%u so users can't manage their own keys, and it can list several paths. It can also be overridden per user or group in Match blocks. Even the default includes ~/.ssh/authorized_keys2, which is easy to forget.
If you already run osquery or a compliance platform, you may have some of this covered. This is a lightweight approach for ad hoc audits that uses only jc (a tool I maintain that converts command output and config files to JSON), jq, and sshd's own view of its config.
1. Ask sshd where it looks for keys
sshd -T prints the effective config, with includes and defaults resolved. Adding -C evaluates Match blocks for a specific connection, so you get the answer for a particular user:
$ sudo sshd -T -C user=alice,host=localhost,addr=127.0.0.1 | jc --sshd-conf | jq -r '.authorizedkeysfile[]'
/etc/ssh/auth_keys/%u
.ssh/authorized_keys
2. Expand the paths for every account and parse each file
#!/usr/bin/env bash
getent passwd | jc --passwd | jq -r '.[] | "\(.username) \(.uid) \(.home)"' |
while read -r user uid home; do
# Ask sshd for this user's effective AuthorizedKeysFile (honors Match User/Group)
paths=$(sshd -T -C user="$user",host=localhost,addr=127.0.0.1 |
jc --sshd-conf | jq -r '.authorizedkeysfile[]?')
for p in $paths; do
[[ $p == none ]] && continue
f=${p//%%/$'\001'} # protect literal %%
f=${f//%u/$user}; f=${f//%U/$uid}; f=${f//%h/$home}
f=${f//$'\001'/%}
[[ $f != /* ]] && f="$home/$f" # relative paths are relative to $HOME
[[ -r $f ]] || continue
jc --authorized-keys < "$f" |
jq -c --arg u "$user" --arg f "$f" '.[] | . + {user: $u, file: $f}'
done
done > keys.jsonl
Run it as root so sshd -T works and every file is readable. Using getent means LDAP/SSSD accounts are included, not just local ones in /etc/passwd. The script expands all four tokens AuthorizedKeysFile supports: %u, %U, %h, and %%.
Each line of keys.jsonl is one key, tagged with its owner and the file it came from:
{"options":[],"type":"ssh-ed25519","key":"AAAA...","comment":"kelly@laptop","user":"alice","file":"/home/alice/.ssh/authorized_keys"}
{"options":[],"type":"ssh-rsa","key":"AAAA...","comment":"backup@nas","user":"bob","file":"/etc/ssh/auth_keys/1001"}
3. Ask questions
Keys with no from= or command= restriction:
jq -r 'select(any(.options[]; startswith("from=") or startswith("command=")) | not)
| "\(.user) \(.type) \(.comment // "(no comment)") \(.file)"' keys.jsonl
Users who still have keys in ~/.ssh when policy says they should only be in /etc/ssh/auth_keys:
jq -r 'select(.file | test("/\\.ssh/")) | "\(.user) \(.file)"' keys.jsonl | sort -u
The same key authorized for more than one account:
jq -s -r 'group_by(.key)[] | select(length > 1)
| "\(.[0].comment // "(no comment)"): \(map(.user) | unique | join(", "))"' keys.jsonl
Legacy RSA/DSA keys:
jq -r 'select(.type == "ssh-rsa" or .type == "ssh-dss") | "\(.user) \(.comment) \(.file)"' keys.jsonl
Why not awk? authorized_keys lines can start with an options field containing quoted values with spaces (command="/usr/bin/rsync --server ..."), so field positions shift from line to line. awk '{print $1}' returns an option string instead of a key type, and grep -v command= also drops keys whose comment happens to mention it. The parser splits options into an array and keeps quoted values intact.
Limitations:
- The audit evaluates
Matchblocks as a local connection (host=localhost,addr=127.0.0.1). If you haveMatch AddressorMatch Hostrules that changeAuthorizedKeysFilefor remote sources, run it again with those values. AuthorizedKeysCommandsetups, where keys come from LDAP or an API instead of files, aren't covered. That command's output is in authorized_keys format, though, so you should be able to pipe it tojc --authorized-keysthe same way.- None of this tells you whether a user's private key has a passphrase, and nothing on the server can. A commenter on r/bash made this point. If it matters to you, SSH certificates with short validity windows are the better fix. The same
sshd -Toutput showstrustedusercakeysandauthorizedprincipalsfile, so you can audit that setup too.
More jc examples (calendars, headerless CSVs, etc.): JC Tips and Tricks – Part 3