SSH 密钥很方便,而方便正是在不知不觉中失控的原因。实践上的关键纪律并不是少生成几把密钥,而是让每把密钥都成为有清晰用途、有限生命周期、并且能在"谁被允许做什么"的文件里留下记录的一个具名身份。
一把密钥应被当作一个身份
一把 SSH 密钥应当能读作一个身份,而不是一句无密码的便利。给它起名:为了它属于谁、为什么存在,而不是为了它恰好躺在那台笔记本;给它一个简短的注释,并在你的工具支持时给私钥配一个口令,让一份只拿到了文件的人无法直接使用。一把「一看就知道属于谁、可以做什么」的密钥,能让后续的运维者不假思索就应用最小权限原则——这正是「上了锁的凭据」和「一堆谁也认不出的无名钥匙」之间的分水岭。
范围才是关键
范围才是密钥的要害。尽量让一把 key 只能碰到它该碰的东西:在服务端,通过账户与 authorized_keys 的设置,只接收该接收的 key,并限制每把 key 具体能做哪些——别一股脑把整台机器的大权交给一个起自自动化的角落。遇到自动化用途,更要按命令、源或目录把它钉死在一个边界内;一把范围被钉死的 key,在泄漏时,泄漏时,损失的也只是「这个被定义好的边界」,而不是一把「每一扇门都能开」的总钥匙。
轮换与审计线索
旧钥匙不会自己走。一套轮换的节奏,才是让清单长久诚实的办法:删掉已经没人再用的 key,把剩余的交实际负责人重新签发,并过一段时间再确认一次——「现在被接受的这些 key,是不是真的对应我要的那些人、那些用途」。authorized_keys 本身,就是把这份「谁允许做什么」变成可复核的文件的实体。这样一个定期、便宜又明确的检查,抵得上一百次在事故后茫然翻找。
坏掉的样子
没有口令的私钥,会让每一个副本都变成一次登录。入职克隆笔记本、把个人配置推到共享仓库、备份软件同步家目录,都会顺带复制 ~/.ssh。只要有人能读到其中一份,就能立刻登录,而服务端日志分辨不出小偷和主人。全程没有任何口令提示,故障是静默的,往往要等日志里出现陌生地址才会被发现。
长期残留的 authorized_keys 条目,是另一种同样安静的失效。工程师离职、合作到期、定时任务下线,公钥却还留在服务器上,因为没人负责清理。几个月后,那一行被遗忘的记录依然有效,而且大多没有 from=、command= 这类限制,等于没有使用边界。只发新钥而不删旧钥的轮换,每转一圈都在扩大攻击面。
第三种失效是算法老旧。ssh-dss 和短长度的 ssh-rsa 在宽松配置的服务端上依然能登录,尽管主流建议早已转向 Ed25519。连接一旦成功就不会告警,而攻击者截获的每次握手,都能在弱算法上拿到大得多的杠杆。正确做法是生成新类型密钥并删掉旧行,而不是让两种长期并存。
第四种是代理转发劫持。用 ssh -A 登录时,本地代理套接字会暴露到远端主机,有足够权限的人可在会话期间让你的代理替他签名。私钥文件纹丝未动,登录却以你的名义扩散到更多主机。症状是:横向移动发生了,现场却找不到被盗密钥,只剩转发打开期间产生的一串签名。
快速验证
把下面的脚本保存为 ssh_audit.py,运行 python3 ssh_audit.py。它只用 Python 标准库,只读当前用户 ~/.ssh,不做任何修改;没有密钥文件的机器报告 SKIP 而非报错,新机器也能干净跑完。
"""审计本地 SSH 密钥卫生:权限、authorized_keys 选项、算法。
只读。仅使用 Python 标准库。
缺失的文件报告为 SKIP,而不是失败。
"""
from __future__ import annotations
import stat
from pathlib import Path
# 被视为老旧的密钥类型。
WEAK_KEY_TYPES = {"ssh-rsa", "ssh-dss"}
KNOWN_KEY_TYPES = {
"ssh-rsa",
"ssh-dss",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-ed25519",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
}
# 能收窄密钥用途的 authorized_keys 选项前缀。
RESTRICTING_OPTIONS = (
"command=",
"from=",
"restrict",
"principals=",
"no-agent-forwarding",
)
# ~/.ssh 里永远不是私钥的文件。
SKIP_NAMES = {
"known_hosts",
"known_hosts.old",
"config",
"authorized_keys",
"environment",
}
def mode_of(path: Path) -> str:
return oct(stat.S_IMODE(path.stat().st_mode))
def split_options(field: str) -> list[str]:
"""按逗号切分选项字段,但引号内的逗号不切。"""
options, current, quoted = [], [], False
for char in field:
if char == '"':
quoted = not quoted
current.append(char)
elif char == "," and not quoted:
options.append("".join(current))
current = []
else:
current.append(char)
if current:
options.append("".join(current))
return options
def parse_authorized_key(line: str, lineno: int) -> dict:
parts = line.split()
if parts[0] in KNOWN_KEY_TYPES:
keytype, options = parts[0], []
else:
options = split_options(parts[0])
keytype = parts[1] if len(parts) > 1 else "<unparsable>"
restricted = any(
opt.strip().lower().startswith(RESTRICTING_OPTIONS) for opt in options
)
return {"lineno": lineno, "type": keytype, "restricted": restricted}
def main() -> int:
ssh_dir = Path.home() / ".ssh"
warnings = 0
checked_keys = 0
if not ssh_dir.is_dir():
print(f"SSH 目录 {ssh_dir}:SKIP(没有 .ssh 目录)")
print("RESULT: SKIP - nothing to audit")
return 0
dir_mode = mode_of(ssh_dir)
dir_flag = "OK" if dir_mode == "0o700" else "WARN (expected 0o700)"
if dir_flag.startswith("WARN"):
warnings += 1
print(f"SSH 目录 {ssh_dir}:mode {dir_mode} {dir_flag}")
for child in sorted(ssh_dir.iterdir()):
if not child.is_file() or child.suffix == ".pub":
continue
if child.name in SKIP_NAMES or child.name.startswith("."):
continue
head = child.read_bytes()[:64]
looks_like_key = (
b"PRIVATE KEY" in head
or child.name.startswith("id_")
or child.name.endswith((".pem", ".key"))
)
if not looks_like_key:
continue
file_mode = mode_of(child)
exposed = bool(stat.S_IMODE(child.stat().st_mode) & 0o077)
flag = "OK" if not exposed else f"WARN (mode {file_mode} is group/other-readable)"
if exposed:
warnings += 1
print(f"私钥 {child.name}:mode {file_mode} {flag}")
auth_keys = ssh_dir / "authorized_keys"
if not auth_keys.is_file():
print(f"{auth_keys}:SKIP(没有 authorized_keys 文件)")
else:
ak_mode = mode_of(auth_keys)
ak_flag = "OK" if ak_mode in ("0o600", "0o400") else f"WARN (mode {ak_mode}, expected 0o600)"
if ak_flag.startswith("WARN"):
warnings += 1
print(f"authorized_keys:mode {ak_mode} {ak_flag}")
for lineno, raw in enumerate(
auth_keys.read_text(encoding="utf-8").splitlines(), 1
):
line = raw.strip()
if not line or line.startswith("#"):
continue
checked_keys += 1
entry = parse_authorized_key(line, lineno)
notes = []
if entry["type"] in WEAK_KEY_TYPES:
notes.append("legacy algorithm")
if entry["type"] == "<unparsable>":
notes.append("could not parse key type")
if not entry["restricted"]:
notes.append("no restriction (no command=/from=/restrict)")
if notes:
warnings += 1
print(
f"authorized_keys 第 {lineno} 行:"
f"{entry['type']} WARN ({'; '.join(notes)})"
)
else:
print(
f"authorized_keys 第 {lineno} 行:"
f"{entry['type']} OK (restricted)"
)
print("passphrase: NOT CHECKED here (use `ssh-keygen -y -f <key>` separately)")
if checked_keys == 0:
print(f"RESULT: no authorized_keys entries checked; warnings={warnings}")
else:
print(f"RESULT: {checked_keys} authorized_keys entries checked; warnings={warnings}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
这个脚本检查三件事。第一,目录与文件权限:~/.ssh 应为 0o700,私钥应为 0o600 或更严,authorized_keys 应为 0o600 或 0o400。第二,逐行解析 authorized_keys,带引号的 command="..." 也能正确切分,并标记缺少 command=、from=、restrict 等收窄选项的条目。第三,标记 ssh-rsa 与 ssh-dss 这两种老旧密钥类型,同时放行 Ed25519、ECDSA 以及硬件密钥对应的 sk- 类型。口令无法从文件元数据读出,所以脚本在这一项如实报告 NOT CHECKED,并指向 ssh-keygen -y。
假设 id_rsa 全局可读、~/.ssh 为 0o755、第一条是无限制的 ssh-rsa,预期输出如下:
SSH 目录 /home/alex/.ssh:mode 0o755 WARN (expected 0o700)
私钥 id_rsa:mode 0o644 WARN (mode 0o644 is group/other-readable)
authorized_keys:mode 0o600 OK
authorized_keys 第 1 行:ssh-rsa WARN (legacy algorithm; no restriction (no command=/from=/restrict))
authorized_keys 第 2 行:ssh-ed25519 OK (restricted)
passphrase: NOT CHECKED here (use `ssh-keygen -y -f <key>` separately)
RESULT: 2 authorized_keys entries checked; warnings=3
每一行 WARN 都是一张清理工单:删掉过期条目、补上使用限制、换掉老旧算法,或者收紧文件权限。脚本只审计本地当前账户,服务端的 sshd_config、主机密钥与证书颁发机构需另行检查。
延伸阅读
来源
- OpenBSD sshd 手册,authorized_keys 格式、密钥选项与认证控制,已核验 2026-09-12。
- NIST IR 7966:使用 SSH 的交互与自动化访问管理安全,密钥发放、轮换与审计指南,已核验 2026-09-12。
- GitHub 文档:使用 SSH 连接 GitHub,生成密钥、口令与密钥类型,已核验 2026-09-12。