SSH 密钥卫生首先要回答:哪份凭据授予什么访问,谁负责,以及如何结束这份授权。密钥注释方便维护清单,却不是经过认证的所有者记录,更不是授权规则。应把指纹与负责人、目标账号、允许操作和移除计划对应起来。生成现代密钥只是开始,不能用一条生成命令代替完整的访问生命周期管理。
限定服务端权限
authorized_keys 中的公钥通常用于授权访问某个账号,同时还受服务器其他控制约束。自动化任务应结合权限有限的账号和经过选择的选项。强制命令能限制执行的操作,from 可以限制来源地址,但二者都不会自动把进程关在某个工作目录,也不会让一个会拼接不可信命令的包装脚本变得安全。
restrict 禁用转发、伪终端分配和 ~/.ssh/rc,但显式搭配选项可以重新启用个别功能;它本身并不禁止 shell 或命令执行。因此,无人值守任务通常还需要谨慎实现的强制命令。no-agent-forwarding 只禁止代理转发,不能证明密钥没有普通 shell 权限。principals 对可信证书颁发机构条目有专门含义,并不是加在任意普通公钥前面就能形成通用身份约束。
还要审阅最终生效的服务端配置和其他授权来源。AuthorizedKeysCommand、证书颁发机构、账号权限和 Match 规则,都会让结果超出单个文件的内容。清单工具看到选项名时应报告观察事实,不能直接给出“已限制,所以安全”的结论。来源限制也取决于服务器实际看到的地址,包括代理、跳板机或地址转换后的来源,不能只按客户端想象的拓扑解释。
区分密钥类型与签名算法
RSA 公钥行仍使用 ssh-rsa 类型标签,即使认证时采用 rsa-sha2-256 或 rsa-sha2-512 签名。OpenSSH 8.8 默认禁用的是使用 SHA-1 的 ssh-rsa 签名方案,而不是所有 RSA 密钥。应检查密钥长度与实际支持的签名算法,不能只凭一行的第一个词就把所有 RSA 条目判成过时。DSA、弱参数和不受支持的配置,需要各自的迁移计划。
新凭据应选择当前环境支持的算法和合适的保护。口令可以保护加密私钥文件,避免文件被复制后立刻就能使用;硬件密钥则提供不同的边界。它们都不能代替安全工作站、受控代理访问和服务端权限限制。无法交互输入口令的自动化,需要明确的凭据保管和更新设计,不能简单地把“没有提示”当成设计完成。
坏掉的样子
只追加新公钥的轮换,会让旧凭据继续得到授权。应从所有相关来源移除退役条目,验证替代凭据,并单独处理现有会话。普通裸公钥不会自动携带到期机制;需要通过支持的服务端选项、SSH 证书或明确的生命周期操作执行过期。在注释里写一个日期不会产生任何强制效果,负责人离职也不会自动让那一行失效。
权限数字需要准确解释。私钥应限制为所有者访问,常见模式是 0600;私有目录常见模式是 0700。authorized_keys 的公钥内容并不是秘密,在所有权和写保护满足检查时,0644 也可能被服务器接受。采用更严格的 0600 策略合理,却不能声称每个其他模式都证明已经泄露。ACL、父目录和平台规则仍需分别核对,简单的模式检查并不涵盖全部访问路径。
按空白直接切分 authorized_keys,会破坏含有带引号空格的命令。转义引号和逗号又会进一步增加自制解析器误读的机会。可使用 OpenSSH 检查指纹,再独立审阅选项语义。指纹一致只能说明找到的是哪把公钥,不能证明限制真的生效,也不能证明对应私钥设置了口令。自动审计输出应避免给出超出这些证据的保证。
快速验证
保存为 ssh_hygiene.py,在安装了 OpenSSH 的环境执行 python3 ssh_hygiene.py。程序在临时目录生成可丢弃的 Ed25519 和三千零七十二位 RSA 密钥。空口令仅用于这个隔离实验;脚本不会读取或更改真实 SSH 文件,不连接任何主机,也不加载代理。正常退出会尝试清理生成文件;实验不会向任何服务器添加授权。
from pathlib import Path
import os
import stat
import subprocess
import tempfile
def run(*args):
return subprocess.run(args, text=True, capture_output=True, check=True,
env={'PATH': os.defpath}).stdout.strip()
with tempfile.TemporaryDirectory(prefix='ssh-hygiene-') as directory:
work = Path(directory)
# Throwaway, passphrase-less keys for this isolated fixture only.
ed = work / 'demo_ed25519'
rsa = work / 'demo_rsa'
run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'fixture-ed', '-f', str(ed))
run('ssh-keygen', '-q', '-t', 'rsa', '-b', '3072', '-N', '', '-C', 'fixture-rsa', '-f', str(rsa))
public = ed.with_suffix('.pub').read_text().strip()
auth = work / 'authorized_keys'
auth.write_text('restrict,command="printf \\"two words\\"" ' + public + '\n')
auth.chmod(0o600)
parsed = run('ssh-keygen', '-l', '-f', str(auth))
original = run('ssh-keygen', '-l', '-f', str(ed.with_suffix('.pub')))
if parsed.split()[1] != original.split()[1]:
raise RuntimeError('quoted options changed the key fingerprint')
rsa_public = rsa.with_suffix('.pub').read_text().split()[0]
if rsa_public != 'ssh-rsa':
raise RuntimeError('unexpected RSA public key type')
signatures = set(run('ssh', '-Q', 'key-sig').splitlines())
if not {'rsa-sha2-256', 'rsa-sha2-512'} <= signatures:
raise RuntimeError('client does not advertise RSA SHA-2 signatures')
modes = {p.name: oct(stat.S_IMODE(p.stat().st_mode)) for p in (ed, rsa, auth)}
if any(stat.S_IMODE(p.stat().st_mode) & 0o077 for p in (ed, rsa)):
raise RuntimeError('fixture private key permissions are too broad')
print('quoted authorized_keys fingerprint:', parsed)
print('RSA key label:', rsa_public, '; SHA-2 signature algorithms supported')
print('fixture modes:', modes)
print('PASS: native key parsing, fingerprint agreement, RSA distinction, private modes')
预期看到带引号命令后仍匹配的 Ed25519 指纹、ssh-rsa 公钥标签以及本机支持 RSA SHA-2 签名的结果、所有者专用的私钥权限,最后打印 PASS。指纹和临时路径每次都会变化。上下文管理器会在退出时尝试删除生成文件,但不提供安全擦除保证。输出不包含私钥内容,本轮源码复核也没有生成密钥或重跑夹具。这个过程证明特定输入可以被原生工具识别,而不是证明任意配置都会被服务器接受。
客户端算法查询只说明本地能力,不说明某台服务器实际允许什么。同样,ssh-keygen 读取指纹也不是强制命令或全部选项的端到端测试。真正审计时,应把每个指纹映射到负责人,在隔离环境验证服务端授权和命令行为,并清楚区分已观察事实与尚未验证的控制。需要修改授权文件时,还应保留受控恢复方式,避免把清理操作变成意外锁定。
延伸阅读
来源
- OpenBSD: sshd,authorized_keys 格式、选项与文件所有权检查,已核验 2026-10-15。
- OpenSSH 8.8 发布说明,RSA 密钥与弃用的 SHA-1 签名的区别,已核验 2026-10-15。
- OpenBSD: ssh-keygen,密钥生成与指纹检查,已核验 2026-10-15。
- NIST IR 7966,SSH 访问清单与生命周期管理,已核验 2026-10-15。