密钥生成并不需要临时文件,而跳过临时文件所能修好的问题,比大多数指南暗示的要少。它去掉的是一个文件生命周期以及围绕它的清理竞态。它没有触及的,是此后泄漏机密的所有机会。这些脚本里的正确性问题通常出在别处,因为诊断信息与密钥材料走的是不同的流。

文件从来就不是你以为的那种暴露面

当临时文件对所有人可读、在崩溃后仍然存在,或者在异常之后跳过了清理步骤时,它才是一个具体的风险。这些风险是真实的,也值得修复。同时它们是局部的、可见的、容易测试的。在断定这次改动只是表面功夫之前,请先在自己的脚本里把每一条都点出来。

更安静的风险在下游:调试时被打印出来的密钥、把整个结果对象序列化的异常、写出核心转储的崩溃处理器、先收到文件路径随后又收到内容的日志聚合器。避开临时文件对其中任何一条都无济于事,而以为它有济于事,正是一次善意的重写让密钥比之前更暴露的原因。把这次改动看作一整串改进中的一项,而不是那个修复。

没有给出文件名时客户端会写出什么

在不带输出路径的情况下调用密钥命令时,当前版本的客户端会把生成的密钥发送到标准输出。正是这一有文档记载的行为让内存中的做法可行,也省掉了把工具指向设备节点这一更老的花招。

import subprocess
proc = subprocess.run(
    ["openvpn", "--genkey", "tls-crypt"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    check=True,
)
key_material = proc.stdout

把命令作为列表传入。这里不需要 shell 展开,而启用 shell 会让参数边界取决于你无法控制的引号处理。如果脚本可能在你预料之外的搜索路径下运行,就用绝对路径指定想要的程序。

返回码检查可以阻止成功路径在一次失败的调用之后继续走下去。没有它,空的字节串会继续往下流,只在很久之后、在密钥被使用并且错误信息已经完全不再提到生成的地方才失败。

两条流,两种含义

标准输出承载密钥。标准错误承载工具想告诉你的一切,包括关于配置、版本和密钥类型的警告。把两者重定向到同一个缓冲区,就是那个会产出一把看起来很像样的密钥、中间却缝进一句话的错误。

失败路径也要单独保留。当调用失败时,报告失败的命令和退出状态,然后再刻意决定标准错误里有没有哪部分是安全的、可以展示。把整个结果对象倒进日志是常见的捷径,而它可能把密钥一起带进去。

如果你之后要对材料做哈希或比较,二进制安全的读取就很要紧。带有意外编码的文本模式可能改动本不该被当作文本的字节,而一把被改动的密钥在握手失败之前与一把错误的密钥无法区分。

如果你确实要把它持久化,那就刻意去做

大多数部署最终都需要密钥落在磁盘上、网关上或机密存储里。只写一次,从创建时就带上严格权限而不是事后再 chmod,并让写入是原子的,这样一个残缺的文件永远不会成为服务启动时读取的那一个。从同一目录内的路径做一次重命名通常就够了。在失败分支上删掉暂存路径,因为失败的那次写入恰恰是人们会忘记的那一份拷贝。

在文件被创建时设置文件模式,并让目录模式也保持严格。避开任何位于备份任务会不加思索地扫过的位置之下的路径,因为夜间备份里的机密,就是那份备份每一个副本里的机密。同样的道理也适用于其他服务可以读取的共享卷。

共享密钥不是身份

生成这份材料既不会签发证书,也不会登记客户端,更不会确定是谁在连接。共享密钥保护的是已经互相信任的各方之间的一条通道;身份仍然来自证书,以及挡在它前面的任何认证。

密钥类型同样不能互换。用于某个特性的对称密钥并不能直接顶替另一个特性的密钥,而更新的版本又加入了各自的变体,两端所用的文件各不相同。生成你的配置实际点名的那个类型,然后按那个类型所定义的角色去使用它。

用一次真实连接来验证结果,而不是检查文件大小。如果握手能够完成、日志里也没有密钥相关的警告,那么生成路径就是对的;如果密钥被合并的流截断了,连接会以某种把矛头指向对端而不是脚本的方式失败。

相关文章