
既然我们已经能在浏览器中访问 Windows Hello、Face ID 和硬件安全密钥了,为什么不使用它们的基础加密能力呢?
随着 iOS 18 变得广泛可用(是的,这篇文章开始写的时候 iOS 18 还刚刚发布...)及新版本 Chrome 的推出,通行密钥 (Passkey/WebAuthn) PRF 扩展终于开始进入主流浏览器。现在,通过 PRF 扩展,网页将可以通过 WebAuthn API 直接访问通行密钥的伪随机函数 (PRF) ,由此派生与该通行密钥关联的加解密密钥。此外,用户还将能利用通行密钥的跨平台同步特性,在不同设备间无缝同步密钥。
说人话就是,这意味着:
- 由于通行密钥的底层通常是硬件加密方案(Yubikey、TPM、CPU 安全隔区等),浏览器将可以利用这些硬件带来的额外安全性进行更安全的密码学操作
- 通行密钥可以跨设备地同步和使用,我们将可以利用平台的基础设施完成密钥安全跨设备同步,无需自建相关服务
这将会给许多加密相关的 Web 服务更多可能性。而对于用户来说,通过一次通行密钥验证即可完成所有加解密操作,加解密过程会变得更加流畅与无感。何况,许多网站本身已经支持使用通行密钥进行身份验证,网站可以在调用通行密钥验证的同时使用 PRF 扩展完成加解密操作,无需用户再额外操作。
当然,依赖不同平台的基础设施进行密钥同步也意味着交出一部分对密钥的控制权并引入新的不可控依赖项。不过这不是必须的。
如果你想试试通行密钥 PRF 扩展,我开发了一个演示用的纯前端密码管理器 PRF Password Manager。在这个示例中,密码是加密存储的,但用户可以利用通行密钥在登录时同时完成密码的解密,并通过再次验证通行密钥以在保存密码时加密密码。所有这些加解密都在浏览器中发生,没有未加密的数据会离开浏览器。而由于通行密钥的跨平台特性,用户还可以在不同设备间无缝同步通行密钥,只需在一个设备上注册通行密钥即可在其他设备上使用。这个示例没有专用后端,由于密码等数据已经被加密,我们可以在任意位置存储数据。在这个演示中,为了能在不同设备上同步数据,我们使用了 Todoist 的 API 来存储加密后的密码。这个演示还额外使用了通行密钥 Large Blob 扩展,这在一些旧验证器上(如 Yubikey 5.7 以下)可能不受支持。
如果你还不甚了解 WebAuthn,你可以参阅:
本文意义不明的文章封面是根据我的一个通行密钥的 PRF 在全零输入上的输出创作生成的,没有 AI 参与。
什么是 PRF
PRF,即 Pseudorandom Function(伪随机函数)。简单来说,它是一个看起来完全随机、但实际上是确定性的函数。给定相同的输入,PRF 总是会产生相同的输出,但这个输出看起来就像是完全随机生成的一样(即,无法与真随机函数的输出区分)。在现代密码学中,PRF 是最基本的原语之一,是构建许多密码学协议的基石。要理解 PRF,我们首先需要理解它与真随机函数的关系。
如果你对计算机理论有一些了解,那么为什么我们无法使用真随机应该是不言自明的。理论上的随机函数可以想象成一个巨大的查找表,对于每一个可能的输入,表中都随机预先分配了一个输出值。这个表是如此之大,以至于我们无法实际构建它,但我们又确实在很多地方需要使用随机函数。我们当然可以在理论上假装我们正在使用真随机函数,但是这在工程上并不可行。既然我们无法使用真正的随机函数,PRF 提供了一个巧妙的替代方案:用一个算法和一个密钥来模拟随机函数的行为。PRF 实际上是一个函数族 PRF(key, input) → output,每个密钥对应族中的一个特定函数。
我们关心的一些 PRF 关键性质包括:
- 有密钥:PRF 接受一个秘密密钥和一个输入值,这意味着它和哈希函数不同
- 多项式时间:PRF 必须能在多项式时间内计算,这使它在实践中可用
- 不可区分:任何计算能力有限的对手(运行时间为多项式时间的算法),在不知道密钥的情况下,无法以不可忽略的概率区分 PRF 的输出和真正的随机输出
当然,如果攻击者有无限的计算能力,他们可以暴力枚举所有可能的密钥,并测试每个密钥是否能产生观察到的输出。这种情况下,攻击者将能够区分 PRF 和真随机函数。但在现实中,我们通常假设攻击者的计算能力在多项式时间内,这使得 PRF 在工程实践中是安全的。
显然,如果攻击者知道了 PRF 使用的密钥,攻击者也将能够区分 PRF 和真随机函数。在我们的情况中,PRF 的密钥由认证器保管,通常不可导出,这意味着攻击者不太可能接触到 PRF 使用的密钥。当然,用户也不太可能接触到——这意味着用户必须盲目信任认证器的提供商,因为用户实际上也无法确定这个 PRF 是否真的密码学安全。
回到 WebAuthn,每个通行密钥内部都有一个密钥,这个密钥对用户和网站都是不可见的。当我们向认证器的 PRF 提供一个输入(通常是一个随机盐)时,认证器会使用这个内部密钥和我们提供的输入,计算出一个看起来随机的输出。相同的输入意味着相同的输出。这使得我们可以重复地获得相同的密钥材料,这是确定性的;而不同的输入会产生不同的输出,且这些输出看起来完全随机,即使攻击者知道输入也无法预测输出。
在这种情况下,我们可以将 PRF 的输出视为密钥,来进一步加密我们需要保密的材料。更妙的是,由于通行密钥本身支持跨设备同步(如 iCloud 钥匙串、Google 密码管理器等),我们的密钥也就自然而然地可以在多设备间使用,而不需要我们自己实现复杂的密钥同步机制。这本质上是因为 PRF 的确定性:只要通行密钥相同,输入相同,输出必然相同。
注册认证器
我们可以从使用 PRF 构建一个简单的加解密工具开始。由于我们要使用的 PRF 功能是一个 WebAuthn 扩展,要使用相关能力,我们仍然受限于 WebAuthn 的基本流程:必须先注册认证器,才能使用该认证器的 PRF 能力。
一些现代认证器无条件提供 PRF 能力。这意味着即使在注册认证器时没有要求 PRF 扩展,在进行后续验证仪式时也可以使用 PRF。并非所有认证器都支持这一特性。
注册认证器的流程大体上和普通的 WebAuthn 注册仪式一样,但是我们需要额外告诉认证器我们希望使用 PRF 扩展:
const generateRandomUint8Array = (length = 32) => {
const input = new Uint8Array(length)
crypto.getRandomValues(input)
return input
}
const firstSalt = generateRandomUint8Array()
const cred = await navigator.credentials.create({
publicKey: {
...publicKeyCredentialCreationOptions,
extensions: {
prf: {
eval: {
first: firstSalt.buffer
}
}
}
}
})
我们在 extensions.prf.eval 中传入一个名为 first 的随机 buffer。在注册仪式中,我们不用关心这里的 buffer 具体是什么,将它放在这里是为了告诉认证器我们希望使用 PRF 扩展。
按规范,我们只需传入
eval: {}即可。不过,一些现代认证器确实支持在注册仪式上提供 PRF 能力。
随后,我们可以通过检查 create 方法的返回值来确定认证器是否支持 PRF:
const extensionResults = cred.getClientExtensionResults()
if (extensionResults?.prf?.enabled) {
// 支持
}
如果认证器确实支持 PRF,我们就能使用这个认证器进行后续的加解密了。很简单。针对不同的情况,你可能会需要将密钥的 ID 或者公钥保存起来,以备后续使用。
加解密
要使用 PRF 进行加密,我们将需要:
- 使用一个随机输入获得 PRF 的输出
- 使用 PRF 输出创建一个密钥派生密钥
- 从密钥派生密钥派生加密密钥
- 执行加密
为什么要先创建密钥派生密钥再派生最终加密的密钥?这么做会带来很多好处:
- 不同认证器的 PRF 输出可能是不一致的,尤其是可能会有不同长度,不一定能满足创建加密密钥所需算法的输入要求。通过密钥派生我们可以获得更一致的输入以供创建密钥
- 在进行密钥派生的过程中,我们将有机会传入盐和标签,这样我们就可以使用一个 PRF 输出创建多个不同用途的密钥。这分离了不同用途的密钥,降低了密钥泄露的风险,也可以减少要求用户交互的次数(获取 PRF 输出是一次 WebAuthn
get(),需要用户验证),提高安全性和用户体验
不复杂,让我们一步步来。首先我们需要进行一次 PRF。很显然,和注册仪式相对地,我们可以通过 navigator.credentials.get 来使用 PRF。输入和注册仪式中的一致,只不过这一次我们确实需要使用 PRF 输出,因此我们最好确保 firstSalt 是密码学随机的,并注意保存。由于 PRF 是私密的,PRF 的输入是可以公开的。
const firstSalt = generateRandomUint8Array()
const cred = await navigator.credentials.get({
publicKey: {
...publicKeyCredentialRequestOptions,
extensions: {
prf: {
eval: {
first: firstSalt.buffer
}
}
}
}
})
如果你对这里(以及注册认证器的时候)传入的参数名为
first感到奇怪,你的感觉是对的。许多认证器支持一次在 PRF 上运行两个输入,在这种情况下我们确实还能传入second,一次获得两个 PRF 输出。这在密钥轮换的场景下会很有用。
你也可以使用
evalByCredential(而不是eval)来传入一组通行密钥 ID -salt,这样可以对用户选择的不同认证器传入不同的盐。这种情况下必须传入对应的allowCredentials。
等待用户完成验证,我们就可以得到 PRF 的输出,并以此创建密钥派生密钥了:
const extensionResults = res.getClientExtensionResults()
if (extensionResults.prf?.results?.first) {
const prf = new Uint8Array(extensionResults.prf.results.first)
// 创建密钥派生密钥
const key = await crypto.subtle.importKey(
'raw', prf, 'HKDF', false, ['deriveKey']
)
}
然后,根据需要派生加密密钥。在这里,我们可以通过进一步提供不同场景下的密钥标签和不同的盐以使用同一个密钥派生密钥派生多个密钥:
const info = new TextEncoder().encode(label) // 派生密钥标签
const salt = generateRandomUint8Array() // 派生密钥盐
// 派生密钥
const encryptionKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', info, salt, hash: 'SHA-256' },
key,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
最后,加密!
const iv = generateRandomUint8Array(12)
const inputArray = new TextEncoder().encode(plainText) // 编码要加密的内容
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
inputArray
)
现在,我们就获得了加密后的 ArrayBuffer,将其进行合适的编码并保存即可。当然,也别忘了保存下整个过程中使用的各个盐、标签和 iv。没有这些信息,我们将无法再次推导出相同的密钥以供解密信息。
我们在这个示例中使用 HKDF 派生 AES-GCM 密钥的做法符合一些通常的最佳实践,但在不同情况下,你可能需要使用其他不同的加密模型。
在了解了加密流程之后,解密流程就简单了很多。和之前一样,我们可以通过 navigator.credentials.get 来获得 PRF 输出,然后使用 crypto.subtle.importKey 创建密钥派生密钥,并最终导出加密密钥。当然,过程中所有额外提供的盐、标签和 iv 都必须和之前提供的一致,否则将无法获得正确的密钥。最后,我们可以使用 crypto.subtle.decrypt 解密密文。
const iv = Uint8Array.from(window.atob(ivEncoded), c => c.charCodeAt(0))
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
ciphertext,
)
在这个示例里,我们最终使用了常见的 AES 算法进行加密,这意味着加解密的密钥会是同一个,自然可以用相同的流程获得。不过,利用认证器里私有的 PRF,我们可以使用一些公开信息就重新获得密钥。由于不需要存储关键私有数据,这大大降低了存储传输保密信息(尤其是在小型项目中)的难度。
在了解使用 PRF 扩展进行基本的加解密之后,我们就可以开始构建一些真正有趣实用的应用了。比如,很多时候,我们会希望在几个用户之间通过不可信通道(如邮件系统、IM 等)中交换私密消息,而不希望其他用户获取这些消息。我们可以:
- 首先创建一个 AES 内容加密密钥
- 依次使用各个用户的通行密钥的 PRF 输出创建一个密钥包装密钥,包装这个内容加密密钥,与对应的通行密钥 ID 一起存储所有包装后的密钥
- 通过任意渠道在所需用户之间同步这个密钥包,甚至可以存储在后文提到的 Large Blob 中
- 需要进行加解密时,我们可以通过通行密钥 ID 找到对应的包装后的密钥,从中获得内容加密密钥
- 进行加解密
还有许多有意思的应用,比如可以使用通行密钥进行消息签名等等。这些已经属于密码学应用的范畴了,有兴趣可以自行探索。
附:关于 Large Blob
许多认证器还随 PRF 扩展一起推出了对 Large Blob 扩展的支持。通过这个扩展,我们将可以在通行密钥中保存最多 1KB 的附加数据。当然,不同认证器对此可能有不同限制,Yubikey 的典型限制是所有通行密钥共享 1KB 的存储空间;而软件实现则通常有充足的空间,可以假设每个通行密钥都能使用至少 1KB 的空间。尽管存储空间仍然不算很大,但相比旧的 Cred Blob 扩展的 32 字节限制,这已经是巨大的进步了,我们也得以在认证器中存储一些实用数据。在我们的例子 PRF Password Manager 中,我们可以将加密所需的 firstSalt 和读取数据所需的 Todoist API Token 随通行密钥一起保存,这样在新设备上用户将无需手动输入这些信息,只需要一次身份验证就可以从通行密钥中恢复这些信息。
使用 Large Blob 流程和 PRF 扩展类似。首先我们需要在注册通行密钥时进行检查。由于 Large Blob 需要存储在认证器上,对应的通行密钥必须是 Resident Key,这通常会占用硬件密钥(Yubikey 等)的有限存储槽位。
const cred = await navigator.credentials.create({
publicKey: {
...publicKeyCredentialCreationOptions,
authenticatorSelection: {
residentKey: 'required', // 由于需要存储数据,认证器无法使用计算密钥,必须存储密钥
},
extensions: {
largeBlob: {
support: 'preferred' // 或者'required'
}
}
}
})
const supported = res.getClientExtensionResults()?.largeBlob?.supported === true
写入也需要一次身份验证,且需要指定要写入的通行密钥,不能写入任意通行密钥。
const res = await navigator.credentials.get({
publicKey: {
...publicKeyCredentialRequestOptions,
allowCredentials: [{
type: 'public-key',
id: credentialId, // 写入时必须传入单个通行密钥 ID
}],
extensions: {
largeBlob: {
write: new TextEncoder().encode(dataToStore)
}
}
}
})
const extensionResults = res.getClientExtensionResults()
if (!extensionResults.largeBlob?.written) {
throw new Error()
}
读取时则无需指定单个密钥,这种情况下读取结果取决于用户选择的通行密钥。目前,规范不支持读写同时进行。
const res = await navigator.credentials.get({
publicKey: {
...publicKeyCredentialRequestOptions,
extensions: {
largeBlob: {
read: true
}
}
}
})
const result = res.getClientExtensionResults()?.largeBlob?.blob
总结
WebAuthn PRF 扩展为 Web 应用带来了强大的加密能力,让我们能够利用用户设备上已有的安全硬件(如 TPM、安全隔区、硬件密钥等)来保护敏感数据。通过这个扩展,我们将能够在 Web 端实现优雅安全、用户体验良好、自动多端同步密钥的数据加解密,这在端到端加密、零知识架构、多用户协作等许多 Web 应用场景中都有巨大的使用潜力。结合 Large Blob 扩展,我们将能够构建真正安全有用的 Web 密码学应用。
随着更多浏览器和认证器开始支持 PRF 扩展,我们将看到越来越多的 Web 应用采用这项技术来提升安全性和用户体验。对于开发者来说,现在是开始探索和实验 PRF 扩展的好时机——它为构建真正安全的 Web 应用提供了一个强大而优雅的工具。
如果你想深入了解 PRF 扩展,可以查看 W3C WebAuthn 规范中的 PRF 扩展一节,或者尝试使用本文提到的 PRF Password Manager 演示项目来感受这项技术的实际效果。

发表回复