SSH 代理为公钥认证提供签名操作。对于普通软件密钥,加载加密私钥时会解锁相关材料,此后代理便可以使用它,不必每次连接都重新询问文件口令。正常代理协议向客户端返回公钥身份和签名,而不是导出私钥。硬件支持的身份有所不同:私钥可以一直保留在认证器里,并要求触摸或用户验证,不能把所有代理身份都描述成完整私钥必然存放在代理内存中。

套接字提供的是服务访问能力

OpenSSH 通常通过 SSH_AUTH_SOCK 查找代理,但客户端配置中的 IdentityAgent 可以选择另一个套接字,也可以关闭代理使用。知道路径并不等于有连接权限。套接字和父目录的权限都重要;root 以及与用户同身份运行的其他进程,则是需要考虑的威胁。环境变量本身不能代替操作系统隔离,隐藏路径也不是可靠的保护边界。

能够访问代理,并不保证每个签名请求都会成功。密钥约束、确认要求、硬件在场和协议限制都可能拒绝请求。即使签名成功,远端服务器也不必接受这把密钥或授予权限。因此,本地签名能力与服务端授权决定应分开观察。诊断“代理里有钥匙但登录失败”时,不能只停留在身份列表这一层。

同时限制身份与委托

ssh-add 加载身份,并在需要时询问文件口令。但 ssh 的 AddKeysToAgent 等配置路径也能加载身份,不能说只有 ssh-add 才可能解锁。代理避免反复读取加密私钥文件,却不保证交换空间、转储、备份或其他进程中绝不存在私钥材料。操作系统与密钥存储策略仍然需要独立审阅,代理不是整台机器的保密证明。

-t 为添加的身份设置存活期限。到期后身份会被移除,这个参数不只是提醒;没有期限时,普通已加载身份可能一直留到显式删除或代理退出。移走、删除或者替换磁盘文件,并不会自动替换代理里已经加载的副本。轮换时应检查实际使用的指纹,而不是只确认文件名已经改变。

-c 通过 ssh-askpass 要求确认。决定是否批准的是辅助程序是否成功退出,而不是远端主机有没有终端。需要验证本地确认机制确实能工作,并拒绝意外请求。确认可以减少无人值守的使用,但仍需理解提示所代表的主机和操作,不能看到弹窗就习惯性通过,也不能把没有远端终端当作必然拒绝的证据。

OpenSSH 8.9 引入的目的地约束使用主机密钥进一步限制允许的认证路径。经过转发时,路径上的客户端需要支持相关协议;已知主机信息也必须正确识别目标。这不是靠域名文字就能独立工作的防火墙。依赖约束前应验证版本和兼容性,保持 OpenSSH 更新,并理解主机密钥变化如何影响允许的路径。

坏掉的样子

代理转发允许远端在转发通道存在期间中继签名请求。那里的管理员或被入侵进程可能使用这份能力,而不需要提取私钥。没有必要时应避免转发,并查看客户端最终生效的配置;命令行没有 -A,不足以证明配置文件没有开启它。风险在于签名委托,不在于私钥文件一定已经复制到远端。

从代理删除身份,并不会撤销已经被复制的私钥、删除服务器 authorized_keys 中的条目,也不保证关闭已建立的 SSH 会话。反过来,服务端撤销授权可能阻止新登录,即使代理仍保存这把钥匙。恢复计划应分别处理受影响的副本、授权记录和活动会话,而不是把其中一个动作误当成全部撤销。

现代 OpenSSH 代理还限制远端请求加载提供方,以及部分 FIDO 签名用途,以减少把转发认证器用于 SSH 之外的特定风险。这些保护不是公开套接字或随意放宽选项的理由。不同版本和平台集成的默认值、套接字位置可能不同;排查时应阅读当前实现的文档,不能把某次输出的路径和权限数字当成永远成立的协议常量。

快速验证

保存为 agent_config.py,在安装了 OpenSSH 的环境执行 python3 agent_config.py。实验创建临时配置,再通过 ssh -G 求出最终配置,不启动代理、不读取私钥、不建立连接,也不修改现有代理。合成环境变量里的套接字与显式配置刻意不同,而且两条路径都不需要存在,因此不会碰到真实认证服务。

import os
from pathlib import Path
import subprocess
import tempfile

with tempfile.TemporaryDirectory(prefix='agent-config-') as directory:
    work = Path(directory)
    alternate = str(work / 'alternate.sock')
    config = work / 'config'
    config.write_text(f'''Host chosen-agent
  HostName example.invalid
  IdentityAgent {alternate}
Host no-agent
  HostName example.invalid
  IdentityAgent none
''')
    env = {'PATH': os.defpath, 'SSH_AUTH_SOCK': str(work / 'environment.sock')}
    for host, expected in [('chosen-agent', alternate), ('no-agent', 'none')]:
        result = subprocess.run(['ssh', '-G', '-F', str(config), host],
                                env=env, cwd=work, text=True, capture_output=True, check=True)
        fields = dict(line.split(' ', 1) for line in result.stdout.splitlines())
        if fields.get('identityagent') != expected:
            raise RuntimeError('unexpected IdentityAgent')
        print(host, 'identityagent=', fields['identityagent'])
    if (work / 'alternate.sock').exists() or (work / 'environment.sock').exists():
        raise RuntimeError('the fixture must not create an agent socket')
    print('PASS: explicit socket overrides environment; none disables agent use')

第一条结果选择显式配置的替代套接字,即使 SSH_AUTH_SOCK 指向别处;第二条选择 none。PASS 只确认这些配置规则。ssh -G 不验证套接字可达性、签名、到期、确认或远端登录。IdentityAgent none 关闭的是该次调用的代理使用,并不关闭文件密钥或其他认证机制,不能据此声称这个连接绝不可能认证成功。

若需要验证加载、存活期和确认,应另开得到授权的测试会话,并使用可丢弃的身份。不要为了重现文章,就对日常共用代理执行 ssh-add -D:它会删除全部已加载身份,可能中断其他工作。应把观察配置的实验,与改变凭据或真实会话的操作清楚分开,依据具体测试目标逐项取得证据。

来源