使用Passkeys和age加密文件:新一代文件加密技术解析

本文深入解析了如何使用Passkeys和age工具进行文件加密,详细介绍了Typage库的WebAuthn集成、FIDO2 PRF扩展的应用,以及跨平台加密解决方案的实现原理和技术架构。

使用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输出包装文件密钥的节:

1
2
-> age-encryption.org/fido2prf Fv8VHh8kzhSlR14OviQ2OA
0Gw/JQEYrx5wPEUQzAh14nB6vTujga6VaboJ/vMKgWw

第一个参数是识别节类型的固定字符串。第二个参数是128位随机数,用作PRF输入。节体是使用从PRF输出派生的包装密钥对文件密钥进行的ChaCha20Poly1305加密。

每个凭证断言(需要单次用户存在检查,例如YubiKey触摸)可以计算两个PRF。这原本用于密钥轮换,但在我们的用例中实际上是一个小的安全问题:攻击者如果入侵了您的系统但没有入侵您的凭证,可能在您有意解密或加密文件时秘密解密一个"额外"文件。我们通过使用两个PRF输出派生包装密钥来缓解此问题。

WebAuthn PRF输入由域分离前缀、计数器和随机数组成:

1
2
"age-encryption.org/fido2prf" || 0x01 || nonce
"age-encryption.org/fido2prf" || 0x02 || nonce

两个32字节PRF输出被连接起来,并传递给以age-encryption.org/fido2prf为盐的HKDF-Extract-SHA-256,以派生ChaCha20Poly1305包装密钥。该密钥与零随机数(由于仅使用一次)一起用于加密文件密钥。

此age接收者格式具有两个重要属性:

  • 每文件硬件绑定:每个文件都有自己的PRF输入,因此严格需要加密文件和访问凭证两者才能解密文件。您无法预计算某些中间值并在以后使用它来解密任意文件。
  • 不可链接性:无法判断两个文件是否加密到同一凭证,或者在不具备解密能力的情况下将文件链接到凭证ID。

WebAuthn和Typage

现在我们有了格式,需要一个实现。于是有了Typage 0.2.3。

1
npm install -s age-encryption@0.2.3

WebAuthn API相当复杂,至少部分原因是它最初是为了在passkeys出现之前暴露U2F安全密钥而开始的,并在多年中有机增长。然而,Typage的passkey支持不到300行代码,包括CTAP2的CBOR子集的简单实现。

在任何加密或解密操作之前,必须通过调用age.webauthn.createCredential创建新的passkey。

1
await age.webauthn.createCredential({ keyName: "age encryption key 🦈" })

age.webauthn.createCredential使用随机user.id调用navigator.credentials.create以避免覆盖现有密钥,将authenticatorSelection.residentKey设置为required以要求认证器存储passkey,当然还有extensions: { prf: {} }。如果启用了prf扩展,非createCredential生成的passkeys也可以使用。

要加密或解密文件,您可以实例化age.webauthn.WebAuthnRecipientage.webauthn.WebAuthnIdentity,它们实现了新的age.Recipientage.Identity接口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const e = new age.Encrypter()
e.addRecipient(new age.webauthn.WebAuthnRecipient())
const ciphertext = await e.encrypt("Hello, age!")
const armored = age.armor.encode(ciphertext)
console.log(armored)

const d = new age.Decrypter()
d.addIdentity(new age.webauthn.WebAuthnIdentity())
const decoded = age.armor.decode(armored)
const out = await d.decrypt(decoded, "text")
console.log(out)

接收者和身份实现调用带有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作为文本字符串
  • 传输方式作为文本字符串数组

例如:

1
2
3
4
1
h'C4A1C97CA40D358EAF4E5CDC51E5AE5F5472C3E6B8942652A9955C34CB5403CDE04B933430280F919220DA22467BBB2BC8D7EF4AE62BCDEBA77CC698A5703ED2'
"localhost"
["usb"]

还有一件事…由于FIDO2硬件令牌在浏览器外部也很容易访问,我们能够构建一个与typage安全密钥身份字符串互操作的age CLI插件:age-plugin-fido2prf。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cat > identity.txt << EOF
AGE-PLUGIN-FIDO2PRF-1Q9VYP39PE972GRF436H5UHXU28J6UH65WTP7DWY5YEF2N92UXN94GQ7DUP9EXDPS9Q8ERY3QMG3YV7AM90YD0M62UC4UM6A80NRF3FTS8MFXJMR0VDSKC6R0WD6GZCM4WD3QKE7G3W
EOF
$ age -d -i identity.txt << EOF
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGFnZS1lbmNyeXB0aW9uLm9yZy9maWRv
MnByZiA2dWVNRDBYNjRsVnorNnFVL1Rqb1hBCjYzSFVVRmFtUWJ6VXZLVy9nV2Zr
QlNkMVVrM0VuOGZmN1dQU2UyOWY5Q0EKLS0tIGRnUTV2T1Z4WE9zcGw4OEs3M0Rz
UHR3ektYeTVNUzIxZXBSQ1J4b2RuUzAKqDgfm0QMjJpOw+tzGClM9dPsjrWUCTaX
NoEA2tHtTerYo3683A==
-----END AGE ENCRYPTED FILE-----
EOF
Enter the security key PIN:
test

由于FIDO2 PRF仅支持对称加密,身份字符串既用于解密也用于加密(使用-e -i)。

这是一个实践age Go插件框架的机会,该框架轻松将Go age.Identity接口的实现转换为可从age或rage使用的CLI插件,抽象了插件协议的所有细节。将可导入的fido2prf Identity实现转换为插件的脚手架仅50行。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计