使用Passkeys和age加密文件
Typage(npm上的age-encryption)是age文件加密格式的TypeScript实现。它可在Node.js、Deno、Bun和浏览器中运行,实现了原生age接收者、密码短语加密、ASCII封装,并支持自定义接收者接口,与Go实现类似。
然而,在浏览器中运行赋予了我们一些特殊能力,比如访问WebAuthn API。自0.2.3版本起,Typage支持使用passkeys和其他WebAuthn凭证进行对称加密,配套的age CLI插件允许在浏览器外部的硬件FIDO2安全密钥上重用凭证。
让我们来看看使用passkeys加密文件的工作原理,以及它在Typage中的实现方式。
使用Passkeys加密
Passkeys是同步的、可发现的WebAuthn凭证。它们是基于标准的防钓鱼认证机制。凭证可以存储在平台认证器(如端到端加密的iCloud钥匙串)、密码管理器(如1Password)或硬件FIDO2令牌(如YubiKeys,尽管这些不同步)中。
WebAuthn凭证的主要功能是对绑定源点的挑战进行加密签名。这对加密来说并不十分有用。然而,具有prf扩展的凭证在产生"断言"(即登录时)时也可以计算伪随机函数。您可以将PRF视为带密钥的哈希(实际上对于安全密钥,它由hmac-secret FIDO2扩展支持):给定输入始终映射到相同输出,没有秘密就无法计算映射,也无法提取秘密。
具体来说,WebAuthn PRF接受一个或两个输入,并为每个输入返回32字节输出。这让"依赖方"可以通过将PRF输出视为仅在凭证可用时才可用的密钥来实现对称加密。使用PRF扩展需要用户验证(即PIN或生物识别)。
注意,没有安全的方法进行非对称加密:我们可以使用PRF扩展来加密私钥,但一旦攻击者观察到该私钥,未来就可以解密任何使用其公钥加密的内容,而无需访问凭证。
对PRF扩展的支持已在Chrome 132、macOS 15、iOS 18和2024年7月起的1Password版本中落地。
fido2prf age格式
为了将age文件加密到新型接收者,我们需要定义随机文件密钥如何被加密并编码到头部节中。以下是一个使用临时FIDO2 PRF输出包装文件密钥的节:
|
|
第一个参数是识别节类型的固定字符串。第二个参数是128位随机数,用作PRF输入。节体是使用从PRF输出派生的包装密钥对文件密钥进行的ChaCha20Poly1305加密。
每个凭证断言(需要单次用户存在检查,例如YubiKey触摸)可以计算两个PRF。这原本用于密钥轮换,但在我们的用例中实际上是一个小的安全问题:攻击者如果入侵了您的系统但没有入侵您的凭证,可能在您有意解密或加密文件时秘密解密一个"额外"文件。我们通过使用两个PRF输出派生包装密钥来缓解此问题。
WebAuthn PRF输入由域分离前缀、计数器和随机数组成:
|
|
两个32字节PRF输出被连接起来,并传递给以age-encryption.org/fido2prf为盐的HKDF-Extract-SHA-256,以派生ChaCha20Poly1305包装密钥。该密钥与零随机数(由于仅使用一次)一起用于加密文件密钥。
此age接收者格式具有两个重要属性:
- 每文件硬件绑定:每个文件都有自己的PRF输入,因此严格需要加密文件和访问凭证两者才能解密文件。您无法预计算某些中间值并在以后使用它来解密任意文件。
- 不可链接性:无法判断两个文件是否加密到同一凭证,或者在不具备解密能力的情况下将文件链接到凭证ID。
WebAuthn和Typage
现在我们有了格式,需要一个实现。于是有了Typage 0.2.3。
|
|
WebAuthn API相当复杂,至少部分原因是它最初是为了在passkeys出现之前暴露U2F安全密钥而开始的,并在多年中有机增长。然而,Typage的passkey支持不到300行代码,包括CTAP2的CBOR子集的简单实现。
在任何加密或解密操作之前,必须通过调用age.webauthn.createCredential
创建新的passkey。
|
|
age.webauthn.createCredential
使用随机user.id
调用navigator.credentials.create
以避免覆盖现有密钥,将authenticatorSelection.residentKey
设置为required
以要求认证器存储passkey,当然还有extensions: { prf: {} }
。如果启用了prf扩展,非createCredential
生成的passkeys也可以使用。
要加密或解密文件,您可以实例化age.webauthn.WebAuthnRecipient
或age.webauthn.WebAuthnIdentity
,它们实现了新的age.Recipient
和age.Identity
接口。
|
|
接收者和身份实现调用带有PRF输入的navigator.credentials.get
以获取包装密钥,然后解析或序列化我们上面描述的age-encryption.org/fido2prf
格式。
除了密钥名称,您可能想设置的唯一选项是依赖方ID。这默认为网页的来源(例如app.example.com
),但也可以是父域(例如example.com
)。凭证对RP ID的子域可用,但对父域不可用。
由于passkeys通常是同步的,这意味着您可以在macOS上加密文件,然后在iPhone上解密,这非常酷。此外,由于混合BLE协议,您可以使用存储在手机上的passkeys与桌面浏览器一起使用。甚至可以使用AirDrop passkey共享机制让其他人解密文件!
安全密钥和age-plugin-fido2prf
您可以将passkeys(可发现或"驻留"凭证)存储在足够新的FIDO2硬件令牌(例如YubiKey 5)上。然而,存储空间有限且支持仍不普遍。另一种方式是让硬件令牌返回所有凭证状态,这些状态加密在凭证ID中,客户端在使用凭证时需要将其返回给令牌。
这对于Web登录有限制,因为在调用WebAuthn API之前需要知道用户是谁(以在数据库中查找凭证ID)。但对于加密来说,这也是可取的:通过这种方式解密文件需要硬件令牌和凭证ID两者,后者可以作为额外的秘密密钥,或者如果您喜欢因素的话,可以作为第二因素。
我决定提供两种配置文件,而不是通过typage API暴露所有分层的WebAuthn细微差别,或排除某种流程:默认情况下,我们将生成并期望可发现的passkeys,但如果传递了security-key选项,我们将请求不将凭证存储在认证器上,并要求浏览器显示硬件令牌的UI。
age.webauthn.createCredential
返回一个age身份字符串,该字符串以CTAP2 CBOR编码凭证ID、依赖方ID和传输方式,格式为AGE-PLUGIN-FIDO2PRF-1....
。此身份字符串在安全密钥流程中是必需的,但在使用passkeys加密或解密时也可以用作可选提示。
更具体地说,编码在age身份字符串中的数据是以下内容的CBOR序列:
- 版本,始终为1
- 凭证ID作为字节字符串
- RP ID作为文本字符串
- 传输方式作为文本字符串数组
例如:
|
|
还有一件事…由于FIDO2硬件令牌在浏览器外部也很容易访问,我们能够构建一个与typage安全密钥身份字符串互操作的age CLI插件:age-plugin-fido2prf。
|
|
由于FIDO2 PRF仅支持对称加密,身份字符串既用于解密也用于加密(使用-e -i
)。
这是一个实践age Go插件框架的机会,该框架轻松将Go age.Identity接口的实现转换为可从age或rage使用的CLI插件,抽象了插件协议的所有细节。将可导入的fido2prf Identity实现转换为插件的脚手架仅50行。