开发 - 无垠 https://flyhigher.top 飞翔的天空无限大 Sun, 19 Oct 2025 11:52:13 +0000 zh-Hans hourly 1 https://flyhigher.top/wp-content/uploads/2017/01/cropped-touch-1-32x32.png 开发 - 无垠 https://flyhigher.top 32 32 使用通行密钥 PRF 扩展在浏览器中安全加密 https://flyhigher.top/develop/2710.html https://flyhigher.top/develop/2710.html#comments Sat, 18 Oct 2025 17:38:49 +0000 https://flyhigher.top/?p=2710

既然我们已经能在浏览器中访问 Windows Hello、Face ID 和硬件安全密钥了,为什么不使用它们的基础加密能力呢?

随着 iOS 18 变得广泛可用(是的,这篇文章开始写的时候 iOS 18 还刚刚发布...)及新版本 Chrome 的推出,通行密钥 (Passkey/WebAuthn) PRF 扩展终于开始进入主流浏览器。现在,通过 PRF 扩展,网页将可以通过 WebAuthn API 直接访问通行密钥的伪随机函数 (PRF) ,由此派生与该通行密钥关联的加解密密钥。此外,用户还将能利用通行密钥的跨平台同步特性,在不同设备间无缝同步密钥。

说人话就是,这意味着:

  1. 由于通行密钥的底层通常是硬件加密方案(Yubikey、TPM、CPU 安全隔区等),浏览器将可以利用这些硬件带来的额外安全性进行更安全的密码学操作
  2. 通行密钥可以跨设备地同步和使用,我们将可以利用平台的基础设施完成密钥安全跨设备同步,无需自建相关服务

这将会给许多加密相关的 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 进行加密,我们将需要:

  1. 使用一个随机输入获得 PRF 的输出
  2. 使用 PRF 输出创建一个密钥派生密钥
  3. 从密钥派生密钥派生加密密钥
  4. 执行加密

为什么要先创建密钥派生密钥再派生最终加密的密钥?这么做会带来很多好处:

  • 不同认证器的 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 等)中交换私密消息,而不希望其他用户获取这些消息。我们可以:

  1. 首先创建一个 AES 内容加密密钥
  2. 依次使用各个用户的通行密钥的 PRF 输出创建一个密钥包装密钥,包装这个内容加密密钥,与对应的通行密钥 ID 一起存储所有包装后的密钥
  3. 通过任意渠道在所需用户之间同步这个密钥包,甚至可以存储在后文提到的 Large Blob 中
  4. 需要进行加解密时,我们可以通过通行密钥 ID 找到对应的包装后的密钥,从中获得内容加密密钥
  5. 进行加解密

还有许多有意思的应用,比如可以使用通行密钥进行消息签名等等。这些已经属于密码学应用的范畴了,有兴趣可以自行探索。

附:关于 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 演示项目来感受这项技术的实际效果。

]]>
https://flyhigher.top/develop/2710.html/feed 2
不止更亮:在 Web 中使用 HDR https://flyhigher.top/develop/2693.html https://flyhigher.top/develop/2693.html#comments Mon, 07 Jul 2025 05:49:05 +0000 https://flyhigher.top/?p=2693 随着现代智能手机普遍开始支持 HDR 照片的拍摄和 Lightroom 等专业摄影软件开始支持 HDR 工作流,HDR 图片的生产正在变得越来越方便,也越来越常见。甚至,通过合适的工作流,我们可以将旧介质的照片(如胶片)重新数字化为 HDR 图片。对于从一开始就无法 HDR 化的早期数字图片,我们现在也可以通过 AI 重建其 HDR 信息。

数字化为 HDR 的胶片照片。如果你的设备和浏览器支持 HDR,请注意比 SDR 白色更亮的云彩细节。

与此同时,网页中的 HDR 显示正在变得逐渐可用,在网页中广泛使用 HDR 的时机已经成熟。这篇文章简要总结了我在近期项目的 HDR 支持开发中碰到的问题和要点。请注意,这篇文章中含有许多 HDR 媒体,包括一些用于测试的、包含极端亮度的图片和文字。建议不要在昏暗的环境下阅读本文。

本文写作于 2025 年 7 月。由于 HDR 相关规范的快速发展和变化,本文中的内容在你阅读时可能已经过时,请务必自行检查。

在开始之前,你需要确定你的设备和浏览器组合能否正确显示 HDR 内容。截至 2025 年 7 月,如果你正在使用 Firefox 或 Safari(除 Safari 26+ 外),你可能会看不到接下来的某些或全部 HDR 内容。不同浏览器的支持情况可能会有差异。

目前,还没有一个完全可靠的方法检查你的设备和浏览器组合是否支持 HDR 显示。不过,检查 (dynamic-range: high) 的媒体查询将可以覆盖绝大多数的情况。Safari 可能需要结合 screen.colorDepth 一起检查。

如果你搜索如何检查浏览器 HDR 支持的相关内容,你可能会看到诸如 (dynamic-range: high) and (color-gamut: p3) 的媒体查询。这是错误的。(dynamic-range: high)(color-gamut: p3) 是两个不同的媒体查询,支持 HDR 的设备或浏览器不一定有 P3 色域(尽管它们通常一起出现),反之亦然。

你的浏览器在当前显示器上的检查结果:

  • (dynamic-range: high):
  • (color-gamut: p3): (不影响 HDR)
  • 颜色深度: (在支持 HDR 时应大于 24)

接下来是一些真实情况测试。建议将页面切换到亮色模式。

如果你发现上方右侧图片左上角的高光区域明显比左侧图片亮,且比页面背景白色(漫射白色)更亮,那么你的浏览器可以正确显示包含增益图的 HDR JPEG 图片。

如果你在上方同时看到一个显著亮于漫射白色的白色方形区域和一个明显比漫射白色更亮的表情包,那么你的浏览器可以正确显示包含 Rec2020 PQ ICC 的 HDR 图片。请注意,iOS Safari 18 及以下版本的 HDR 图片支持很不稳定,会出现一部分图片正确显示 HDR 一部分变成 SDR 的情况(包括这个测试)。以上两张图片有完全一致的嵌入 ICC,应有一致的表现。

举头望明月

如果你在上方看到一些显著亮于漫射白色的文字和形状,那么你的浏览器可以显示 HDR 颜色。这项测试是非标准的,如果你在此测试中看不到任何 HDR 内容,这并不意味着你的设备和浏览器不能正确显示 HDR。

为啥要 HDR

如果你对摄影略有了解,你可能已经听说过另一个不同的 HDR 的概念。在摄影中,HDR 通常指的是高动态范围摄影,即使用多张曝光不同的照片,通过后期合成,来获得比普通照片更宽的动态范围。但在网页或其他数字媒体中,HDR 通常指的是高动态范围显示,即使用比 SDR 更宽的动态范围来显示内容。最直观的体现就是,HDR 媒体中的峰值亮度在屏幕上看起来通常比标准动态范围 SDR 中的最亮的白色更亮。为什么要 HDR?SDR 下的各种设计都被曾经的屏幕技术限制,而 HDR 可以充分发挥现代屏幕的能力。显然,它能让各类媒体,包括网页自己,在屏幕上看起来更好。甚至在浏览器中,这不只是 HDR 的事,而是色彩管理相关能力的拓展。

你可能听到过一个说法,前端没有色彩管理,只有 sRGB。这句话放在几年之前可能还是正确的,但随着 HDR 的内容越来越多,浏览器对 HDR 内容的支持变好,这句话已经不再正确了——现在我们确实能在现代浏览器中做一定程度的色彩管理了。等一下,你可能会问,这和 HDR 有什么关系?HDR 相对 SDR 最直观的体现就是亮度范围的拓展,当亮度被拓展后,我们无法再使用传统 sRGB 的 8 位/通道来表示颜色了,8 位下的 HDR 会出现严重的色彩断层(通常,即使有良好的曲线,HDR 内容也需要至少 10 位来避免颜色断层);同时,2.2 的伽马曲线也不再适合 HDR,需要使用为 HDR 优化的色彩空间和曲线来表示颜色。我们需要对 CSS 和 JavaScript 进行严肃的色彩管理拓展。

如果你像我一样曾经分不清色域和色彩空间:简单来说,色域 (Gamut) 就是颜色可以被表示的范围,也就是通常你在色域图上看到的那些三角形。常见的色域有 sRGB、P3、Adobe RGB 等;而色彩空间 (Color space) 和色域不同,描述的是颜色被表示的方式,更像是一套坐标,或是一种编码颜色的方式。色彩空间通过定义原色坐标、白点等参数来定义如何描述一个颜色。P3 是色域,而 Display-P3 在 P3 下定义了 D65 白点,2.2 的伽马曲线等等,属于色彩空间。

于是,在 CSS Color 4 中,我们有了 color() 函数,可以让我们使用特定的色彩空间来描述颜色;还有了 oklch()oklab() 等新的色彩空间,可以让我们使用更符合人类直觉的色彩空间来描述颜色…有了这些好东西,我们现在能在网页中随意使用 HDR 了吗?是,也不是。我们可以在网页中使用 HDR 图片和视频了,但同时我们还缺少很多东西,包括真正的 HDR 色彩空间、对 HDR 内容的管理能力和 canvas 的 HDR 支持等等。HDR 相关规范正在发展,这正是一个写文章介绍这个概念好时机。

HDR 图片

HDR 图片的生产流程不在本文的讨论范围内。如果你使用现代移动设备,很可能已经可以轻松拍摄、分享 HDR 图片。如果你使用 Lightroom 等专业摄影软件,则现在已经可以轻松导出 HDR 图片了。Adobe 有一篇很棒的文章介绍了如何使用 Lightroom HDR 工作流。遗憾的是,目前在消费级设备中还没有可靠的方法校准 HDR 显示器。

让我们从拿到 HDR 图片开始。要在网页中显示 HDR 图片,大概有两种办法:一是使用直接进行 HDR 色彩管理的图片(包括嵌入 HDR ICC Profile 和使用 nclx 色彩类型的 AVIF);二是使用带有增益图的 HDR 图片。直接进行 HDR 色彩管理的图片很显然,甚至前段时间已经被广泛讨论了(嵌入了 HDR ICC 的表情包),但什么是带有增益图的 HDR 图片?

HDR = 底图 × 增益图

带有增益图的 HDR 图片本质上是一张正常的 SDR 图片加一张增益辅助图。在渲染时,支持增益图的软件会正常读取 SDR 底图,然后读取增益图来调整图片不同区域的亮度(和颜色)。对于不支持 HDR 的软件或设备,或在不理解增益图的软件中,增益图会被忽略,图片会优雅降级为 SDR。

这种方法和好处在于,内容创作者可以精确控制图片在 SDR 和 HDR 下的表现。而如果直接使用 HDR 图片,在 SDR 设备上,图片会被软件色调映射到 SDR,但这一过程是特定于平台的,创作者无法保证图片在 SDR 设备上能一致、正确地表现创作意图。不过,使用增益图的代价是相比 SDR 图片大小会增加大约 30%。

色调映射对比

从上面的例子可以看到,平台默认的色调映射算法通常无法取得最佳效果。当前版本的 Chrome (138) 中的色调映射算法在这个例子中的 HDR ICC 图片上会带来明显的暖色偏移,同时丢失对比度;而带有增益图的图片在色调映射后则更接近 HDR 原图的表现。HDR 到 SDR 的色调映射不是一件简单的事情,不同平台可能会有不同的表现,进而导致结果良莠不齐。带有底图和增益图的图片相当于带有标准答案,可以尽可能在所有平台和设备上都能取得一致的、符合创作者意图的表现。

目前,我们可以在 JPEG 或 AVIF 格式下嵌入 HDR 增益图。然而,尽管 JPEG 增益图格式支持良好, AVIF 增益图的浏览器支持度仍正在提高,要使用带有增益图的 AVIF 图片,你还需要等待一段时间。在目前,如果需要使用 HDR 图片,推荐使用带增益图的 JPEG 以获得最广泛的支持。

需要注意的是,尽管带增益图的 JPEG 图片本身是一张正确的 SDR JPEG 图片,但我们不能简单将其作为普通 JPEG 图片放入目前常见的图像处理工作流中,否则极容易丢失正确的 HDR 效果。例如,如果使用 Exiftool 清理图片的 Exif 信息:

$ exiftool -all= --icc_profile:all hdr.jpg

你会发现图片的 HDR 效果丢失了,这显然是因为所有与 HDR 增益图相关的信息都被清除了。如何才能保留增益图呢?图像处理小课堂开课了。

实际上,JPEG HDR 增益图有两个版本。在其被 ISO 规范化之前,谷歌就在使用类似格式保存 HDR 图片,谷歌称之为 UltraHDR。在不久之前,谷歌、苹果等厂商一起将这一格式标准化,出版为 ISO 21496-1。这两种格式有少许不同。如果你在 2024 年从 Lightroom 导出过 JPEG HDR 图片,这大概率在使用 UltraHDR 格式;最近导出的图片则很可能使用 ISO 21496-1 格式。我们可以使用 Exiftool 来检查:

$ exiftool -G1 -a -s hdr.jpg | grep hdrgm
[XMP-hdrgm]  Version: 1.0

旧格式在 XMP 中有一个 hdrgm:Version 字段。通常为了兼容性,使用新的标准格式导出的图片也会保留这一字段。

$ exiftool -G1 -a -s new_hdr.jpg | grep APP2
[APP2]  UniformResourceName: urn:iso:std:iso:ts:21496:-1

新的标准格式则会在 APP2 段中添加一个 UniformResourceName 字段,其值为 urn:iso:std:iso:ts:21496:-1,表示这一图片使用 ISO 21496-1 标准的增益图。

$ exiftool -MPF:all -G1 new_hdr.jpg
[MPF0]      MPF Version                   : 0100
[MPF0]      Number Of Images              : 2
[MPImage2]  MP Image Flags                : (none)
[MPImage2]  MP Image Format               : JPEG
[MPImage2]  MP Image Type                 : Gain Map Image  // 对于旧格式,这一项为 none (0)
[MPImage2]  MP Image Length               : 974155
[MPImage2]  MP Image Start                : 1535216
[MPImage2]  Dependent Image 1 Entry Number: 0
[MPImage2]  Dependent Image 2 Entry Number: 0
[MPImage2]  MP Image 2                    : (Binary data 974155 bytes, use -b option to extract)

新旧格式都把增益图放在 MP Image 2 中。这会是一个合法的 JPEG 文件。旧格式中,MP Image 2 的类型为 none,而新格式中,这一项的类型为 Gain Map Image。如果图片丢失了 UniformResourceName 但 MP Image 2 是 Gain Map Image,则图片解码器通常会忽略 MP Image 2 将图片解释为 SDR。

而 HDR 相关的参数则被塞在了 MP Image 2 的 XMP 中。我们可以使用 Exiftool 的 -ee (Extract Embedded) 选项来查看:

$ exiftool -G1 -a -s -ee new_hdr.jpg | grep hdrgm
[XMP-hdrgm]  Version           : 1.0  // 这个是图片本身的 XMP
[XMP-hdrgm]  Version           : 1.0  // 从这里开始是 MP Image 2 的 XMP
[XMP-hdrgm]  BaseRenditionIsHDR: False
[XMP-hdrgm]  HDRCapacityMin    : 0
[XMP-hdrgm]  HDRCapacityMax    : 2.263458
[XMP-hdrgm]  OffsetSDR         : 0.015625
[XMP-hdrgm]  OffsetHDR         : 0.015625
[XMP-hdrgm]  GainMapMin        : -1.894775, -0.331843, -0.360353
[XMP-hdrgm]  GainMapMax        : 2.197754, 1.913239, 1.535431
[XMP-hdrgm]  Gamma             : 1.321106, 0.624804, 0.815203

以上这些意味着,在图片通过处理管线时,我们需要保留图片的 XMP hdrgm 信息、APP2 段和 MPF 段。任意一项内容的丢失都会导致图片的 HDR 效果丢失。此外,在对图片进行变换时,我们需要一并处理增益图。目前,许多图片处理库还不能正确地处理带有增益图的图片。如果你需要自己开发,请注意:

  • 不要将增益图视为图片并尝试进行常见的图像压缩和优化,这可能会导致最终的 HDR 不正确。将其视为用于将 SDR 图片映射到 HDR 图片的计算矩阵。
  • 增益图和底图之间的计算使用乘法,因此请注意接近 0 的像素值。意外的 0 像素会导致最终结果中出现 0,这可能会产生断层和伪影。
  • 不要修改增益图的 HDR 元数据,即使已知的最大值/最小值像素已经被裁切或不存在在增益图中。增益图中的其他像素仍然可能需要这些值进行正确的计算。

现在我们终于拿到了生产就绪的 HDR 图片。让我们把它放进页面。这可能是最简单的一步。如果你使用增益图 JPEG 图片,并且没有 SDR 的后备,直接把图片放进 img 标签就可以了。如果你使用基于 HDR 色彩管理的 AVIF 图片,则可以使用 picture 元素让浏览器自己选择合适的图片,避免糟糕的 SDR 色调映射:

<picture>
  <source srcset="hdr.avif" type="image/avif" media="(dynamic-range: high)">
  <img src="proxy.php?url=sdr.jpg">
</picture>

iOS Safari 18 及以下不支持 HDR 图片,但会宣称自己支持 (dynamic-range: high) 媒体查询,且支持 JPEG,因此需要通过 JavaScript 来切换正确的图片。我们需要同时检查 (dynamic-range: high) 媒体查询和 screen.colorDepth > 24 来判断。不过随着 iOS 26 的发布,这个问题将会逐渐得到解决。

现在 HDR 图片应该可以正确显示了。但这还没结束。有的时候,我们作为开发者无法控制用户生成的 HDR 内容,或是不得不使用一些带有极端亮度的 HDR 图片。直接显示这些图片可能会严重损害用户体验,甚至带来健康风险。有任何办法控制页面上 HDR 内容的极端值吗?有什么办法可以让用户在亮色模式下看到完整的动态范围,同时在暗色模式下限制动态范围吗?有的兄弟有的。

dynamic-range-limit: no-limit
dynamic-range-limit: constrained
dynamic-range-limit: standard

通过 CSS Color HDR 规范中定义的 dynamic-range-limit 属性,我们可以限制页面上的 HDR 内容表现。这里我们有三个选择,no-limit 表示不限制动态范围,constrained 表示限制极端亮度,将 HDR 峰值亮度限制在比漫射白色略高,但仍然保持一部分 HDR 表现,standard 表示限制为 SDR 范围。具体色调映射方式由浏览器决定。显然,如果要取得最佳色调映射效果,我们需要使用带有增益图的 HDR 图片。

那打印网页时会发生什么呢?显然我们无法在物理纸张上打印 HDR 效果(废话),在打印时,浏览器会将页面上所有 HDR 内容都色调映射到 SDR。再一次,如果要取得最佳色调映射效果,我们需要使用带有增益图的 HDR 图片。如果有其他内容需要调整,则可以使用 CSS dynamic-range 媒体查询。

HDR 颜色

目前,我们还无法规范地在浏览器中指定和使用 HDR 颜色。尽管 CSS Color 4 规范中定义了 Display-P3 和 Rec2020 等看似支持 HDR 的色彩空间,但实际上 CSS Color 4 只定义了在 SDR 下的色彩空间,并没有定义任何 HDR 色彩空间,因此,在新的 CSS Color HDR 规范被广泛采纳之前,我们无法规范地使用 HDR 颜色。

尽管如此,你应该还记得在文章开头,我们确实进行了 HDR 颜色测试。如果你的设备支持 HDR,你应该能看到比白色更亮的颜色。这是怎么做到的呢?

在 CSS Color 4 中,我们引入了一个新的 color() 函数,它允许我们使用特定的色彩空间来描述超越 sRGB 的颜色,例如 color(display-p3 1 0 0),我们可以用一个色彩空间名称加上三个 0-1 的成分值(RGB 或 XYZ)来描述颜色。更妙的是,由于现在 CSS 支持不同色彩空间中的颜色转换,实际上在指定颜色时,成分值并不要求在 0-1 之间,而是可以超出这个范围。例如 color(rec2020 0.42053 0.979780 0.00579) 在转换为 Display-P3 颜色时会变为 color(display-p3 -0.6112 1.0079 -0.2192)。你可以注意到这里的值都已经超出了 0-1 的范围。尽管这个颜色并不在 CSS 定义的 Display-P3 色彩空间中,但这仍然是一个有效的颜色。

CSS Color 4 极大地拓展了 CSS 中颜色的灵活性。这不是一个简单的话题,可能值得另写一篇文章来讨论。

这时,如果我们指定的值超出了色彩空间,会发生什么呢?CSS Color 4 规范中写道

成分值超出 0-1 的范围意味着颜色超出色彩空间。这些值并非无效,且在中间计算中会被保留;但在显示时,它们会通过相对色度意图进行 CSS 色彩空间映射,将值在显示颜色空间中映射到实际值的 0-1 范围内。

这意味着,我们可以定义成分值大于 1 的颜色,如果能被显示,成分值大于 1 就会使得该成分超出 SDR 范围。不过,光保留无效值还不够,我们还需要避免浏览器将超出范围的值转换为 SDR 范围。这实际上是由 ardov 发现的一个 bug。在实际操作中,满足以下条件时:

  1. 页面中有任意包含 Rec2020 ICC 的图片
  2. 超出范围的颜色被 CSS 用作背景色

Chrome 会保留所有超出色彩空间范围的值,并不经过映射就提交渲染;不过 iOS Safari 无需这两个条件,可以直接使用超出范围的 HDR 颜色。这意味着,我们可以定义成分值大于 1 的颜色,只要支持,浏览器就会将其渲染为比 SDR 更亮的颜色。此外,为了在 Chrome 中的文字上使用,我们需要将颜色设置为背景色并使用 background-clip: text 来裁剪背景色。在文章开头的例子中,我们实际上使用的颜色是 color(display-p3 2 2 2),显示为比白色更亮的白色。尽管不符合规范,这一 hack 已经稳定存在了超过两年。我的猜测是,Chrome 在页面中有使用 Rec2020 ICC 的图片时,浏览器会将整个页面都合成为 Rec2020 颜色,在这个过程中,超出范围的背景颜色意外跳过了映射。而 iOS Safari 则疑似会将所有颜色的亮度统一为一个比 SDR 稍亮的亮度,且不会超过屏幕最高亮度。

在上面的例子中,SDR 范围为 0-1 区间。当然我们还可以使用其他 HDR 颜色。

虽然这是一个比较脏的 hack,但通过这种方法,我们可以充分利用现代显示设备的能力,在网页中一窥真正的 EDR UI 的样貌了。由于在 SDR 设备上颜色会被自动映射,我们不必担心用户在 SDR 设备上看到太奇怪的颜色,颜色自己会映射到 SDR 范围。当然,由于是自动映射,我们无法保证颜色在所有设备上都有合理的、一致的表现。

如果你无法看到上面的 HDR 效果:

相机拍摄的 HDR 效果

相机拍摄的 HDR 效果。这无法完美还原实际观看感觉

必须要说明的是,CSS Color 4 中定义的色彩空间本质上都在 SDR 下,这样使用 HDR 颜色显然是不规范且不准确的。一方面浏览器的行为随时有可能变更;另一方面,由于用户浏览器和显示器可能不支持 HDR,或拥有能力不同的 HDR 显示器,如此定义的颜色由于会经过基于设备能力的颜色映射,并不能保证在所有设备上都有一致的表现,无法稳定地在生产环境中使用。此外,我们之前讨论过的 dynamic-range-limit CSS 属性目前也无法用于限制除图片和视频以外的内容,包括使用 HDR 颜色的其他元素。滥用 HDR 颜色可能会严重降低用户体验,甚至产生健康损害,而用户无法对此做出限制,这是不可取的。

要在网页中规范地使用 HDR 颜色,我们需要等待 CSS Color HDR 规范的最终发布,其中定义了 Rec2100 等的真 HDR 色彩空间,以及 dynamic-range-limit 等实用属性。CSS Color HDR 计划为 CSS 引入 rec2100-pq、rec2100-hlg 和 rec2100-linear 等色彩空间。若浏览器支持,我们将可以使用如 color(rec2100-pq 1.0 1.0 1.0) 的颜色。rec2100-pq 是绝对亮度的色彩空间,这一颜色表示亮度为 10,000 cd/m² 的白色(当然,现在还没有什么普通显示器能显示这个)。还有计划添加一个 color-hdr 函数,以便开发者在不同的 HDR 余量(即 HDR 峰值亮度相对于 SDR 的倍数。SDR 下的余量为 0)下显示不同的颜色。由于准确的 HDR 余量被考虑为浏览器指纹,开发者无法不授权就获取确切的 HDR 余量,但是将可以定义这样的颜色:

--color: color-hdr(color(rec2100-linear 0.9 1.0 0.8) 0, color(rec2100-linear 1.8 2.0 1.5) 2);

这时,当用户处在 SDR 显示器下时,该函数将返回前一个颜色;而在余量大于 2 时,返回后一个颜色;在 0-2 之间时,返回插值颜色。

HDR 视频和 HDR 画布

HDR 视频和 HDR 画布其实是两个极端:大部分浏览器很早就支持了 HDR 视频(由于视频通常是单独提交渲染的,不与页面中的其他 SDR 元素合成,支持很方便),但目前所有浏览器对 HDR 画布的支持都还在初级阶段。但这都导致关于它们的内容没法写太多,我将它们合并为了一个章节。

在网页中使用 HDR 视频可以很复杂也可以很简单,主要问题在于选择合适的视频编码。浏览器对 HDR 视频编码的支持各不相同,通常,HEVC HDR 的浏览器支持程度较好,AV1 的支持程度也在慢慢增加。要在浏览器中检查 HDR 的支持程度,可以首先检查 navigator.mediaCapabilities.decodingInfo

const capability = await navigator.mediaCapabilities.decodingInfo({
  type: 'media-source',
  video: {
    contentType: 'video/webm; codecs="vp09.00.10.08"',
    width: 1920,
    height: 1080,
    bitrate: 2646242,
    framerate: '25',
    transferFunction: 'pq',
    colorGamut: 'p3',
    hdrMetadataType: 'smpteSt2086', // HDR 10
  }
})

capability // { powerEfficient: true, smooth: true, supported: true }

其中 transferFunction, colorGamuthdrMetadataType 即为 HDR 相关内容检查。如果返回结果为 supported: true 即为支持当前编码组合。Firefox 尚不支持此 API。

在使用 video 元素嵌入视频时,我们还可以在 source 元素的 type 属性中加入编码信息,帮助浏览器选择正确的源:

<video>
  <source src="proxy.php?url=movie_pq.mp4" type='video/mp4; codecs="hvc1.2.4.L153.B0"'>
</video>

最后,由于部分设备(如电视)可能不支持网页其他内容的 HDR 但支持视频的 HDR,CSS MediaQueries 5 中还有一个 (video-dynamic-range: high) 媒体查询的提案,用法和 dynamic-range 媒体查询一样,但仅用于视频。该媒体查询目前只有 Firefox 支持,Chrome 需启用 flag。

对于画布,情况就复杂一些。由于开发者不仅需要在兼容 HDR 的平台上显示 HDR 内容,还需要针对不同的 HDR 余量甚至 SDR 做色调映射和色彩管理,而 Canvas 最初就是围绕 sRGB 的 8 位/通道设计的。这需要对 Canvas、WebGL 和 WebGPU 的规范都进行不小的修改,牵扯的范围比较广。目前的提案包括:

  • 为 2D Canvas、WebGL 和 WebGPU 添加 HDR 色彩空间
  • 为 2D Canvas 添加 16 位浮点颜色支持
  • 允许为 2D Canvas 设置 HDR 元数据
  • 为 WebGL 和 WebGPU 添加拓展的动态范围
  • 为 2D Canvas、WebGL 和 WebGPU 添加默认色调映射,未来可能支持自定义色调映射
  • 允许 2D Canvas、WebGL 和 WebGPU 在导入纹理或图像时不经过 SDR 映射

...等等。目前已有部分特性可以通过启用 Chrome 的 enable-experimental-web-platform-features flag 使用。例如,在 WebGPU 中,我们可以使用:

const context = canvas.getContext('webgpu')
context.configure({
  device,
  format: 'rgba16float',
  colorSpace: 'display-p3',
  toneMapping: { mode: 'extended' },
})

来启用 HDR。目前只支持 Display P3 色彩空间,未来可能会添加 rec2100-linear 等色彩空间;同时目前仅有 Chrome 支持 HDR 画布,需要启用上文提到的 flag。Firefox 和 Linux 上的 Chrome 可能需要额外设置以启用 WebGPU。此外还有一些问题阻止我们在生产中使用 HDR 画布,例如现在无法导入 HDR 纹理,这意味着如果要使用 HDR 图像,我们必须手动解码并将原始像素值传递给画布。这在代码复杂度和性能上都是无法接受的,仍需要等待相关提案的落实。HDR Canvas 目前也无法导出或另存为 HDR 图片。

如果你的浏览器/显示器支持 HDR WebGPU,你应该能在上方看到一个右侧明显比漫射白色更亮的画布,并且渐变区域中的颜色没有明显断层或分界线。

此外,还有一些优秀的 HDR WebGPU 示例可供参考,如 Ultraviolet Photogrammetry, WebGPU HDR ExampleWebGPU Particles Sample

总结

现实情况是,目前不同浏览器对不同 HDR 特性的支持情况差距非常大。Chrome 拥有较为完善的支持,Safari 26 正在迎头赶上,而 Firefox 则有些落后了。此外,还有大量 HDR 相关提案正在标准化的过程中,不稳定的 API 和不确定的浏览器支持使得许多 HDR 特性还无法在生产环境中使用。不过至少 HDR 视频和 HDR 图片已经基本可用了——尽管浏览器之间的行为还有许多混乱与不同,但可以预见很快我们就将会有完善的 HDR 图片与视频支持。我最近为我的照片集 Axton Gallery 加上了 HDR 图片支持,使得部分照片在支持的平台上得以充分利用显示器的动态范围,提供更好的观看体验。

其他 HDR 特性则仍有待标准化与浏览器的进一步支持。相信不久之后,我们就可以在浏览器中充分发挥现代显示器的能力了。

]]>
https://flyhigher.top/develop/2693.html/feed 10
前端时间国际化 101 https://flyhigher.top/develop/2482.html https://flyhigher.top/develop/2482.html#comments Thu, 14 Oct 2021 15:58:23 +0000 https://flyhigher.top/?p=2482

时间只是幻觉。

—— 阿尔伯特·爱因斯坦

最近在开发一个需要完善国际化方案的前端项目,在处理时间国际化的时候遇到了一些问题。于是花了一些时间研究,有了这篇文章。不过由于网上关于 JavaScript 中 Date 对象的坑的文章已经一抓一大把了,因此这篇文章不是 JavaScript 中 Date 对象的使用指南,而是只专注于前端时间国际化。

从时区说起

要想处理时间,UTC 是一个绕不开的名字。协调世界时(Coordinated Universal Time)是目前通用的世界时间标准,计时基于原子钟,但并不等于 TAI(国际原子时)。TAI 不计算闰秒,但 UTC 会不定期插入闰秒,因此 UTC 与 TAI 的差异正在不断扩大。UTC 也接近于 GMT(格林威治标准时间),但不完全等同。可能很多人都发现近几年 GMT 已经越来越少出现了,这是因为 GMT 计时基于地球自转,由于地球自转的不规则性且正在逐渐变慢,目前已经基本被 UTC 所取代了。

JavaScript 的 Date 实现不处理闰秒。实际上,由于闰秒增加的不可预测性,Unix/POSIX 时间戳完全不考虑闰秒。在闰秒发生时,Unix 时间戳会重复一秒。这也意味着,一个时间戳对应两个时间点是有可能发生的。

由于 UTC 是标准的,我们有时会使用 UTC+/-N 的方式表达一个时区。这很容易理解,但并不准确。中国通行的 Asia/Shanghai 时区大部分情况下可以用 UTC+8 表示,但英国通行的 Europe/London 时区并不能用一个 UTC+N 的方式表示——由于夏令时制度,Europe/London 在夏天等于 UTC+1,在冬天等于 UTC/GMT。

一个时区与 UTC 的偏移并不一定是整小时。如 Asia/Yangon 当前为 UTC+6:30,而 Australia/Eucla 目前拥有奇妙的 UTC+8:45 的偏移。

夏令时的存在表明时间的表示不是连续的,时区之间的时差也并不是固定的,我们并不能用固定时差来处理时间,这很容易意识到。但一个不容易意识到的点是,时区还包含了其历史变更信息。中国目前不实行夏令时制度,那我们就可以放心用 UTC+8 来表示中国的时区了吗?你可能已经注意到了上一段中描述 Asia/Shanghai 时区时我使用了大部分一词。Asia/Shanghai 时区在历史上实行过夏令时,因此 Asia/Shanghai 在部分时间段可以使用 UTC+9 来表示。

new Date('1988-04-18 00:00:00')
// Mon Apr 18 1988 00:00:00 GMT+0900 (中国夏令时间)

夏令时已经够混乱了,但它实际上比你想象得更混乱——部分穆斯林国家一年有四次夏令时切换(进入斋月时夏令时会暂时取消),还有一些国家使用混沌的 15/30 分钟夏令时而非通常的一小时。

不要总是基于 00:00 来判断一天的开始。部分国家使用 0:00-1:00 切换夏令时,这意味着 23:59 的下一分钟有可能是 1:00。

事实上,虽然一天只有 24 个小时,但当前(2021.10)正在使用的时区有超过 300 个。每一个时区都包含了其特定的历史。虽然有些时区在现在看起来是一致的,但它们都包含了不同的历史。时区也会创造新的历史。由于政治、经济或其他原因,一些时区会调整它们与 UTC 的偏差(萨摩亚曾经从 UTC-10 切换到 UTC+14,导致该国 2011.12.30 整一天都消失了),或是启用/取消夏令时,甚至有可能导致一个时区重新划分为两个。因此,为了正确处理各个时区,我们需要一个数据库来存放时区变更信息。还好,已经有人帮我们做了这些工作。目前大多数 *nix 系统和大量开源项目都在使用 IANA 维护的时区数据库(IANA TZ Database),其中包含了自 Unix 时间戳 0 以来各时区的变更信息。当然这一数据库也包含了大量 Unix 时间戳 0 之前的时区变更信息,但并不能保证这些信息的准确性。IANA 时区数据库会定期更新,以反映新的时区变更和新发现的历史史实导致的时区历史变更。

Windows 不使用 IANA 时区数据库。微软为 Windows 自己维护了一套时区数据库,这有时会导致在一个系统上合法的时间在另一系统上不合法。

既然我们不能使用 UTC 偏移来表示一个时区,那就只能为每个时区定义一个标准名称。通常地,我们使用 <大洲>/<城市> 来命名一个时区。这里的城市一般为该时区中人口最多的城市。于是,我们可以将中国的通行时区表示为 Asia/Shanghai。也有一些时区有自己的别名,如太平洋标准时间 PST 和协调世界时 UTC

时区名称使用城市而非国家,是由于国家的变动通常比城市的变动要快得多。

城市不是时区的最小单位。有很多城市同时处于多个时区,甚至澳大利亚有一个机场的跑道两端处于不同的时区。

处理时区困难重重

几个月前的一天,奶冰在他的 Telegram 频道里发了这样的一条消息:

奶冰频道的消息

你想的没错,这个问题正是由时区与 UTC 偏移的不同造成的。Asia/Shanghai 时区在 1940 年前后和 1986 年前后曾实行过夏令时,而夏令时的切换会导致一小时的出现和消失。具体来说,启用夏令时当天会有一个小时消失,如 2021.3.28 英国启用夏令时,1:00 直接跳到 3:00,导致 2021-03-28 01:30:00Europe/London 时区中是不合法的;取消夏令时当天又会有一个小时重复,如 2021.10.31 英国取消夏令时,2:00 会重新跳回 1:00 一次,导致 2021-10-31 01:30:00Europe/London 时区中对应了两个时间点。而在奶冰的例子中,1988-04-10 00:46:50 正好处于因夏令时启用而消失的一小时中,因此系统会认为此时间字符串不合法而拒绝解析。

你可能会注意到在历史上 1988.4.10 这一天 Asia/Shanghai 时区实际上是去掉了 1:00-2:00 这一小时而不是 0:00-1:00。上文问题更深层次的原因是,在 IANA TZDB 2018a 及更早版本中,IANA 因缺乏历史资料而设置了错误的夏令时规则,规则设定了夏令时交界于 0:00-1:00 从而导致上文问题发生。而随后社区发现了更准确的史实,因此 IANA 更新了数据库。上文的问题在更新了系统的时区数据库后便解决了。

IANA TZDB 2018a 及之前版本的错误数据

IANA TZDB 2018a 及之前版本的错误数据

再来考虑另一种情况。你的应用的某位巴西用户在 2018 年保存了一个未来时间 2022-01-15 12:00(按当时的规律那应该是个夏令时时间),不巧那时候你的应用是以格式化的时间字符串形式保存的时间。之后你发现巴西已经于 2019 年 4 月宣布彻底取消夏令时制度,那么 2022-01-15 12:00 这个时间对应的 Unix 时间戳发生了变化,变得不再准确,要正确处理这一字符串就需要参考这一字符串生成的时间(或生成时计算的 UTC 偏移)来做不同的处理。因此,应用从一开始就应该避免使用字符串来传输、存储时间,而是使用 Unix 时间戳。如果不得不使用字符串存储时间,请尽可能:

  • 使用 UTC 描述时间,你永远不会知道本地时区在未来会发生什么
  • 如果需要以当地时间描述时间,一定带上当前 UTC 偏移

时区历史带来的问题往往意想不到而且远比想象得多。实际上时区历史数据非常详细而繁多且跨设备不一致,并没有简单而统一的处理方法。在需要严谨处理时区时可能需要在应用程序中内嵌一套各端统一的时区数据库,但这样的方案放在前端又会带来不少问题:

  • 体积过大。moment.js 曾经设计过一种简洁的 TZDB 表示,但尽管已经尽可能压缩整个文件仍然达到了 180+KB。在性能优先的 Web 应用中这是不可接受的
  • 需要持续更新。时区数据一直在变动,需要在时区数据更新时尽快更新应用内的时区数据,这带来了额外的维护成本

ES6 为我们带来了 Intl 命名空间。在这里,JavaScript 运行时提供了不少时间相关的国际化能力。因此,在不使用额外数据的情况下准确处理时区是可能的,但这并不完美:

  • 各端不统一。浏览器提供的时区数据受浏览器版本、系统版本等可能变化,最新的时区更新可能无法快速反映到所有设备上
  • 实现复杂。JavaScriptDate 对象的不良设计导致实现完善的时区处理并不容易,且 Intl 命名空间下的对象实例化性能开销较大,需要额外优化

Intl 命名空间下还有很多实用的国际化相关方法,值得我们另开一篇文章来讲讲了。

在真实开发中,这需要取舍。目前主流的 JavaScript 时间处理库都已转向浏览器内置方法,并在需要时通过 Polyfill 保证跨端一致性。在这篇文章中,我们将尝试在不使用第三方库的情况下实现基本的时间国际化处理。此外,还有一些诸如需要使用 Unix 时间戳才能正确地在各端交换时间等细节需要注意。

时区转换

JavaScript 中的 Date 并不是不包含时区信息——实际上,Date 对象表示的一定是当前时区。通过尝试:

new Date('1970-01-01T00:00:00Z')
// Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)

就可以知道,JavaScript 运行时其实知道当前时区,并会在需要的时候将其他时区的时间转换为当前时区的时间。那么,如何将本地时间转换为其他时区的时间呢?从 Date 的角度看,这并不行,因为我们无法设置一个 Date 对象的时区。但我们可以“投机取巧”:将 Date 对象的时间加上/减去对应的时差,尽管 Date 对象仍然认为自己在本地时区,但这样不就可以正确显示了嘛!但我们会碰到上文提到的问题:时区之间的时间差并不固定,在没有额外数据的情况下很难正确计算。

还好,ES6 基于 Intl 命名空间扩展了 Date.prototype.toLocaleString() 方法,使其可以接受时区参数并按指定时区格式化时间。如果你在搜索引擎中搜索如何使用 JavaScript 转换时区,你大概率会在 StackOverflow 上找到类似这样的答案:

const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-US', { timeZone }))
}

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中国标准时间)
convertTimeZone(now, 'Europe/London') // Tue Oct 12 2021 18:00:00 GMT+0800 (中国标准时间)

很好理解,我们使用 en-US 的区域设置要求 JavaScript 运行时以我们指定的时区格式化时间,再将时间字符串重新解析为时间对象。这里的 timeZone 就是诸如 Asia/Shanghai 等的 IANA TZDB 时区名称。这个字符串确实需要自己提供,但这就是我们唯一需要自己准备的数据了!只要提供了时区名称,浏览器就会自动计算正确的时间,无需我们自行计算。

对于时区名称,你可以考虑使用 @vvo/tzdb。这是一个声称为自动更新的 IANA TZDB 的 JSON 导出,并已被数个大型项目使用。你可以从这个包中导出所有时区名称。

这个方法看起来还不错,对吧?但实际上,它有两个问题:

  • 指定了区域设置和时区的 toLocaleString() 实际上每次调用都会在 JavaScript 运行时中创建新的 Intl.DateTimeFormat 对象(在后文详述),而后者会带来昂贵的性能开销(在 Node 14 中,实例化一次会在 V8 中增加内存使用约 46.3Kb。但这是符合预期的,详见 V8 Issue)。因此,在密集调用的情况下需要考虑计算并缓存时差,并在一定时间后或需要时进行更新
  • 使用 toLocaleString() 并使用 en-US 区域设置格式化的默认时间格式类似于 10/13/2021, 1:00:00 AM。这可以被大部分浏览器正确解析,但这是不规范的,不同浏览器有可能产生不同结果。你也可以自行配置格式(同下文的 Intl.DateTimeFormat),但仍然无法构造出规范的字符串

因此,更佳的方案是,我们需要建立一个可反复使用的格式化器以避免重复创建 Intl.DateTimeFormat 带来的额外开销,并需要手动构造出符合规范的时间字符串,并将其重新解析为 Date 对象。

const timeZoneConverter = (timeZone) => {
    // 新建 DateTimeFormat 对象以供对同一目标时区重用
    // 由于时区属性必须在创建 DateTimeFormat 对象时指定,我们只能为同一时区重用格式化器
    const formatter = new Intl.DateTimeFormat('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    })
    return {
        // 提供 conver 方法以将提供的 Date 对象转换为指定时区
        convert (date) {
            // zh-CN 的区域设置会返回类似 1970/01/01 00:00:00 的字符串
            // 替换字符即可构造出类似 1970-01-01T00:00:00 的 ISO 8601 标准格式时间字符串并被正确解析
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim())
        }
    }
}

const toLondonTime = timeZoneConverter('Europe/London') // 对于同一时区,此对象可重用

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中国标准时间)
toLondonTime.convert(now) // Tue Oct 12 2021 18:00:00 GMT+0800 (中国标准时间)

目前 zh-CN 的区域设置会产生类似 1970/01/01 00:00:00 的格式化字符串。这一格式目前跨端一致,但由于规范没有指定时间格式,这个格式在未来有可能变更。更好的方案是使用 formatToParts() 方法(在后文详述)获取时间字符串的各部分并手动拼接出标准格式的字符串,但在这个例子中直接 replace 拥有更好的性能。

现在,尝试反复转换时间至同一时区 1000 次,耗时从 toLocaleString() 1.5 秒降低到了 0.04 秒。尽管代码长了点,但这次重写在最好的情况下为我们带来了超过 20 倍的性能提升。

需要注意的是,虽然这看起来就算最终方案了,但这个方案依然不完美。主要有以下两个问题:

  • 在需要密集转换为不同时区时,由于无法重用格式化器,性能依然较差且难以进一步优化
  • 由于 Intl.DateTimeFormat 不支持格式化毫秒,在格式化字符串的过程中毫秒会丢失,导致最终结果可能会与期望结果产生最高 999ms 的误差,需要额外处理。比如需要计算时差时,我们可能需要这么写:
    const calcTimeDiff = (date, converter) => {
        const secDate = date - date.getMilliseconds() // 去掉毫秒,避免转换前后精度差异
        return converter.convert(new Date(secDate), tzName) - secDate
    }
    
    calcTimeDiff(new Date(), timeZoneConverter('Europe/London')) // -25200000

无论如何,在折腾一番后我们还是把时区正确转换了。接下来准备格式化时间字符串了吗?不过在此之前,我们得先来聊聊语言、文字和区域。

语言文字区域傻傻分不清

如何在计算机中表示中文?

“这不简单,”你可能会说,“用 zh 啊。”

那简体中文呢?

zh-CN。”你或许会说出这个答案。

那用于新加坡的简体中文和用于中国大陆的简体中文该如何区分呢?

嗯……好问题。

要能正确区分不同的简体中文,我们还得先回到定义上。实际上,“国际化”并不只是语言的翻译而已,国际化包含的是一整套对于各个区域的本地化方案。要准确表示一个国际化方案,我们实际至少需要确定三个属性:语言(Language)、文字(Script)和区域(Locale)。

  • 语言通常指的是声音语言。不同的语言都有一套自己的发音规则,很难互通。如中文和英语都属于语言
  • 文字对应的是某个语言的书写方式,同样的语言可能会有多种书写方案。如中文主要有简体和繁体两种书写方案
  • 区域指国际化面向的地区,相同的语言和文字,在不同地区也有可能会有不同的使用习惯。如新加坡和中国大陆都使用简体中文,但两地的用词习惯等有些许差异

只有确定了这三个属性,我们才能正确定义一个国际化方案(或者说区域设置)。当然,还有很多其他属性可以更准确的表达某个区域设置,但通常有语言、文字和区域就已经足够了。

于是,基于 BCP 47,我们可以知道:

cmn-Hans-CN = 中文普通话-简体-中国大陆
cmn-Hans-SG = 中文普通话-简体-新加坡
cmn-Hant-TW = 中文普通话-繁体-台湾
yue-Hant-HK = 中文粤语-繁体-香港

等等,这都是啥?还有 BCP 47 又是啥?BCP 是 IETF 发布的“最佳当前实践”文档,而 BCP 47 是一些国际化相关的 ISO 和备忘录的集合,也是目前事实上由 HTML 和 ECMAScript 所使用的表达区域设置的标准。BCP 47 定义的区域设置标签实际上比较复杂,但对于大部分简单使用情况,上文示例中的格式已经完全够用了。简单来说,要表达一个区域设置,我们会使用 语言[-文字][-区域] 的格式,而文字和区域都是可选的。而对于每个部分的具体代码,BCP 47 也有做具体定义。其中:

  • 语言使用 ISO 639-1 定义的两位字母代码(如中文为 zh,英文为 en)或 ISO 639-2/3 定义的三位字母代码(如中文普通话为 cmn,英文为 eng),通常小写
  • 文字使用 ISO 15924 定义的四位字母代码,通常首字母大写。如简体中文是 Hans,繁体中文是 Hant
  • 区域通常使用 ISO 3166-1 定义的两位字母代码,通常大写,如中国大陆为 CN,英国为 GB

ISO 639-1/2/3 的关系实际是:ISO 639-1 是最早制定的规范,使用两位字母表示语言,但语言数量之多并不能只用两位代码表示。因此后来修订了 ISO 639-2 和 3,使用三位字母表示了更多语言。通常 639-1 代码和 ISO-2/3 代码是一对多的关系。如中文 zh 其实是中文普通话 cmn 的宏语言(macrolanguage),同样使用 zh 为宏语言的语言还有 wuu(中文吴语)、hak(中文客家话)、yue(中文粤语)等数十种。从规范上我们现在应该使用 ISO 639-2/3 代码来替代 ISO 639-1 代码了,但由于历史阻力和真实需求中分类无需如此细致等原因,使用 ISO 639-1 指定语言仍然非常常见而且完全可以接受。此外,特别地,我们在 ISO 639-3 中定义未指明的语言为 und

因此,对于这一节开头的两个问题,在 BCP 47 中正确答案其实是:

zh = 中文
cmn = 中文普通话

zh-Hans = 中文-简体
cmn-Hans = 中文普通话-简体

zh-CN 实际是指在中国大陆使用的中文,当然也包含在中国大陆使用的繁体中文。不过,由于大部分情况下一个区域只会通用一种文字,很多情况下我们可以忽略文字这一项,即使用 zh-CN(或者 cmn-CN)来表示中国大陆的简体中文普通话——毕竟在大部分业务中在中国大陆使用繁体和非普通话的情况非常少。

事实上,类似 zh-Hanszh-Hant 开头的区域设置名称已经被标记为 redundant 废弃,因此尽可能只使用 zh-CN 或者 cmn-Hans-CN 这样的区域设置名称。所有区域设置名称的列表可以在 IANA 找到。

现在我们可以准确定义一个区域设置了。不过我们还有一些小小的需求。比如我们想在 cmn-Hans-CN 的区域设置中使用农历来表示日期,但显然我们上文定义的表示方法并不能表达这一需求。好在,Unicode 为 BCP 47 提供了 u 扩展。在区域设置名称后面加上 -u-[选项] 就可以表达更细致的变体了。所以我们有:

cmn-Hans-CN-u-ca-chinese = 中文普通话-简体-中国大陆-u-日历-中国农历
jpn-Jpan-JP-u-ca-japanese = 日语-日文汉字/平假名/片假名-日本-u-日历-日本日历
cmn-Hans-CN-u-nu-hansfin = 中文普通话-简体-中国大陆-u-数字-简体大写数字

u 扩展的具体可选项可以在 Unicode 网站上找到。而多个 u 扩展还可以连接——于是我们甚至可以写出 cmn-Hans-CN-u-ca-chinese-nu-hansfin 这种丧心病狂的区域设置名称。当然,相信你现在已经可以看懂这个区域设置的意思了。

不同地区可能会有不同的日历使用习惯,如中国有使用农历的需求,泰国有使用佛历的需求,我们可以通过 u 扩展指定不同的日历。不过,大部分情况下我们会使用标准的 ISO 8601 日历(gregory),JavaScript 的 Date 对象也只支持这种日历。

你可以使用 BCP47 language subtag lookup 工具快速检查你编写的 BCP 47 区域标签是否规范。

终于我们可以正确表达一个完美符合我们需求的区域设置了。接下来,让我们开始格式化时间吧。

格式化时间

这题我会!

const formatDate(date) => {
    return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${`${date.getDate()}`.padStart(2, '0')} ${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}:${`${date.getSeconds()}`.padStart(2, '0')}`
}

formatDate(new Date()) // 2021-10-13 01:00:00

就完事了……吗?先不论这样的格式化代码难以阅读,尽管上文这样的日期格式国际通用,但并非所有区域都习惯于这样的日期表示方法。比如英语国家/地区在很多时候习惯在日期中加入星期,而阿拉伯语国家/地区在部分情况下习惯使用阿拉伯语数字(而非常用的阿拉伯-印度数字);再比如美式英语国家/地区习惯月-日-年的日期表示法,而英式英语国家/地区习惯日-月-年的日期表示法……不同区域在时间表示格式习惯上的差异是巨大的,我们很难通过一个简单的方法来正确地、国际化地格式化一个日期。

好在 ES6 早就为我们铺平了道路。还记得上文提到过的 Intl.DateTimeFormat 吗?我们通过它来实例化一个日期格式化器并用进行日期的国际化。

直接来看例子吧:

const options = {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long'
}
const now = new Date()

const enUSFormatter = new Intl.DateTimeFormat('en-US', options)

const zhCNFormatter = new Intl.DateTimeFormat('zh-CN', options)
const zhCNAltFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', options)
const zhCNAlt2Formatter = new Intl.DateTimeFormat('zh-CN-u-ca-roc-nu-hansfin', options)

const jaFormatter = new Intl.DateTimeFormat('ja', options)
const jaAltFormatter = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', options)

const arEGFormatter = new Intl.DateTimeFormat('ar-EG', options)

enUSFormatter.format(now) // Wednesday, Oct 13, 2021

zhCNFormatter.format(now) // 2021年10月13日星期三
zhCNAltFormatter.format(now) // 2021辛丑年九月8星期三
zhCNAlt2Formatter.format(now) // 民国壹佰壹拾年拾月拾叁日星期三

jaFormatter.format(now) // 2021年10月13日水曜日
jaAltFormatter.format(now) // 令和3年10月13日水曜日

arEGFormatter.format(now) // الأربعاء، ١٣ أكتوبر ٢٠٢١

在这里我们使用 ISO 639-1 代码来表示语言,是由于事实上 ISO 639-1 代码更加常见与通用。在大部分支持 Intl.DateTimeFormat 的 JavaScript 运行时中我们也可以使用 ISO 639-2/3 代码来表示语言(但实际会 fallback 至对应的 ISO 639-1 代码)。

你也可以通过在 options 中设置 calendar 属性和 numberingSystem 属性来替换区域设置名称中对 u 扩展的使用。这也是推荐方式。

这非常直观,我们可以指定区域设置和格式化选项来初始化一个格式化器,并在之后使用格式化器对象的 format 方法来格式化一个 Date 对象。这里的格式化选项其实非常灵活,能格式化的不只是日期,时间也可以被灵活地格式化,有非常多的组合可以选择。我们不会在这里详细解释每一个选项,你可以访问 MDN 文档来了解更多。

如前文所述,Intl.DateTimeFormat 无法格式化毫秒。

不过需要注意的是,JavaScript 运行时不一定支持所有区域设置,也不一定支持所有格式化选项。在遇到不支持的情况时,Intl.DateTimeFormat 默认会静默 fallback 到最匹配的支持项,因此在处理不常见的区域设置或选项时,你可能需要再额外检查。你可以通过 Intl.DateTimeFormat.supportedLocalesOf() 静态方法判断当前运行时是否支持指定的区域设置,也可以在实例化格式化器后在对象上调用 resolvedOptions() 方法来检查运行时的解析结果是否与预期一致。

new Intl.DateTimeFormat('yue-Hant-CN').resolvedOptions()
// {locale: 'zh-CN', calendar: 'gregory', …}
// fallback 至 zh-CN,与 yue-CN 的预期不一致

此外,正如你所看到的,各种语言在日期格式化中使用的文本 JavaScript 运行时都已经帮我们内置了。因此,我们甚至可以利用这些国际化特性来为我们的应用减少一点需要翻译的字符串——打包进应用的翻译越少,应用体积也就越小了嘛——比如说获取一周七天对应的名字:

const getWeekdayNames = (locale) => {
     // 基于一个固定日期计算,这里选择 1970.1.1
     // 不能使用 0,因为 Unix 时间戳 0 在不同时区的日期不一样
    const base = new Date(1970, 0, 1).getTime()
    const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })
    return Array.from({ length: 7 }, (_, day) => (
        formatter.format(new Date(base + 3600000 * 24 * (-4 + day))) // 1970.1.1 是周四
    ))
}

getWeekdayNames('en-US') // ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
getWeekdayNames('zh-CN') // ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
getWeekdayNames('ja') // ['日', '月', '火', '水', '木', '金', '土']
getWeekdayNames('ar-EG') // ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']

当然,如果你还是不喜欢运行时为你提供的格式,我们还有上文提到过的 formatToParts() 方法可以用。来看一个简单的例子吧:

new Intl.DateTimeFormat('zh-CN', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long',
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
}).formatToParts(new Date())
// [
//     { type: 'year', value: '2021' },
//     { type: 'literal', value: '年' },
//     { type: 'month', value: '10' },
//     { type: 'literal', value: '月' },
//     { type: 'day', value: '13' },
//     { type: 'literal', value: '日' },
//     { type: 'weekday', value: '星期三' },
//     { type: 'literal', value: ' ' },
//     { type: 'dayPeriod', value: '上午' },
//     { type: 'hour', value: '1' },
//     { type: 'literal', value: ':' },
//     { type: 'minute', value: '00' },
//     { type: 'literal', value: ':' },
//     { type: 'second', value: '00' }
// ]

随后,你就可以自己解析这个数组来构造出你想要的时间格式了。最后,我们还可以使用 Intl.RelativeTimeFormat 来格式化相对日期。当然我们不会在这里详细讲解这个 API,你可以参考 MDN 文档。直接来看一个简单例子吧:

const getRelativeTime = (num, unit, locale) => {
    return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(num, unit)
}

getRelativeTime(-3, 'day', 'en-US') // 3 days ago
getRelativeTime(-1, 'day', 'zh-CN') // 昨天
getRelativeTime(0, 'second', 'zh-CN') // 现在
getRelativeTime(3, 'hour', 'ja') // 3 時間後

Intl.RelativeTimeFormat 是一个相对较晚进入标准的对象,因此浏览器支持程度较差,可能需要使用 Polyfill。不过目前(2021.10)主流浏览器的最新版本均已支持此 API。

未来

我希望这篇文章时区转换的部分可以很快过时——这并非无稽之谈,目前(2021.10)TC39 的 Temporal 提案已经进入 Stage 3 了。Temporal 提案定义了一个新的、时区友好的 Temporal 命名空间,并期望在不久后就能进入标准并最终应用于生产环境。Temporal 定义了完整的时区、时间段、日历规则的处理,且拥有简单明了的 API。那时候,JavaScript 的时区处理就不会再如此痛苦了。由于目前 Temporal 提案还未进入标准,API 暂未稳定,我们无法将其用于生产环境,但我们可以来看一个简单的例子感受一下这个 API 的强大。

const zonedDateTime = Temporal.ZonedDateTime.from({
  timeZone: 'America/Los_Angeles',
  year: 1995,
  month: 12,
  day: 7,
  hour: 3,
  minute: 24,
  second: 30,
  millisecond: 0,
  microsecond: 3,
  nanosecond: 500,
  calendar: 'iso8601'
}) // 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]

如果你希望立刻开始使用 Temporal,现在已有 Polyfill 可用。

不过,时区问题不会消失,各地区的习惯也很难融合到一起。时间的国际化处理是极其复杂的,前端中的时间国际化仍然值得我们认真关注。

]]>
https://flyhigher.top/develop/2482.html/feed 5
谈谈 WebAuthn https://flyhigher.top/develop/2160.html https://flyhigher.top/develop/2160.html#comments Tue, 08 Jul 2025 18:38:49 +0000 https://flyhigher.top/?p=2160 WebAuthn,即 Web Authentication,是一个用于在浏览器上进行认证的 API,W3C 将其表述为 "An API for accessing Public Key Credentials",即“一个用于访问公钥凭证的 API”。WebAuthn 很强大,强大到被认为是 Web 身份认证的未来(当然,也很复杂)。你有想过通过指纹或者面部识别来登录网站吗?WebAuthn 就能在保证安全和隐私的情况下让这样的想法成为现实。

通过 WebAuthn 调起 Windows Hello 登录网站

你可以在这个网站自行体验 WebAuthn。或者,如果你有一个使用 WordPress 的站点,可以尝试一下 WP-WebAuthn 这款插件,它可以为你的站点启用 WebAuthn 登录。这是我为了写这篇文章而练手开发的插件,本文中的部分示例代码也来自这款插件。

WebAuthn Level 1 标准已在 2019 年 3 月成为 W3C 推荐标准,而 Level 2 标准目前也已进入编辑草案阶段,但互联网上有关它的中文资料却仍然很少,国内也鲜有网站支持这一新标准。于是,在抱着各种英文资料和标准啃了几天以后,我折腾出了这篇文章。

在这篇文章里,我将会从最基本的概念开始,逐渐深入 WebAuthn,直到解码公钥等深层细节。这可能是你能找到的有关 WebAuthn 最详细最基础的中文文章,也很可能是最长的。在开始之前,我强烈建议你打开菜单中的文章目录浏览一遍,以对这篇文章涉及的内容有所了解。如果你只是想了解如何简单地在你的项目中添加对 WebAuthn 的支持,那么“浅谈 WebAuthn”部分就是你要找的;如果你想了解更多关于 WebAuthn 的底层细节,那么你可以继续阅读“深入了解 WebAuthn”部分。

由于在本文写作时 WebAuthn Level 2 规范尚未定稿,这篇文章我们将只专注于 Level 1 规范。

这篇文章最后更新于 2020.9 ,在你读到这篇文章时,部分事实可能已经发生改变,请注意判断。

更新日志:

  • 2021.1.16 添加 Chrome 开发工具信息
  • 2020.9.17 更新 iOS/iPad OS 兼容性信息
  • 2020.5.20 添加部分无用户名认证的平台兼容性内容
  • 2020.5.15 添加部分 extensions 相关内容与链接;添加全局凭证 ID 唯一内容;添加凭证 ID 查找相关内容;添加“从 U2F 认证迁移”一节
  • 2020.4.13 更新与修正 userVerification 相关内容;添加部分 Firefox 相关内容
  • 2020.3.23 文章发布

那么让我们开始吧。

浅谈 WebAuthn

在这个部分里,我们将会从基础概念开始,了解有关 WebAuthn 和密码学的一些基础知识,并最终使用第三方库实现基础的 WebAuthn 认证。如果你已经对这些内容有所了解了,可以跳到“深入了解 WebAuthn”继续阅读。

为什么使用 WebAuthn

钓鱼邮件

相信你一定收到过类似的邮件吧?只要你点进那个最显眼的链接,你就会进入一个设置好的圈套——钓鱼网站。如果你一时糊涂在这类网站上填写了你的账号和密码,bingo,你的账号就不再是你的账号了。

不过,就算你警惕心再强,也无法避免密码泄露事件。Twitter, Facebook 等都爆出过明文密码泄露事件,证明再大的公司或组织也无法避免密码泄露问题。雪上加霜的是,很大一部分用户都非常喜欢使用重复密码,这就导致一次密码泄露会牵连很多网站,用户的账户安全性完全无法得到保证。

那么,有什么办法解决这些问题吗?彻底解决的方法只有一个,那就是抛弃密码。可是没有密码还怎么验证用户身份呢?这就是 WebAuthn 的用武之地了。

没有密码的好时代,来临力!

什么是 WebAuthn

那么到底什么是 WebAuthn 呢?如开头所说,WebAuthn 是“一个用于访问公钥凭证的 API”,网站可以通过这个 API 进行一些高安全性的身份验证。WebAuthn 一个最常见的应用就是用于网站登录时的 2FA(双重因素验证)甚至是无密码登录。通过网页调用 WebAuthn,在不同平台下,我们可以实现通过 USB Key、指纹、面部甚至虹膜扫描来认证身份,同时确保安全和隐私。

WebAuthn 可使用的各种身份认证方式

WebAuthn 标准是 FIDO2 标准的一部分,而 FIDO2 则是由 FIDO 联盟和 W3C 共同推出的 U2F(现称作 FIDO1)的后继标准,旨在增强网络认证的安全性。

你可能了解过 U2F,那么 U2F 和 FIDO2 的区别在哪里呢?从名字上可以看出,U2F,即“通用第二因素协议”,是专注于作为密码后的第二道屏障的,而 FIDO2 增加了单因素认证功能,这意味着使用 FIDO2 可以完全替代密码,真正实现无密码登录。

FIDO2 标准主要包括四个部分,其一是用于网站和访客设备交互的 WebAuthn,而 Client to Authenticator Protocol 2(CTAP2,客户端-认证器协议)作为 WebAuthn 的补充,则是用于访客的设备和认证器交互的协议。标准的其他两个部分则是 U2F 和 UAF 规范。在这篇文章中,我们只关心 WebAuthn,不会涉及 CTAP, U2F 和 UAF 的相关知识。如果你对这段话中的一些概念不了解,不要紧张,接下来我们就来谈谈 WebAuthn 中一些常用的术语和概念。

WebAuthn 只能在安全上下文中使用,也就是说,页面需要使用 HTTPS 协议或是处于 localhost 中。

常用术语和概念

WebAuthn 中有许多不常碰到的术语,不过我只会在这里介绍一些常用的术语和概念——如果你只是希望做出一个简单的实现,那么了解这一段中的一些概念就足够了。同时,由于我实在没能找到一部分术语的通用翻译,有一些术语我按着自己的理解尽可能地翻译了,有任何问题请告诉我。

在一个完整的 WebAuthn 认证流程中,通常有这么几个角色:

  • Relying Party 依赖方 (RP) :指服务提供方,即网站
  • User 用户:正准备登录的你
  • Authenticator 认证器:通常指 USB Key 或是设备内置的指纹扫描器、虹膜扫描器、面部识别装置等,正是它们在使用流程中代替了密码甚至是用户名
  • User Agent 用户代理:通常指浏览器或系统,负责与认证器交互

认证过程通常分为两种:

  • Registration Ceremony 注册仪式:用户向账户上添加认证器
  • Authentication Ceremony 验证仪式:用户通过已注册的认证器验证身份

同时,认证过程中还会产生这些内容:

  • Challenge 挑战:通常是一串随机字符串
  • Public Key Credential 公钥凭证:由认证器产生的凭证,在技术上代替了密码
  • Attestation 证明:注册时认证器产生的验证数据
  • Assertion 断言:验证时认证器产生的验证数据

请注意区分证明 (Attestation) 和断言 (Assertion),特别是在这两个单词有些相似的情况下。在 WebAuthn 中,它们是不同过程中的类似概念,但并不相同。

如果你对于这些内容不是很理解,没有关系,我们会在“使用流程”一节中将这些概念放到实际情况中解释,你只要先区分这些概念即可。

安全性的基础

WebAuthn 为什么安全?和 HTTPS 一样,WebAuthn 使用非对称加密的思路来保证安全性,但侧重点又有所不同。要理解 WebAuthn 的认证流程,我们必须对非对称加密有所了解。如果你对非对称加密比较熟悉了,就直接跳到下一段吧。

WebAuthn 的核心在于“认证”,即保证产生了凭证的认证器是用户的认证器,而不是第三方在伪造凭证。但为什么要使用非对称加密呢?我们可以先来看看对称加密。

让我们假设小明和小红相互写信,见不到对方。这天小红突然想,给自己写信的人是不是真的小明呢?于是小红要求对方向小红证明自己是小明。怎么证明呢?他们商量出来这么一套方案:

  1. 在小明的身份已经验证的情况下,小明和小红商量一个统一的密码和密钥
  2. 一段时间后,在小红要求小明验证身份时,小红发送一段文本给小明
  3. 小明用提前和小红商量好的密码和密钥加密文本后发回给小红
  4. 小红使用相同的密码和密钥解密文本,如果得到文本和之前发送的一致,就表明对方拥有正确的密码和密钥

在密码学中,我们通常把“加密算法”称为密码 (code),和通常登录时用的密码 (password) 并非同一个概念。

在这种情况下,只要密钥不泄露,即拥有正确密钥的只有小明和小红,那么小红就能确定对方一定是小明。但这样的问题也很明显,如果小明和小红不能见面,那么他们就必须先以明文交换密码——在这个过程中一旦有人窃取到了密码,这个认证就无效了。

这时我们就需要使用非对称加密来拯救破碎的信任了。非对称加密的基础是一对密钥,也就是公钥和私钥,它们是互相关联但(几乎)无法互相推导的。在非对称加密的过程中,选择相同的密码后,使用私钥加密明文得到的密文只能由对应的公钥解密,无法使用私钥解密,同时这个过程反过来也是成立的。

本文的核心并不是非对称加密算法,因此不会详述非对称加密在数学上的证明,若有兴趣可以自行了解。

这就完美解决密钥泄露的问题了。让我们回过头来看之前的例子。现在小明和小红决定使用非对称加密来证明身份,过程如下:

  1. 在小明的身份已经验证的情况下,小明提前生成一对公私钥,将公钥发送给小红,私钥自己保管,同时商量好统一的密码
  2. 一段时间后,在小红要求小明验证身份时,小红发送一段文本给小明
  3. 小明使用商量好的密码和自己的私钥加密文本,发送给小红
  4. 小红使用相同的密码和小明的公钥解密文本,如果得到文本和之前发送的一致,就表明对方拥有正确的密码和私钥

在这种情况下,只要私钥不泄露,那么小红解密得到的文本和之前发送的一致,那么就可以证明收到的密文一定是由小明加密后发送过来的。而在整个过程中,私钥都没有离开过小明,更没有经过传输,也就几乎没有泄露的可能了。这时,小红终于可以确定对面和她通信的人的确是小明了。

题外话:HTTPS 虽然也应用了非对称加密,但 HTTPS 更侧重于“加密”而非“认证”,因此思路上会有些许差异。要了解关于 HTTPS 和非对称加密的更多信息,你可以阅读我之前写的这篇文章:

使用流程

了解了非对称加密,我们就可以来看看 WebAuthn 的认证流程了。

和普通的密码一样,使用 WebAuthn 分为两个部分,注册和验证。注册仪式会在依赖方中将认证器的一些信息和用户建立关联;而验证仪式则是验证这些信息以登确保是用户本人在登录。根据上一节的思路,我们可以知道,注册仪式就是认证器生成一对公私钥,然后将公钥交给依赖方;而验证仪式是依赖方发送给认证器一段文本,要求认证器用自己的私钥加密后发回以验证。

在实际情况中,WebAuthn 是基于挑战-应答模型工作的。要更好地理解,我们直接来看具体流程。先来看看注册的流程。

WebAuthn 注册流程
  1. 浏览器向依赖方发送某个用户的注册请求
  2. 依赖方向浏览器发送挑战、依赖方信息和用户信息
  3. 浏览器向认证器发送挑战、依赖方信息、用户信息和客户端信息以请求创建公钥凭证
  4. 认证器请求用户动作,随后创建一对公私钥,并使用私钥签名挑战(即证明),和公钥一起交给浏览器
  5. 浏览器将签名后的挑战和公钥发送给依赖方
  6. 依赖方用公钥验证挑战是否与发送的一致,如果成功则将公钥与用户绑定,注册完成

我也做了一个交互式的 Demo,你可以对照流程来更好地理解。

注意,这个 Demo 和下方的 Demo 都只是一个本地模拟的简单示例,没有任何数据会被上传,当然也不会对数据进行验证。

而之后的验证流程如下:

WebAuthn 验证流程
  1. 浏览器向依赖方发送某个用户的验证请求
  2. 依赖方向浏览器发送挑战
  3. 浏览器向认证器发送挑战、依赖方信息和客户端信息以请求获取公钥凭证
  4. 认证器请求用户动作,随后通过依赖方信息找到对应私钥,并使用私钥签名挑战(即断言),交给浏览器
  5. 浏览器将签名后的挑战发送给依赖方
  6. 依赖方用之前存储的公钥验证挑战是否与发送的一致,一致则验证成功

在上方注册后才能验证

可以看到,WebAuthn 不仅在理论上是安全的,同时在整个过程中并没有隐私数据被传输——用户信息实际上只包含用户名和用户 ID。因此我们完全可以说 WebAuthn 是安全且私密的。

为了避免用户在不同依赖方之间被追踪,认证器通常会为每个依赖方和用户的组合都创建一对公私钥。不过,由于认证器的存储空间有限,认证器通常不会存储每一个私钥,而是会通过各类信息和烧录在认证器内的主密钥“算”出对应的私钥以实现无限对公私钥。具体算法根据不同厂商会有所不同。对于 Yubikey,你可以在这里了解更多。

如果依赖方需要,用户同意后,发送给依赖方的公钥凭证中可以包含用于辨认认证器型号的信息,不过这对隐私的影响微乎其微。

浏览器接口

要使用 WebAuthn,我们必须要依靠浏览器作为媒介和验证器进行交互,而这就需要浏览器对于 WebAuthn 的支持了。绝大多数新版本的现代浏览器都为 WebAuthn 提供了统一的接口,而在这一段中我们会了解如何使用相关的接口。但是在开始之前,我们可以先来看看浏览器的支持程度(2020.9)。

浏览器支持情况-----桌面端 Chrome67+-----移动端 Chrome67+[1]-----桌面端 Firefox60+-----移动端 Firefox不支持[2]-----桌面端 Edge18+-----移动端 Edge不支持[3]-----桌面端 Safari13+-----移动端 Safari13.3+[4]-----桌面端 Opera54+-----移动端 Opera不支持

[1] 受平台限制,Chrome 在 iOS 平台上不支持 WebAuthn,在 Android 平台上支持大部分 WebAuthn 功能,但仍不支持部分特性(如 userVerification)。
[2] 移动端 Firefox 80 以下的版本支持 WebAuthn 但似乎会忽略 authenticatorAttachment 等一部分参数,同时移动端 Firefox Beta 80 以下的版本支持 WebAuthn 但无法成功调用。自 80 版本起移动端 Firefox 暂时取消了对 WebAuthn 的支持(可能是 Bug)。
[3] 移动端 Edge 似乎支持 WebAuthn 但无法成功调用。
[4] Safari iOS/iPad OS 13 仅支持外部认证器,无法调用 Touch ID 或 Face ID;自 iOS/iPad OS 14 起 Safari 已支持全功能 WebAuthn,可以调用 Touch ID/Face ID

当然,一众国产浏览器,包括 Samsung Browser 和 Yandex Browser,目前都不支持 WebAuthn。此外,由于 WebAuthn 涉及外部验证器和 TPM 可信平台模块等,用户的操作系统也会对 WebAuthn 的可用性造成影响。以下是一些需要注意的信息:

  • Windows 10 1903 以下版本仅 Edge 能提供完整支持,其他浏览器只能使用 USB Key 等外部认证器;1903+ 中所有浏览器都可以通过 Windows Hello 带来完整的 WebAuthn 支持
  • Android 需要安装并开启 Google 服务
  • iOS/iPad OS 13.3 以下的版本不支持 WebAuthn,iOS/iPad OS 14 以下的版本支持有限(参考上文),iOS/iPad OS 14 开始 Safari 已支持全功能 WebAuthn(功能完整度甚至超过了 Android)

可以看出,WebAuthn 的发展之路仍然很漫长,但好在桌面端对它的支持已经比较完善了,在一些情况下我们完全有理由使用它。


来看看浏览器提供了怎么样的接口吧。要使用 WebAuthn,我们可以使用 navigator.credentials.create() 请求认证器生成公钥凭证和 navigator.credentials.get() 请求获取公钥凭证。

你或许已经对 Credential Management API 有所了解了。通过这些 API,我们还可以实现一些有趣的特性,不过这值得另开一篇文章来讲了。

对于一个基础的实现,navigator.credentials.create() 需要传入的参数如下:

navigator.credentials.create({
    publicKey: {
        challenge,
        rp: {
            id,
            name
        },
        user: {
            id,
            name,
            displayName
        },
        pubKeyCredParams: [
            {
                type: "public-key",
                alg
            }
        ],
        authenticatorSelection: {
            authenticatorAttachment,
            userVerification
        },
        excludeCredentials: [
            {
                id,
                transports: [],
                type: "public-key"
            }
        ],
        timeout
    }
})

navigator.credentials.create() 方法中,我们必须传入一个对象,其中只有一对名为 publicKey 的键值。这指明了我们需要创建公钥凭证,而非普通的密码凭证。然后,在 publicKey 对象中设置这些常用参数:

  • challenge: Uint8Array:转换为 Uint8Array 的挑战,长度至少为 16,建议为 32
  • rp: Object:依赖方信息,其中有一项为必须:
    • rp.id: String:(可选)依赖方 ID,必须为当前域名或为当前域名的子集的域名(不是子域名)。如域名为 test.123.example.com,则依赖方 ID 可以是 test.123.example.com, 123.example.comexample.com。不指定则默认使用当前域名
    • rp.name: String:依赖方名称,用于方便用户辨认
  • user: Object:用户信息,其中有三项为必须:
    • user.id: Uint8Array:转换为 Uint8Array 的字符串。出于安全考量,这应尽可能不与任何用户信息相关联,如不要包含用户名、用户邮箱等
    • user.name: String:登录用户名
    • user.dispalyName: String:用于显示的用户名称,显示与否的具体行为取决于浏览器
  • pubKeyCredParams: Array:一个算法列表,指明依赖方接受哪些签名算法。列表的每一项都是一个对象,拥有两个属性:
    • pubKeyCredParams[].type: String:值只能为 "public-key"
    • pubKeyCredParams[].alg: Number:一个负整数,用于标明算法。具体算法对应的数字可以在 COSE 找到
  • authenticatorSelection: Object:(可选)用于过滤正确的认证器,这里介绍常用的一个参数:
    • authenticatorSelection.authenticatorAttachment: String:(可选)指定要求的认证器类型。如果没有满足要求的认证器,认证可能会失败。该参数可以为 null(表示接受所有类型的认证器)或是以下两个值之一:
      • platform:表示仅接受平台内置的、无法移除的认证器,如手机的指纹识别设备
      • cross-platform:表示仅接受外部认证器,如 USB Key
    • authenticatorSelection.userVerification: String:(可选)指定认证器是否需要验证“用户为本人 (User Verified, UV)”,否则只须“用户在场 (User Present, UP)”。具体验证过程取决于认证器(不同认证器的认证方法不同,也有认证器不支持用户验证),而对验证结果的处理情况则取决于依赖方。该参数可以为以下三个值之一:
      • required:依赖方要求用户验证
      • preferred:(默认)依赖方希望有用户验证,但也接受用户在场的结果
      • discouraged:依赖方不关心用户验证。对于 iOS/iPad OS 13,必须设置为此值,否则验证将失败
  • excludeCredentials: Array:(可选)用于标识要排除的凭证,可以避免同一个用户多次注册同一个认证器。如果用户试图注册相同的认证器,用户代理会抛出 InvalidStateError 错误。数组中的每一项都是一个公钥凭证对象,包含以下属性:
    • excludeCredentials[].type: String:值只能为 "public-key"
    • excludeCredentials[].id: Uint8Array:要排除的凭证 ID
    • excludeCredentials[].transports: Array:(可选)用于指定该凭证所需的认证器与用户代理的通信方式,可以包含以下的一或多个字符串:
      • usb:可以通过 USB 连接的认证器
      • nfc:可以通过 NFC 连接的认证器
      • ble:可以通过蓝牙连接的认证器
      • internal:平台内置的、无法移除的认证器
  • timeout: Number:(可选)方法超时时间的毫秒数,超时后将强制终止 create() 并抛出错误。若不设置,将使用用户代理的默认值;若太大或太小,则使用最接近的用户代理默认值范围中的值。推荐值为 5000-120000

对于 pubKeyCredParams,通常我们只需添加 ES256 (alg: -7) 算法即可兼容大部分外部认证器,此外,再添加 RS256 (alg: -257) 算法即可兼容大部分平台内置认证器(如 Windows Hello)。当然,前端添加算法之后,后端也需要相应的算法支持。

对于 userVerification,由于默认值 "preferred" 并不能很好地被所有设备支持,因此无论在 create() 中还是 get() 中不指定该参数都会在 Chrome 中触发一条警告。具体请参阅这个页面

调用 create() 之后,我们就可以拿到一个 Promise,并可以在 then 中获得认证器返回的 PublicKeyCredential 对象。以下是一个 create() 返回的 PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},
    response: AuthenticatorAttestationResponse {
        attestationObject: ArrayBuffer(390) {},
        clientDataJSON: ArrayBuffer(121) {}
    },
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0",
    type: "public-key"
}

其中有:

  • id: String:Base64URL 编码的凭证 ID
  • rawId: ArrayBufferArrayBuffer 的原始凭证 ID
  • type: String:一定是 "public-key"
  • response: ObjectAuthenticatorAttestationResponse 对象,是 PublicKeyCredential 的主要部分,包含以下两个内容:
    • response.clientDataJSON: ArrayBuffer:客户端数据,包含 origin(即凭证请求来源)、挑战等信息
    • response.attestationObject: ArrayBuffer:CBOR 编码的认证器数据,包含凭证公钥、凭证 ID、签名(如果有)、签名计数等信息

然后将 ArrayBuffer 们以合适的方式编码成字符串,我们就可以把 PublicKeyCredential 发送给依赖方以供验证与注册了。具体怎么操作,我们会在下文详细讨论。当然,别忘了 catch() 注册过程中抛出的任何错误。

你可能会认为在所有情况下,注册时认证器都会对挑战进行签名。实际上在大部分情况下(同时也是默认情况),注册时认证器并不会对挑战进行签名attestationObject 并不会包含签名后的挑战。只有依赖方明确要求证明且用户同意(部分浏览器要求)后认证器才会对挑战进行签名(具体实现据情况会有所不同)。对此,MDN 解释道“大部分情况下,用户注册公钥时我们会使用「初次使用时信任模型」(TOFU) ,此时验证公钥是没有必要的。”要了解更多关于证明的内容,请参阅“验证认证器”一节。

而对于 navigator.credentials.get(),我们可以传入如下的参数:

navigator.credentials.get({
    publicKey: {
        challenge,
        rpId,
        userVerification,
        allowCredentials: [
            {
                id,
                transports: [],
                type: "public-key"
            }
        ],
        timeout
    }
})

create() 一样,对于 get() 我们需要传入一个对象,其中只有一对名为 publicKey 的键值,指明我们需要获取的是公钥凭证而非普通的密码凭证。在 publicKey 对象中我们可以设置这些常用参数:

  • challenge: Uint8Array:转换为 Uint8Array 的挑战,长度至少为 16,建议为 32
  • rpID: String:(可选)依赖方 ID,需要和注册认证器时的一致。规则和上述的 rp.id 一致,不指定默认使用当前域名
  • userVerification: String:和上文一样,只是需要注意它这次不在 authenticatorSelection 中了
  • allowCredentials: Array:(可选)用于标识允许的凭证 ID,使用户代理找到正确的认证器。只有符合这个列表中凭证 ID 的凭证才能被成功返回。数组中的每一项都是对象,包含以下属性:
    • allowCredentials[].type: String:值只能为 "public-key"
    • allowCredentials[].id: Uint8Array:允许的凭证 ID
    • allowCredentials[].transports: Array:(可选)用于指定该凭证所需的认证器与用户代理的通信方式,可以包含以下的一或多个字符串:
      • usb:可以通过 USB 连接的认证器
      • nfc:可以通过 NFC 连接的认证器
      • ble:可以通过蓝牙连接的认证器
      • internal:平台内置的、无法移除的认证器
  • timeout: Number:(可选)方法超时时间的毫秒数,和上面的一样,推荐值为 5000-120000

嗯,要传入的参数少多了。之后,和 create() 一样,调用 get() 之后,我们就可以拿到一个 Promise 并在 then 中获得认证器返回的 PublicKeyCredential 对象。以下是一个 get() 返回的 PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(37) {},
        signature: ArrayBuffer(256) {},
        userHandle: ArrayBuffer(64) {},
        clientDataJSON: ArrayBuffer(118) {}
    }
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0"
    type: "public-key"
}

这里的东西就比 create() 时拿到的要多了。看看我们拿到了什么吧:

  • id: String:Base64URL 编码的凭证 ID
  • rawId: ArrayBufferArrayBuffer 的原始凭证 ID
  • type: String:一定是 "public-key"
  • response: Object:对于验证流程,认证会返回 AuthenticatorAssertionResponse 而不是 AuthenticatorAttestationResponse 对象,这个对象包含以下 4 个属性:
    • response.authenticatorData: ArrayBuffer:认证器信息,包含认证状态、签名计数等
    • response.signature: ArrayBuffer:被认证器签名的 authenticatorData + clientDataHashclientDataJSON 的 SHA-256 hash)
    • response.userHandle: ArrayBuffercreate() 创建凭证时的用户 ID user.id。许多 U2F 设备不支持这一特性,这一项将会是 null
    • response.clientDataJSON: ArrayBuffer:客户端数据,包含 origin(即凭证请求来源)、挑战等信息

同样地,我们将 ArrayBuffer 们以合适的方式编码成字符串后就可以把 PublicKeyCredential 发送给依赖方以供验证了。至于具体怎么做,别急,马上就来讲一讲。

简单实现

了这么多,我们终于可以实现一个简单的 WebAuthn 认证页面了。由于在实际操作中 WebAuthn 相关的数据解码和密码计算比较复杂,在服务器端我们可以使用第三方库来帮我们做这些脏活累活,我们只需专注于具体流程就可以了。

要寻找可用的第三方库,你可以在 webauthn.io 上找到适用于各种语言的第三方库——除了 PHP(笑)。不过好在你可以在 GitHub 上找到几个不错的 PHP WebAuthn 库,比如 web-auth/webauthn-framework

在我们的这个例子中,我们关心的主要是前端逻辑;而后端我们可以使用各类几乎已经做到开箱即用的第三方库,这样我们可以专注于流程而不必关心细节。当然如果你想了解后端的解码细节,可以阅读“手动解个码”一节。


让我们先从注册开始吧。现在,用户点击了注册认证器的按钮,一个请求被发送给服务器(也就是依赖方)。在最简单的情况中,依赖方需要将三个内容发送给浏览器:挑战、用户信息和用户已注册的凭证 ID 列表(即 excludeCredentials。当然依赖方也可以自由选择发送更多信息,只要最终前端能构建合法的参数即可。

挑战最终会被转换为 Uint8Array,即一组 0-255 的整数。如果使用 PHP,在后端我们可以这样生成 Base64 编码的挑战:

$challenge = "";
for($i = 0; $i < 32; $i++){
    $challenge .= chr(random_int(0, 255));
}
$challenge = base64_encode($challenge);

对于用户信息,我们需要登录名、显示名称和 ID 三项内容。我们可以从数据库中取出用户信息,也可以新建一份。需要注意的是,出于安全和隐私的考量,ID 不应该包含用户的任何信息,比如用户邮箱等。推荐的做法是和挑战一样,生成一个随机字符串/一组随机数,并将其于用户关联起来以供之后使用。

发送已注册的凭证 ID 列表是为了防止用户重复注册同一个认证器。正确设置该列表后,如果用户试图注册同一个认证器,浏览器会中止流程并抛出 InvalidStateError

最后,别忘了将挑战等一些后续可能会用到的信息临时存储起来。Session 就是一个很好的选择。

将所有信息发送到浏览器之后,我们应该可以构建出新建凭证所需的参数了。由于有多个参数需要以 Uint8Array 的形式传入,我们可以准备一个简单的工具函数帮我们将 Base64 的字符串转为 Uint8Array

function str2ab(str){
    return Uint8Array.from(window.atob(str), c=>c.charCodeAt(0));
}

除了 challenge, rp, userexcludeCredentials 几部分需要你根据具体情况设置外,上文提到的其他参数一般可以这么设置:

publicKey: {
    challenge, // 自行设置
    rp, // 自行设置
    user, // 自行设置
    pubKeyCredParams: [
        {
            type: "public-key",
            alg: -7 // ES256
        },
        {
            type: "public-key",
            alg: -257 // RS256
        }
    ],
    authenticatorSelection: {
        userVerification: "discouraged",
        authenticatorAttachment: null // 除非用户指定,大部分情况下无需指定
    },
    excludeCredentials, // 自行设置
    timeout: 60000
}

然后就是传入 navigator.credentials.create(),拿到 PublicKeyCredential。如果一切顺利,接下来我们就需要考虑如何将返回的内容传回依赖方了。由于我们拿到的很多都是 ArrayBuffer,我们需要将其进行编码。再准备一个工具函数吧:

function array2b64String(a) {
    return window.btoa(String.fromCharCode(...a));
}

然后适当处理,我们就可以得到一个方便传输的 JSON 字符串了:

navigator.credentials.create({publicKey}).then((credentialInfo) => {
    const publicKeyCredential = {
        id: credentialInfo.id,
        type: credentialInfo.type,
        rawId: array2b64String(new Uint8Array(credentialInfo.rawId)),
        response: {
            clientDataJSON: array2b64String(new Uint8Array(credentialInfo.response.clientDataJSON)),
            attestationObject: array2b64String(new Uint8Array(credentialInfo.response.attestationObject))
        }
    };
    return publicKeyCredential;
}).then(JSON.stringify).then((authenticatorResponseJSON) => {
    // 可以发送了
}).catch((error) => {
    console.warn(error); // 捕获错误
})

依赖方收到数据以后,还需要做三件事:验证挑战、存储凭证 ID 和存储公钥。如果数据解码顺利,且收到的挑战和之前发送的一致,就可以认为注册成功,将凭证 ID 及公钥与用户关联起来。这一步有很多第三方库可以帮我们做,对于基础实现我们就不深入探究了。

由于不同厂商的认证器的实现方式不同,我们并不能保证凭证 ID 一定是全局唯一的,也就是说,凭证 ID 有可能碰撞——即使这些凭证实际上是不同的。依赖方在实现凭证 ID 的存储及查找时,需要注意和用户 ID 结合进行存储或查找,或是直接在注册认证器时在服务器端对比阻止相同的凭证 ID。


接下来就可以进行验证了。某天,用户点击了验证按钮准备登录,于是浏览器发送了验证请求到依赖方,同时附上要登录的用户名。接下来依赖方至少需要发送两项内容给浏览器:挑战和用户已绑定的凭证 ID 列表(即 allowCredentials)。

之后前端的处理流程就和注册时基本一致了。只是需要注意验证流程中获取到的 PublicKeyCredential 的结构和注册时的稍有不同。

当浏览器将数据传回后,依赖方需要做的事情就比之前要麻烦一些了。依赖方需要验证挑战,并用之前存储的公钥验证签名和签名计数。同样地,这一步有很多第三方库可以帮我们做。最后,如果验证全部通过,我们就可以允许用户登录了。

到目前为止,我们已经实现了一个简单的 WebAuthn 验证服务。不过这只是一个最基础的实现,对于很多高安全要求的身份认证这是远远不够的。因此,我们需要摆脱对第三方库的依赖,深入了解 WebAuthn。你可以继续阅读“深入了解 WebAuthn”部分,不过对于基础的 WebAuthn 实现,我们的旅程就到这里了。

拓展阅读

如果你的目标只是快速了解如何开发 WebAuthn,那么你阅读到这里就可以了。同时,上一节例子中的部分代码来自于我为了这篇文章开发的 WordPress 插件 WP-WebAuthn,这个插件可以为你的 WordPress 启用 WebAuthn 无密码登录(并非二步验证),你可以查阅插件的 GitHub 存储库了解更多,也可以自己安装试一试。

如果你正在使用 Chrome 开发,Chrome 87+ 版本添加了一个 WebAuthn 开发者面板,可以帮助你在没有任何实体验证器的情况下开发 WebAuthn 功能。你可以在 Google 的这篇文章中了解更多。不够,如果你正在使用 Firefox,很遗憾目前我还没有找到对应的开发工具或是浏览器扩展可用。

如果你希望了解更多关于非对称加密的数学证明,可以查阅阮一峰老师的这篇文章

如果你希望了解更多关于 Credential Management API 的信息,可以查阅这篇文章(英文)。

如果你希望了解更多 WebAuthn 的细节,可以继续往下阅读。

深入了解 WebAuthn

如上文所说,如果摆脱对第三方库的依赖,或是要实现更安全的 WebAuthn,我们必须深入了解 WebAuthn。在这一部分中,我们会详细讨论上文没有提到的一些概念和参数,并了解 WebAuthn 中各类数据的结构以实现解码与验证。先来看一看一些进阶的选项吧。

进阶选项

没错,上文提到的传入 navigator.credentials.create()navigator.credentials.get() 方法的参数其实只是所有参数的一部分。对于 create(),我们还可以配置这些可选内容(上文提及的已省略):

navigator.credentials.create({
    publicKey: {
        rp: {
            icon
        },
        user: {
            icon
        },
        attestation,
        authenticatorSelection: {
            requireResidentKey
        },
        extensions
    }
})
  • rp.icon: Stringuser.icon:是的,你可以为依赖方和用户设置图标,可以使用 HTTPS URL 或 Base64,一般长度不能超过 128 字节,不过大部分浏览器不会显示图标,或是有非常小的图片体积限制,所以一般没有使用这一属性的必要
  • attestation: String:表明依赖方是否需要证明。可选三个值:
    • none:(默认)不需要证明。如上文所述,依赖方不关心证明,因此认证器不会签名。对于 iOS/iPad OS 13,必须设置为此值,否则验证将失败
    • indirect:依赖方需要证明,但证明方式可由认证器选择。在支持匿名证明的认证器上,认证器会通过匿名证明的方式签名挑战,并向依赖方提供签名方式等信息
    • direct:依赖方要求直接证明。此时认证器会使用烧录在认证器中的公钥进行签名,同时向依赖方提供签名方式等信息以供依赖方验证认证器是否可信。更多信息可以阅读“验证认证器”一节
  • authenticatorSelection 中,我们还可以设置两个可选属性:
    • authenticatorSelection.requireResidentKey: Boolean:是否要求将私钥钥永久存储于认证器中。默认值为 false。对于 iOS/iPad OS 13,必须设置为 false,否则验证将失败
  • extensions: Object:WebAuthn 扩展,可以提供规范之外的配置和响应。由于实际情况中很少会使用这一特性,我们不会在这篇文章中讨论它

requireResidentKey 设置为 true 可以实现无用户名的登录,即认证器同时替代了用户名和密码。需要注意的是,尽管大部分认证器可以实现无限对公私钥,但能永久存储的私钥数量是有限的(对于 Yubikey,这通常是 25),因此只应在真正需要的时候启用此特性。我们会在“无用户名登录”一节中详细讨论原因。

如果你没有高安全需求(如银行交易等),请不要向认证器索取证明,即将 attestation 设置为 "none"。对于普通身份认证来说,要求证明不必要的,且会有浏览器提示打扰到用户。

Android 暂时无法实施用户验证,进而会导致依赖方验证失败。你可以在这里追踪这个特性的实现情况。

对于 extensions,由于目前浏览器支持和应用范围有限,我们不会在这篇文章中涉及,不过你可以看一个例子:

extensions: {
    uvm: true, // 要求认证器返回用户进行验证的方法
    txAuthSimple: "Please proceed" // 在认证器上显示与交易有关的简短消息
}

你可以在这个页面了解更多关于 extensions 的信息。

对于 get(),我们其实只有一个可选内容没讲了,即 extensions。和上文一样,我们不会在这篇文章中讨论它。

就这些了!

手动解个码

是时候看看如何手动解码了。我们将会在这一节中讨论认证器返回的数据的结构以及如何正确地解码它们。

首先我们来看看如何处理注册过程中认证器发回的数据。假设所有 ArrayBuffer 类型的值都被正确地以 Base64 编码,且后端已经将 JSON 的字符串解析为字典。先来复习一下,我们得到的数据应该是这样的(数据较长,已省略一部分):

{
    id: "ZRBkDBCEtq...9XY8atOcbg",
    type: "public-key",
    rawId: "ZRBkDBCEtq...9XY8atOcbg==",
    response: {
        clientDataJSON: "eyJjaGFsbGVuZ2U...i5jcmVhdGUifQ==",
        attestationObject: "o2NmbXRkbm9uZWd...xNHuAMzz2LxZA=="
    }
}

这里的 id 就是凭证的 ID,如果验证正确,我们最终要将它存储起来并于用户关联。同时可以看到 Base64 编码后的 rawId 其实和 id 是一致的(不过 id 是 Base64URL 编码的)。而 type 则一定是 "public-key"。

不过,我们主要关心的还是 respose 中的两项内容。首先是 clientDataJSON。它的处理比较简单,看名字就知道,它应该是一个 JSON 字符串。

小技巧:如果你看到一个 Base64 编码的字符串以 "ey" 开头,那么它大概率是一个 Base64 编码的 JSON。

clientDataJSON Base64 解码再 JSON 解码之后我们就能得到一个字典:

{
    challenge: "NI4i1vsNmP2KHcmyFnBCKRVQPfHgg34SsYZUOPZY2lM",
    extra_keys_may_be_added_here: "do not compare clientDataJSON against a template. See https://goo.gl/yabPex",
    origin: "https://dev.axton.cc",
    type: "webauthn.create"
}

结构一目了然。在这里,我们需要验证三项内容:

  • challenge 和之前发送的一致
  • origin 为创建凭证的页面的源(协议+主机名+端口号,并非 URL)
  • type 为 "webauthn.create"

同时可以注意到有一个奇怪的 extra_keys_may_be_added_here。这其实是 Google 在 Chrome 中搞的一点小把戏,有一定概率会出现,提醒我们需要将 JSON 解析后再验证键值以防额外插入的键值影响验证。具体信息你可以访问那个 URL 看一看。

对于 Firefox,我们会多得到两项 clientExtensionshashAlgorithm ,分别指明了客户端扩展数据和签名算法。

{
    challenge: "dg6ost6ujhAA0g6WqLe-SOOH-tbhvjW9Sp90aPKlLJI",
    clientExtensions: {},
    hashAlgorithm: "SHA-256",
    origin: "https://dev.axton.cc",
    type: "webauthn.create"
}

由于本文不考虑扩展数据,因此我们可以不考虑 clientExtensions。同时由于目前规范中指定的签名算法只有 SHA-256 一种,因此现阶段我们也可以简单地忽略 hashAlgorithm

clientDataJSON 很简单地就处理完了。接下来我们要来看看 attestationObject。先来看看 attestationObject 的结构图示:

attestationObject 的结构

attestationObject 是 CBOR 编码后再被 Base64 编码的,因此我们需要额外对其进行 CBOR 解码。

CBOR (Concise Binary Object Representation, 简明二进制对象表示) 是一种多应用于物联网领域的编码方式,你可以将它看作体积更小、更方便物联网传输的二进制 JSON。大部分语言都可以找到对应的 CBOR 解码库。

我们当然不会手解 CBOR,直接来看看解开之后的样子吧:

{
    fmt: "none",
    attStmt: {},
    authData: [211, 217, 43, 24, 199, ..., 97, 238, 166, 67, 107]
}

这些键值的含义如下:

  • fmt:标明证明的格式。WebAuthn 预定义了几种格式,分别为:
    • none:没有证明
    • packed:为 WebAuthn 优化的证明格式
    • android-safetynet:Android 使用的格式
    • android-key:又是 Android 使用的格式
    • fido-u2f:FIDO U2F 认证器使用的格式
    • tpm:TPM 可信平台模块使用的格式
  • attStmt:证明对象,具体格式根据 fmt 的不同会有所不同
  • authData:包含公钥、签名计数等来自认证器的数据

诶,例子里的 attStmt 怎么是空的?还记得之前说的吗?大部分情况下,如果依赖方不要求证明,那么认证器不会签名挑战,于是 fmt 会变为 "none",attstmt 会为空。如果不是高安全要求,我们可以只对这一种情况做支持。

注意,部分情况下 Firefox 会在不要求证明(即 attestation 为 "none")时会返回 fmt 为 "packed" 的证明。这是符合规范的。此时认证器会进行自证明,你可以视情况进行处理。具体可以阅读“验证认证器”一节。

对于非 "none" 的 fmt 我们稍后再谈,现在我们先来看看 authData。来复习一下 authData 的结构:

authData 的结构

对于它的解码比较简单粗暴,我们要做的就是根据图示将它切开,然后适当地转换类型。其中各部分的含义如下:

  • rpIdHash:如其名,SHA-256 的 rpId,长度 32 字节
  • flags:标识认证状态,长度 1 字节。其中 8 位分别为:
    • ED:是否有扩展数据
    • AT:是否包含 attestedCredentialData。通常注册认证器时会包含 attestedCredentialData,而验证时不包含
    • 保留位
    • 保留位
    • 保留位
    • UV:用户是否已验证
    • 保留位
    • UP:用户是否在场
  • signCount:签名计数,长度 4 字节
  • attestedCredentialData:包含公钥等凭据数据,变长。其结构如下:
    • aaguid:认证器的 AAGUID (Authenticator Attestation GUID),对于同一型号的认证器,它们的 AAGUID 是相同的
    • credentialIdLength:标识 credentialId 的长度,长度 2 字节
    • credentialId:凭证 ID,和 rawId 一致,长度由 credentialIdLength 定义
    • credentialPublicKey:CBOR 编码的 COSE 格式的凭证公钥,变长
  • extensions:扩展数据,变长,本文不讨论

出于隐私考虑,如果不要求证明,认证器会以 0 填充 AAGUID。

如果你的后端在使用 Node.js,这里有个工具函数可以帮你完成这一步(不考虑 extensions):

function parseAuthData(buffer){
    let rpIdHash = buffer.slice(0, 32);
    buffer = buffer.slice(32);
  
    let flagsBuf = buffer.slice(0, 1);
    buffer = buffer.slice(1);

    let flagsInt = flagsBuf[0];
    let flags = {
        up: !!(flagsInt & 0x01),
        uv: !!(flagsInt & 0x04),
        at: !!(flagsInt & 0x40),
        ed: !!(flagsInt & 0x80),
        flagsInt
    }

    let counterBuf = buffer.slice(0, 4);
    buffer = buffer.slice(4);
  
    let counter = counterBuf.readUInt32BE(0);

    let aaguid = undefined;
    let credID = undefined;
    let COSEPublicKey = undefined;

    if(flags.at) {
        aaguid = buffer.slice(0, 16);
        buffer = buffer.slice(16);

        let credIDLenBuf = buffer.slice(0, 2);
        buffer = buffer.slice(2);

        let credIDLen = credIDLenBuf.readUInt16BE(0);
        credID = buffer.slice(0, credIDLen);

        buffer = buffer.slice(credIDLen);
        COSEPublicKey = buffer;
    }

    return {rpIdHash, flagsBuf, flags, counter, counterBuf, aaguid, credID, COSEPublicKey}
}

这段代码来自 herrjemand

解开后,依赖方至少需要做四件事情:

  1. 验证 rpIdHash 和预期的一致
  2. 按预期检查用户在场和用户验证状态
  3. 存储签名计数
  4. 存储公钥

签名计数不一定从 0 开始。

对于公钥,也就是 credentialPublicKey,我们需要多一次 CBOR 解码,然后就可以得到类似这样的公钥:

{
    kty: "EC",
    alg: "ECDSA_w_SHA256",
    crv: "P-256",
    x: "ZGQALNfqo0L7HFYQHFHCS/X5db49z0ePnuQEs3w3X8w=",
    y: "6qYxhnjYuez/Q8N6vX7nIIGfxFWdZ25NzQfZYuYOalA="
}

然后可以选择适当的方法将其存储起来,之后的步骤本文就不再赘述了。现在,将目光拉回来,让我们看看包含证明的 attestationObject 是怎么样的。我们来看一个例子:

{
    fmt: "packed",
    attStmt: {
        alg: -7,
        sig: [48, 70, 2, 33, 0, ..., 132, 78, 46, 100, 21],
        x5c: [
            [48, 130, 2, 189, 48, 130, 1, 165, 160, 3, ..., 177, 48, 125, 191, 145, 24, 225, 169, 41, 248]
        ]
    },
    authData: [211, 217, 43, 24, 199, ..., 158, 54, 87, 126, 54]
}

这里有一个使用 "packed" 格式的证明。此时,attStmt 中包含三个值:

  • alg:签名算法
  • sig:签名值
  • x5c:X.509 编码的证书链

我们不会在这一节中详述对签名的验证。要了解更多信息,你可以阅读“验证认证器”一节。现在,让我们来看看如何处理验证过程中认证器发回的数据。我们得到的数据应该是这样的(数据较长,已省略一部分):

{
    id: "hmqdxPLit9...BWeVxZqdvU",
    type: "public-key",
    rawId: "hmqdxPLit9V...BWeVxZqdvU=",
    response: {
        authenticatorData: "09krGMcWTf...UFAAAABA==",
        clientDataJSON: "eyJjaGFsbGVuZ2U...XRobi5nZXQifQ==",
        signature: "UsXZV3pvT3np8btj6V0g...WBkaqyt88DrD40qh+A==",
        userHandle: "MmYxNWYzZjQyZjM...Tg2ZDY4NzhlNw=="
    }
}

id, rawIdtype 和之前一样,这里就不再赘述了。让我们来看看 response。首先是 clientDataJSON,和之前的解法一样,要验证的内容也一样,只是 type 从 "webauthn.create" 变成了 "webauthn.get"。

{
    challenge: "bnkd2CmrEuvKnAFXs2QlC3SKlg4XFvGtP4HJL1yEWyU",
    origin: "https://dev.axton.cc",
    type: "webauthn.get"
}

然后是 userHandle。前面讲过,这是认证器在创建凭证时的用户 ID。如果用户在使用 U2F 认证器,很可能这一项为空,所以大部分情况下我们不关心这一项。

接着来看 authenticatorData。这其实就是之前的 attestedCredentialData,只是这次不包含公钥。以相同的方式切开数据,我们应该可以得到 rpIdHash, flagssignCount 三项。此时,依赖方至少需要做这三样事情:

  1. 验证 rpIdHash 和预期的一致
  2. 按预期检查用户在场和用户验证状态
  3. 验证签名计数大于之前存储的计数,并更新存储的计数

如果签名计数比之前的小,那么这个认证器很可能是伪造的,应该中止验证并返回验证失败。同时,签名计数不一定每次按 1 递增,通常只要计数比此前的大就认为计数检查通过。

最后,我们来看 signature,也就是签名。不过这个签名不是简单的对挑战的签名,具体算法如图所示:

签名过程

计算签名时,认证器会将 authenticatorDataclientDataHash(也就是 clientDataJSON 的 SHA-256 Hash)拼接起来,并使用对应的私钥签名。依赖方应该使用对应的公钥将其解密,并验证内容是否是 authenticatorDataclientDataHash 的拼接。这部分的计算不在本文的讨论范围内。

最后,如果全部验证通过,返回验证成功。

验证认证器

WebAuthn 已经很安全了,但有的时候我们还要让它更安全一点。比如,如果用户在使用伪造的或是自制的认证器,认证器的安全性就得不到保证。此时,依赖方就需要验证认证器是否是可信的认证器。

这一过程仅发生在注册认证器时。此时,如果认证器验证通过,就可以存储公钥,后续步骤和之前描述的一致。

再次说明,如果不是对安全性有极高的要求,向认证器索取证明以验证认证器是否可信是没有必要的。此外,验证认证器需要依赖方自行维护可信认证器列表,大大增加了维护的复杂性。

在调用 navigator.credentials.create() 时,我们可以将 attestation 设置为非 "none" 来向认证器索取证明。除无证明外,WebAuthn 定义了四种证明方式:

  • Basic Attestation (Basic) :基础的证明方式,认证器会使用认证器私钥签名
  • Self Attestation (Self) :自证明,认证器会使用凭证私钥签名
  • Attestation CA (AttCA) :认证器会使用多个证明私钥之一签名
  • Elliptic Curve based Direct Anonymous Attestation (ECDAA) :通过 DAA 实现的匿名证明

和验证过程一样,这里签名的目标是 authenticatorDataclientDataHash 的连接。

还记得 create()attestation 可选的三个值吗?这个值会决定认证器最终使用哪种方式进行证明。复习一下:

  • none:大部分情况下认证器会选择不进行证明,部分认证器会进行自证明。此时 AAGUID 会被 0 填充
  • indirect:认证器会试图通过替换 AAGUID 和选择合适的证明方式来进行匿名证明,具体方式由认证器选择
  • direct:认证器会提供最直接的证明信息

注意,大部分情况下,当认证器需要向依赖方证明自己可信时需要提供认证器公钥,这会触发浏览器提示,只有用户同意后认证器才会进行证明,否则认证器将不提供证明。

浏览器提示

为什么浏览器会说“安全密钥的品牌和型号”?事实上,为了避免用户通过认证器证书被跨依赖方追踪,FIDO 要求使用相同认证器证书的认证器的数量不能少于 100,000。于是大部分认证器厂商会选择让同一型号的认证器共用同一份证书。因此,浏览器的会询问用户是否同意“查看安全密钥的品牌和型号”。

Android Safety Net 不会向用户询问是否同意,而是会静默进行证明。

当证明不为空时,依赖方收到数据后根据 attestationObject.fmt 的不同,需要选择不同的验证方式来验证认证器的可信情况。出于篇幅原因,这里我们不会讨论每一种 fmt 的验证方式,更多信息你可以查阅 W3C 文档

fmtpacked 时,attestationObject.attStmt 可能会有三种格式:

// 自证明
{
    alg, // 算法
    sig // 签名
}
// 基础或证明 CA 证明
{
    alg,
    sig,
    x5c // X.509 证书链
}
// 椭圆曲线证明
{
    alg,
    sig,
    ecdaaKeyId // ECDAA-Issuer 公钥标识符
}

此时,依赖方需要检查证书符合预期格式并检查证书是否在可信链上。首先,如果证明中既没有 ecdaaKeyId 也没有 x5c,就说明这个证明使用的是自证明,只需使用认证器提供的公钥验证即可;如果有 x5c,那么就需要验证 x5c 中的证书是否在可信链上。将 x5c 中的每个证书以 Base64 编码,按 64 个字符切开,并在头尾加上 -----BEGIN CERTIFICATE----------END CERTIFICATE-----就能得到一个证书字符串了。之后,依赖方需要验证证书是否可信。

function base64ToPem(b64cert){
    let pemcert = '';
    for(let i = 0; i < b64cert.length; i += 64){
        pemcert += b64cert.slice(i, i + 64) + '\n';
    }
    return '-----BEGIN CERTIFICATE-----\n' + pemcert + '-----END CERTIFICATE-----';
}

这段代码来自 herrjemand

至于 ecdaaKeyId,由于目前应用较少,处理方法可能需要你另寻资料了。检查证书的具体步骤已经超出了本文的范围,具体可以查阅这篇文章(英文)。

你可以在 FIDO Metadata Service 找到各大厂商认证器的可信证书链。

当在 Android 上调起 WebAuthn 时,大部分情况下 fmt 将会为 safety-net。此时 attestationObject.attStmt 的结构会是:

{
    ver: "200616037",
    response: {
        type: "Buffer",
        data: [101, 121, 74, 104, 98, ..., 115, 104, 104, 82, 65]
    }
}

此时,clientDataJSON 中还会出现 androidPackageName 键,值是调起 WebAuthn 验证的应用的包名,如 Chrome 就是 "com.android.chrome"。

在这个证明中,data 其实是一个 JWT 字符串,我们可以将它编码为字符串并将其按照 JWT 进行解码(别忘了验证 JWT 签名)。最终我们会得到一个类似这样的 Payload:

{
    nonce: "0QAurN4F9wik6GEkblDJhGuf4kuaqZn5zaaxlvD1hlA=",
    timestampMs: 1584950686460,
    apkPackageName: "com.google.android.gms",
    apkDigestSha256: "2BQHno+bmWWwdLUYylS8HLt5ESJzci3nt2uui71ojyE=",
    ctsProfileMatch: true,
    apkCertificateDigestSha256: [
        "8P1sW0EPicslw7UzRsiXL64w+O50Ed+RBICtay2g24M="
    ],
    basicIntegrity: true,
    evaluationType: "BASIC"
}

其中包含了有关设备状态的一些信息。比如说,如果 ctsProfileMatchfalse,那么该设备很有可能被 root 了。对于高安全要求的场景,我们可以视情况进行验证。

同时我们可以在 JWT Header 中验证证明的有效性。我们应该能取得这样的 Header:

{
    alg: "RS256",
    x5c: [
        "MIIFkzCCBHugAwIBAgIR...uvlyjOwAzXuMu7M+PWRc",
        "MIIESjCCAzKgAwIBAgIN...UK4v4ZUN80atnZz1yg=="
    ]
}

这里的结构就和上方的 x5c 验证类似了。具体步骤可以参考这篇文章(英文)。

其他所有格式的验证方式也都可以在这篇文章(英文)中找到。

无用户名登录

认证器已经代替了密码,可是这还不够!在进行第一因素认证(即使用 WebAuthn 登录)时,我们还是需要输入用户名,然后才能进行身份认证。懒惰是第一生产力,我们能不能不输入用户名就进行身份认证呢?实际上,大部分认证器都允许我们无用户名登录。而这一特性的核心就是 Resident Key 客户端密钥驻留。

你可以思考一下,为什么普通的 WebAuthn 为什么不能实现无用户名登录?事实上,大部分认证器为了实现无限对公私钥,会将私钥通过 Key Warp 等技术加密后包含在凭证 ID 中发送给依赖方,这样认证器本身就不用存储任何信息。不过,这就导致需要身份认证时,依赖方必须通过用户名找到对应的凭证 ID,将其发送给认证器以供其算出私钥。

Yubikey 实现了一个基于 HMAC 的算法,认证器可以在私钥不离开认证器的前提下(常规的 Key Warp 算法中实际上私钥离开了认证器)通过一些输入和凭证 ID 重新计算私钥,你可以阅读这篇文章了解更多。

客户端通过凭证 ID 查找对应认证器的算法根据系统的不同是不同的。通常凭证 ID 中会包含认证器信息,因此系统可以通过凭证 ID 找到对应的认证器。

要避免输入用户名,我们可以要求认证器将私钥在自己的内存中也存储一份。这样,依赖方无需提供凭证 ID,认证器就可以通过依赖方 ID 找到所需的私钥并签名公钥。以下是具体流程:

注册时:

  1. 依赖方请求新建凭证,同时要求启用客户端密钥
  2. 认证器生成一对公私钥,并将私钥存储在永久内存中且与依赖方 ID 及用户 ID 绑定,随后将公钥发送给依赖方以供存储
  3. 依赖方将用户 ID 即公钥与用户绑定

验证时:

  1. 依赖方请求验证,但不必提供除依赖方 ID 以外的更多信息
  2. 用户选择认证器
  3. 认证器根据依赖方 ID 找到对应私钥
  4. 如果有多个对应私钥,认证器会询问用户应该使用哪个身份信息登录
  5. 确定私钥后,认证器签名挑战并将其返回,同时返回用户 ID
  6. 依赖方通过用户 ID 找到对应用户并用对应公钥检查签名,正确则允许对应用户登录

可以看到,这个特性同时要求认证器存储用户 ID,即上面提到过的 userHandle。依赖方需要根据此信息找到对应用户,因此不支持 userHandle 的 U2F 认证器无法进行无用户名登录。

如之前所说,认证器能永久存储的私钥数量是有限的,因此只应在真正需要无用户名登录的时候启用此特性。

目前暂时没有办法检测认证器是否支持客户端密钥驻留,因此在无用户名验证失败时应 fallback 至常规的 WebAuthn 验证,即向用户询问用户名。

现在让我们来看看如何实现这一特性吧。首先,调用 navigator.credentials.create() 时我们需要注意两个参数:requireResidentKey 必须为 trueuserVerification 必须为 "required"。

navigator.credentials.create({
    publicKey: {
        ...
        authenticatorSelection: {
            requireResidentKey: true,
            userVerification: "required"
            ...
        },
        ...
    }
})

Windows Hello 似乎会存储所有已注册的凭据,因此无论是否指定 requireResidentKey,你都可以通过 Windows Hello 进行无用户名登录。

随后,浏览器会询问用户是否允许认证器存储私钥。

浏览器提示

如果用户同意,认证器会存储私钥,并和普通的 WebAuthn 一样返回信息。不过,依赖方收到数据之后,只需将公钥、用户 ID 与用户关联起来,而不必再将凭证 ID 与用户关联起来。至此注册完成。

之后,在用户请求登录时,无需再向依赖方提供用户名。同时在传入 navigator.credentials.get() 的参数中也有两个需要注意:userVerification 必须为 "required",同时 allowCredentials 必须为空。

navigator.credentials.get({
    publicKey: {
        ...
        userVerification: "required",
        allowCredentials: [],
        ...
    }
})

Android 暂不支持无用户名验证,空的 allowCredentials 会导致浏览器返回 NotSupportedError 错误。

此时,认证器会根据依赖方 ID 找到对应的私钥。如果有多个对应私钥,认证器会询问用户应该使用哪个身份信息登录。用户选择后,认证器就会使用对应的私钥签名挑战并将其返回。此时,userHandle 一定不为空。

依赖方收到数据后,需要将 userHandle 作为用户 ID 找到对应的用户,并使用对应的公钥验证签名。如果验证成功,则认为对应的用户身份认证成功,依赖方可以允许其登录。至此验证结束。

有的时候你可能会需要清除认证器中的密钥。绝大多数认证器都提供了对应的软件以供清除存储的密钥,但大部分情况下这会重置整个认证器,这意味着相关认证器此前的所有凭证均会失效。因此建议不要将日常使用的认证器作为开发测试用的认证器。

从 U2F 认证迁移

如果你的服务此前提供了 U2F 第二因素认证,你可能会希望在将依赖方从 U2F 升级到 WebAuthn 时用户此前注册的 U2F 认证器仍然可用而无需重新注册。由于 WebAuthn 向后兼容 U2F 设备,用户是可以继续使用原有的 U2F 认证器的。不过,由于 WebAuthn 的依赖方 ID 与 U2F 的 appid 并不一定相同,你需要将原有的 U2F appid 随 WebAuthn 流程一起传递给认证器,认证器才能使用正确的私钥进行响应。

要实现这一点,我们只需要在注册及认证仪式中使用 WebAuthn 的 appid 扩展。

extensions: {
    appid: "https://example.com" //U2F appid
}

此时认证器便可以得到正确的私钥,之后的流程与正常情况一致;依赖方除了正常的 WebAuthn 流程外,不需要再做任何其它操作。

拓展阅读

我们的 WebAuthn 之旅到这里就真的结束了。不过,你仍然可以自行探究,了解更多。本文只是一个粗浅的使用指南,而被称为“Web 身份认证的未来”的 WebAuthn 的深层还要很多细节值得我们挖掘学习,不过本文不会再继续讨论了。

如果你想了解更多关于 WebAuthn 的信息,最好的方法是直接阅读 W3C 规范。此外,Yubico 也提供了不错的 WebAuthn 文档

要了解更多认证器存储凭证的信息,你可以阅读这篇文章(英文)。

如果你想了解更多 Android Safety Net 是怎样运作的,可以观看这个视频,并通过 Google 的文档了解 Android Safety Net 的更多信息。

如果你想方便地调试 WebAuthn,webauthn.me 提供了一个非常直观方便的调试器

参考资料

WebAuthn Level 1 规范 - W3C
WebAuthn Level 2 草案 - W3C
一起来了解Web Authentication - TechBridge 技术共笔部落格
Introduction to WebAuthn API - Medium
WebAuthn/FIDO2: Verifying responses - Medium
WebAuthn介绍与使用 - obeta
webauthn.io
webauthn.guide
webauthn.me
Attestation and Assertion - MDN
Web Authentication API - MDN(顺便翻译了一半)
FIDO TechNotes: The Truth about Attestation - FIDO Alliance
WebAuthn Developer Guide - Yubico
WebAuthn - Wikipedia
All about FIDO2, CTAP2 and WebAuthn - Microsoft Tech Community
Webauthn Framework
如何开发支持 FIDO U2F 登录的网站 - 知乎专栏

]]>
https://flyhigher.top/develop/2160.html/feed 44
NLP 找门:用朴素贝叶斯进行文本分类 https://flyhigher.top/develop/1989.html https://flyhigher.top/develop/1989.html#comments Wed, 29 Jan 2020 11:39:58 +0000 https://flyhigher.top/?p=1989

有一天,神收到了一封垃圾邮件,于是世界上就有了朴素贝叶斯算法。

——我瞎编的

为什么这篇文章的标题是 NLP 找门呢?因为如果你看完了这篇文章,你就可以准备入门自然语言处理 (Natural Language Processing) 了。我会把这篇文章当作一篇彻彻底底的 NLP 入门文章来写,尽量避免“专业高端”词汇和“全是奇怪符号”的式子,这样只要你有高中数学基础,就能毫无压力地看完这篇文章,学会使用朴素贝叶斯分类算法分类文本。

让我们开始吧。

所以,啥是朴素贝叶斯?

简单来说,朴素贝叶斯 (Naïve Bayes) 是一个简单但高效的分类算法,在进行不复杂的文本分类时高效且拥有不低的准确度,判断垃圾邮件就是用朴素贝叶斯进行文本分类的一个经典例子。当然朴素贝叶斯分类不仅能用在 NLP 中,在许多分类问题中朴素贝叶斯也有非常好的效果,但我们今天只关注它在 NLP 中的应用。

要了解朴素贝叶斯,我们要先来了解一下贝叶斯定理。

每增加一个数学公式都会使读者减半。

——霍金

那么,就让我们先来看看贝叶斯定理的公式吧(笑

$$P(A|B) = \frac{P(AB)}{P(B)} = \frac{P(B|A)P(A)}{P(B)}$$

其实还是很简单的,运用高中的条件概率知识就能理解。本质上,贝叶斯定理给出了一种方法,使得我们可以在 \(P(A|B)\) 和 \(P(B|A)\) 之间互相转换,因为通常情况下它们是不一样的。

要更好地理解,请考虑这样一个例子:

假设有一个学校图书馆,图书管理员正为找不到某本书而发愁。已知老师有 70% 的意愿借走这本书,而是学生的意愿是 30%,这个学校的师生比例是 1:10,那么借走这本书的人是老师的概率有多大?

设学校总人数为 \(T\),我们可以很容易地写出这样的一个式子:

$$\begin{align} P & = \frac{T\cdot\frac{1}{11}\cdot 70\%}{T\cdot\frac{1}{11}\cdot 70\%+T\cdot\frac{10}{11}\cdot 30\%} \\ & = \frac{70\%}{70\%+10\times30\%} \\ & = \frac{7}{37} \end{align}$$

这就是贝叶斯定理了!等等,你可能会挠挠头,这哪里是贝叶斯了?别急,如果我们把最上面的式子换个字母的话...

$$P(H|E) = \frac{P(E|H)P(H)}{P(E)}$$

在这里,\(H\) 指 Hypothesis,即假设,而 \(E\) 指 Evidence,即证据。这样,这个式子就很好理解了,在我们上面的例子里,借走书是证据,那么 \(P(E)\) 就是某个人选择借走这本书的概率;这个人是老师是假设,那么这个人是老师的概率是 \(P(H)\)。注意这里的概率指在学校中抽一个人是老师的概率,并不是“在借走书的前提下”这个人是老师的概率,而“在借走书的前提下”这个人是老师的概率应该是 \(P(H|E)\),也正是我们要求的概率。同样地,而“这个人是老师”的前提下借走书的概率就是 \(P(E|H)\) 了。

那么再来看看上面我们凭小学知识就列出的计算式,如果把里面的具体数据换成概率来表示的话,这个式子就会变成...

$$P(H|E) = \frac{T\cdot P(E|H)P(H)}{T\cdot P(E|H)P(H) + T\cdot P(E|\neg H)P(\neg H)}$$

好吧,这里解释一下,\(\neg\) 符号表示“非”,所以 \(P(E|\neg H)\) 表示在“这个人不是老师”的前提下借走书的概率。而 \(T\cdot P(E|H)P(H)\) + \(T\cdot P(E|\neg H)P(\neg H)\),即“可能借走书的老师的数量 + 可能借走书的学生的数量”,就是“可能借走书的人的数量”了,也就是 \(T\cdot P(E)\)。上下消去 \(T\),我们就能得到上面的式子了。

$$P(H|E) = \frac{P(E|H)P(H)}{P(E)}$$

这就是贝叶斯了定理。如果你还是不太清楚,可以去看看 3Blue1Brown 的这个视频,图形化的讲解会清晰很多。

这里我要提一下这个式子里各部分的专有名称了(“专业高端”词汇警告),你可以不记住,直接看后面。

  • \(P(H|E)\) 叫做 \(H\) 的后验概率,反之亦然
  • \(P(H)\) 叫做 \(H\) 的先验概率,反之亦然
  • 特别地,我们把 \(P(E|H)\) 称作“似然值”,即 likelihood

那什么是朴素贝叶斯呢?按上面所说的,朴素贝叶斯是一种分类算法。简单来说,朴素贝叶斯将一个对象的各个特征考虑为互相独立,然后根据这些特征的概率的乘积来判断对象所属的分类。基本原理如下:

$$P(H|E) = \frac{P(E|H)P(H)}{P(E)} \propto P(E|H)P(H) = P(H)\prod_{i}P(W_i|H)$$

在这里,\(W_i\) 指某一对象的第 \(i\) 个特征,对于文本分类来说,这就是一段文本中的某个单词。

朴素贝叶斯之所以“朴素”,是因为它要求各个特征间是独立的,在文本分类中也就是各个单词之间互不干扰。虽然思路简单的代价是适用范围变窄,不过由于这样的简化在很多情况下已经足够了,因此实际上朴素贝叶斯的应用范围非常广。你看朴素贝叶斯 Naïve Bayes 的缩写都是 NB 了,能不厉害吗(逃

那么,咋分类啊

看来你这下完全听懂了呢(笑),是时候看看如何在 NLP 中应用朴素贝叶斯了。和上面一样,我们用一个具体的例子来说明。我们的目标是让电脑学会分类美国共和党和民主党的演讲稿,由于两个党派的演讲风格不同,所以这样的分类在理论上是可行的。

上面我们要用到的数据集,你可以下载下来一起动手玩一玩。先说一下数据集的数据结构吧,压缩包里有两个文件,train.txt 将会被当作训练数据集,而 test.txt 则会作为训练结束后的验证数据集。两个文本文件里数据的结构是类似的,就像这样:

BLUE WELL I AM SO HONORED AND PERSONALLY UNKNOWNWORD TO BE HERE...
RED THANK YOU . THIS IS QUITE AN INSTITUTION . IT'S GOOD TO BE...

每行都是一篇演讲稿,每行的第一个单词指明了这篇演讲稿所属的党派,RED 指共和党,而 BLUE 指民主党。所有单词和符号都已经被转为大写并由空格分隔方便处理。train.txt 有共和党演讲稿和民主党演讲稿各 23 篇,test.txt 有 6 篇共和党演讲稿,12 篇民主党演讲稿。

明白了?那我们就开始吧。

捋捋思路

首先,我们需要考虑如何在文本分类中应用朴素贝叶斯。很简单,按朴素贝叶斯的思路,计算每个词在某一分类下的出现概率,然后将某篇文章的所有词的概率相乘,再乘以该分类的先验概率,就可以得到某篇文章在某一分类下的概率。

$$P(Class|Article) = P(Class)\prod_{i}P(Word_i|Class)$$

各个分类概率都计算完成后,概率最高的那个分类就是这篇文章可能所属的分类。这个思路的核心就是用词决定了文本风格,文本的不同类别用词会有差异,只要能量化这些差异就可以分类文本。在我们的例子中,我们可以从 train.txt 中统计各个词汇的出现情况,然后用 test.txt 中的数据按上面的算法验证我们算法的准确性。

这个思路很简单也很清晰,但还有一些问题需要解决。第一,有的时候,我们的测试数据集中可能会出现一个在训练数据集中没有出现过的词语。这个时候,朴素贝叶斯的计算结果会是 0。如果我们把 0 乘进式子中,那就别想得到正常的结果了。所以,我们还需要对计算某一单词在某一分类中的式子稍加改进。使用拉普拉斯平滑,就可以避免出现概率为 0 的情况。别被名字吓到,拉普拉斯平滑是一种非常简单的平滑方法:在分子上 +1,在分母上加整个取值范围,这样就可以给整个分式加上非常微小的偏移,避免出现 0。

$$\begin{align} P(Word_i|Class) & = \frac{Word_iCountInClass}{AllWordCountInClass} \\ & \approx \frac{Word_iCountInClass + 1}{AllWordCountInClass + UniqueWordCount} \end{align}$$

第二,对于长文本,大部分词语在某一分类中的出现概率是远小于 1 的,加上长文本词汇量大,往往概率相乘的结果会非常小。受限于计算机处理浮点数的原理,精确处理这么小的数字是很麻烦的。幸好,运用一些简单的数学知识就可以将其转化为更精确的表达,那就是取对数。

首先,将概率计算结果取对数并不影响我们的计算结果。因为取对数是一个单调递增的操作,而我们计算概率只是为了排序选择概率最高的分类,因此取对数是不影响我们排序的。而把多项式取对数,等于把多项式的每一项取对数后相加。所以我们有:

$$\lg{\Big(P(Class)\prod_{i}P(Word_i|Class)\Big)} = \lg{P(Class)} + \sum_i\lg{P(Word_i|Class)}$$

大部分情况下,在每一次取对数的时候,要取对数的数字的大小,即 \(P(Word_i|Class)\) 尚还在计算机能处理的范围内,因此我们可以放心地使用取对数的方法,避免计算机精度不够影响结果。

第三就是在某些情况下,可能会有部分词语干扰计算结果,如 and, is 这类的被大量使用的中性词。如果希望得到更好的结果,我们可以维护一个停用词表,在计算时排除停用词即可。或者,我们可以在计算完每个单词的出现数量后,排除数量最多的前 \(N\) 个单词,避免这些单词过多地影响计算。

写点代码

现在我们终于可以开始实战,写点代码了。我会用简单的 Python 来表达思路。好了,理一理思绪,第一步我们要做的,是统计训练数据集中的用词情况。具体来说,根据上面的思考,我们需要统计的有:

  • 每个单词在各分类中出现的数量
  • 各分类中的不重复词数量

用 Python 简单表示如下:

# 读入数据过程略...
data_raw = "读入的数据".split('\n') # 按行分隔
data_blue = []
data_red = []
word_count_blue = {}
word_count_red = {}

for line_data in data_raw:
    word_list = line_data.split(" ") # 分隔单词
    if word_list[0] == "BLUE":
        for i in range(1,len(word_list)):
            if not is_excluded(word_list[i]): # 判断是否为停用词
                data_blue.append(word_list[i])
                # 统计单词出现次数
                word_count_blue.setdefault(word_list[i], 0)
                word_count_blue[word_list[i]] += 1
    elif word_list[0] == "RED":
        # Class = red 时同理...

#统计非重复词
unique_words_blue = list(set(data_blue))
unique_words_red = list(set(data_red))

训练过程到这里就结束了,计算机已经知道了各政党演讲的用词习惯。很简单吧?接下来我们就要使用测试数据集来测试准确度了。这里开始就涉及到朴素贝叶斯的计算了,可能会稍微复杂一点点。

import math
#读入测试数据过程略...
test_data_raw = "读入的数据".split('\n') # 按行分隔
test_data = []

for line_data in test_data_raw:
    if line_data[0] == "BLUE":
        content_data = line_data.split(" ").pop(0)
        test_data.append({"class": "blue", "content": content_data})
    elif line_data[0] == 'RED':
        # ...

for line_data in test_data:
    posibility_blue = 0
    for word in line_data["content"]:
        # 计算各单词概率,取对数后相加,使用了拉普拉斯平滑
        if word in word_count_blue:
            posibility_blue += math.log((word_count_blue[word]+1)/(len(data_blue)+len(unique_words_blue)+len(unique_words_red)))
        else:
            posibility_blue += math.log(1/(len(data_blue)+len(unique_words_blue)+len(unique_words_red)))
    # 最后加上该分类概率的对数
    posibility_blue += math.log(len(data_blue)/(len(data_blue)+len(data_red)))

    # 计算 Red 同理...

计算完成后,我们就得到每篇演讲稿在各分类下的概率了。之后,简单的比较就能得出推断的分类:如果 \(P(Red|Article)\) 大于 \(P(Blue|Article)\),那么这篇演讲稿就更可能是共和党的演讲稿,反之则是民主党的演讲稿。

很好理解吧?那么这么简单的思路,准确性怎么样呢?对于我们的例子,这是我设置了个别停用词后的结果:

+-----+-------+--------------+--------------+-------+-------+
|   ID|  Class|        P(Red)|       P(Blue)|  Guess| Status|
+-----+-------+--------------+--------------+-------+-------+
|    1|   Blue|  -23204.68377|  -22998.67066|   Blue|      √|
|    2|   Blue|  -16438.44625|  -16137.48257|   Blue|      √|
|    3|   Blue|  -33468.81214|  -32567.61801|   Blue|      √|
|    4|   Blue|    -8606.2193|   -8601.50426|   Blue|      √|
|    5|   Blue|  -12430.97436|  -11935.70662|   Blue|      √|
|    6|   Blue|  -44033.02883|  -43877.55367|   Blue|      √|
|    7|   Blue|   -16947.2851|  -16758.57542|   Blue|      √|
|    8|   Blue|  -26957.26997|  -26889.62444|   Blue|      √|
|    9|   Blue|  -27503.73985|  -27249.21828|   Blue|      √|
|   10|   Blue|   -20528.4457|   -19991.1248|   Blue|      √|
|   11|   Blue|  -20337.96493|  -19860.12831|   Blue|      √|
|   12|   Blue|  -28409.28489|  -28118.98017|   Blue|      √|
|   13|    Red|  -13756.01015|  -14488.11751|    Red|      √|
|   14|    Red|  -17221.22732|  -17710.15936|    Red|      √|
|   15|    Red|  -17397.45136|  -17899.98659|    Red|      √|
|   16|    Red|  -10724.69095|  -11092.77837|    Red|      √|
|   17|    Red|  -10402.40027|  -10859.48681|    Red|      √|
|   18|    Red|   -9371.53792|    -9669.6769|    Red|      √|
+-----+-------+--------------+--------------+-------+-------+
|                                      Total: 18/18, 100.0% |
+-----------------------------------------------------------+

100% 的准确率哦!事实上,对于我们的这个例子,就算不设置停用词,我们仍能达到 100% 的分类准确率。朴素贝叶斯分类的确很 NB 呢。

更进一步

我们的探索到这里就结束了,但如果你有兴趣,完全可以继续探索下去。我们的例子是一个非常简化的例子,在实际情况中,还有很多问题需要解决。比如,对于中文及类似语言,不存在拉丁语系的天然分词结构,而朴素贝叶斯的文本分类是基于单词的,那么中文的分词就会是个问题;再比如,对于朴素贝叶斯分类来说,词语之间的顺序是不影响分类结果的,但这就会导致“今天心情很好,但昨天不好”和“昨天心情很好,但今天不好”在朴素贝叶斯看来是一样的,要想获得更好的结果,我们必须考虑词语的顺序。

这些问题,今天我们就不再深究了,但你可以自己探索。比如,引入思路同样很简洁的马尔科夫链,我们就可以让计算机学会考虑词语间的顺序,不过那就会是另一个话题了。

]]>
https://flyhigher.top/develop/1989.html/feed 19
MDx 中增强页面可访问性的细节 https://flyhigher.top/develop/1912.html https://flyhigher.top/develop/1912.html#comments Sat, 25 Jan 2020 16:45:04 +0000 https://flyhigher.top/?p=1912

这篇文章本来是打算发在 MDx Blog 的,不过写到后来发现这篇文章相对来说还是有价值的,于是就将这篇文章转移到了无垠,MDx Blog 则不再发表这篇文章。

从 MDx 开发之初,增强页面的可访问性就被列为了一个重要目标。从 HTML 语义化,到 ARIA 地标,再到 prefers-reduced-motion,随着浏览器对于增强页面可访问性的支持功能越来越多,MDx 也在不断地更新,让更多的人都能毫无困难地浏览、查看由 MDx 渲染的网页。本文会详细说明一些 MDx 中增强页面可访问性的细节,如果你希望增强自己开发的网页的可访问性,本文可能会对你有所帮助。

基础性增强

增强可访问性,最重要也是最基础的一点就是 HTML 语义化。通过使用 HTML 5 语义化标签和 ARIA Role 标签,我们可以很方便地将整个网页在语义上分为几部分,而这将有效地改善屏幕阅读器对网页的分析质量,使视力障碍用户更好地浏览网页。

HTML 语义化

HTML 语义化属于增强页面可访问性的基础中的基础。通过使用语义化的 HTML 标签,我们可以方便地为网页中的各部分标记功能而不影响页面样式。随着符合 HTML 5 的现代化网页成为主流,语义化标签已被很大一部分网站所支持,我也不过多赘述了。就举几个常用的“地标性”标签吧:

  • header 标签标记页眉,默认样式同 div,不要把它和 head 搞混了
  • main 标签标记页面的主要内容,默认样式同 div
  • article 标签标记文章(在 HTML 5 中它指任何独立或可复用的文本),默认样式同 div
  • footer 标签标记页脚,默认样式同 div
  • nav 标签标记页面导航栏区域,默认样式同 div
  • aside 标签标记侧栏,默认样式同 div
  • section 标签标记“一组内容”,你可以把它看作是划定网页中一块区域的通用标签,默认样式同 div

只要用这些标签替代 div 而不是无脑使用 div 就能提高页面的可访问性。MDx 使用了这些标签来增强页面的可访问性。

下一步,ARIA

有的时候,出于一些原因,我们没法使用语义化的 HTML 标签,或者没有合适的语义化标签来标记内容,这时候我们就需要使用 ARIA (Accessible Rich Internet Applications,可访问的富互联网应用)。ARIA 的基础用法很简单,只要在相应元素上加上 role 属性指定该元素的功能即可。下面是一个简单的例子:

<div role="navigation">
  <ul>
    <li><a href="proxy.php?url=#">Page A</a></li>
    <li><a href="proxy.php?url=#">Page B</li>
    <li><a href="proxy.php?url=#">Page C</a></li>
  </ul>
</div>

通过 role="navigation",我们把一个 div 标记为了导航栏区域。实际上,对屏幕阅读器来说,这和 nav 标签在语义上是一致的。上一节中的例子都可以分别用以下 ARIA Role 表示:

  • role="heading"
  • role="main"
  • role="article"
  • role="contentinfo"
  • role="navigation"
  • role="complementary"
  • role="region"

除此以外,MDx 还使用了 role="banner"role="search" 来分别标记页面中的顶部栏和搜索表单。另外,有的时候 role 属性还需配合 aria-* 属性一同使用,具体规则比较复杂,我就不在这里赘述了,具体可参考 WAI-ARIA Roles - MDN

媒体查询来帮忙

CSS 的媒体查询其实是一个非常强大的特性,妥善利用其中的一些特性,我们可以在网页外观上大大增强网页的可访问性。除了已经烂大街的“对浏览器窗口大小”响应的媒体查询,我会介绍 3 个很少有网站充分利用的媒体查询。

print

print 媒体查询其实已经“历史悠久”了,只要使用 @media print {...} 就可以创建只在打印页面时生效的样式。通过这种方式,我们可以让页面在打印时应用一套为打印优化的样式,增强页面在物理纸张上的可访问性。

维基百科是对打印样式做了单独适配的著名例子。

由于在打印的情况下,页面已经离开了“可交互”的范围,还有分页这种在屏幕中无需考虑的问题,要让页面在物理纸张上仍能被轻松地阅读,我们需要做一些特殊的适配。直接来看一个例子吧。以下是 MDx 针对打印情况进行的一些特殊优化,样式的具体理由已经写在了注释中。

@media print {
    /*隐藏不必要的元素*/
    header, .mdx-ad-after-article, .mdx-ad-in-article, .mdx-post-money, .page-footer-nav, .mdx-same-posts, .spanout button, .mdui-drawer, .mdui-overlay, .mdui-menu, .mdx-share-img-dialog, .mdx-share-wechat-dialog {
        display: none!important;
    }

    /*使用下划线标记链接*/
    article a {
        text-decoration: underline!important;
    }
    /*由于纸张不可交互,在链接后显示链接到的 URL*/
    article a::after {
        content: "(" attr(href) ")";
    }

    /*强制白底黑字*/
    html > body {
        background-color: white!important;
    }
    article p {
        color: rgba(0,0,0,.8)!important;
    }

    /*对于纸张上的长文章,衬线字体拥有更好的可读性。以下字体列表来自 fonts.css*/
    article {
        font: 13pt Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif!important;
    }

    /*除部分元素外,禁止在元素内部分页*/
    * {
        page-break-inside: avoid;
    }
    article p, article a, #comments, article {
        page-break-inside: auto;
    }
}

MDx 自 1.9.3 版本起已经包含了对文章页的打印样式优化。可以很容易地看出,优化后的样式在物理纸张上将更容易阅读。

prefers-color-scheme

prefers-color-scheme 是最近才被浏览器广泛支持的一个媒体查询,但它的功能却很强大:响应系统级黑暗模式。这样,我们可以在开启黑暗模式的系统中让网页具有更好的可读性。不过由于这个媒体查询直到最近才被广泛支持,因此大部分网站对此并没有进行适配。

一些适配这一特性的网站:少数派、微信公众平台(文章页)等。

通过像这样的媒体查询,我们可以在系统启用黑暗模式时让页面实时响应:

@media (prefers-color-scheme: dark) {
    body {
        background-color: #212121;
        color: rgba(255, 255, 255, .7);
    }
}

prefers-color-scheme 一共有 3 个可能值:no-preference, lightdark。你也可以不像上面的例子那样默认使用亮色样式并在黑暗模式下启用黑暗样式,而可以反过来将黑暗样式作为默认,用亮色样式覆盖。在不支持的浏览器上将会显示默认样式(即媒体查询不会生效)。

MDx 已经在 1.9.6 版本中包含对这一媒体查询的支持。

prefers-reduced-motion

看名字就猜得出,prefers-reduced-motion 是和 prefers-color-scheme 师出同门的媒体查询。因此,和 prefers-color-scheme 一样,它也是在最近才获得了浏览器的广泛支持。它的作用也很强大:响应系统的“减弱动态效果”模式。这对某些不喜欢甚至无法接受动画的用户可能很有帮助。要知道,对有些用户来说这甚至是医疗上的必要。使用如下的媒体查询,我们可以在系统启用减弱动态效果模式时让页面实时响应,减弱动画:

@media (prefers-reduced-motion: reduce) {
    /*只是一个示例,实际情况中可以保留一些淡入淡出效果*/
    * {
        transition: all 0s!important;
        transform: none!important;
    }
}

prefers-reduced-motion 只有 2 个可能值:no-preferencereduce,在不支持的浏览器上该媒体查询不会生效。

MDx 已经在 1.9.6 版本中包含对这一媒体查询的支持。

黑暗模式适配

黑暗模式可不是简单地切换到黑底白字这么简单。单纯地使用黑底白字会导致页面元素之间对比度过高,文字刺眼,反而降低了页面的可访问性。我在下面总结了几条应用在了 MDx 中的黑暗模式优化规则和 Google 推荐的优化规则:

一是避免黑底白字。黑底白字看起来真的很难受,事实上,深灰和浅灰是黑暗模式最好的配色。比较下方例子里的两段文本,毫无疑问深灰色背景和浅灰色文字搭配的样式的可读性要比黑底白字高很多。(p.s. 有时候这条规则在白底黑字时也适用)

二是更改部分元素的亮度和对比度以增强可读性。你可能已经注意到了,下面优化的例子里标题文字的颜色改变了。很明显,未优化的黑暗模式中标题可读性不如优化的版本。在实际中,我们通常可以通过 CSS 滤镜来实现这一更改。

@media (prefers-color-scheme: dark) {
    h1 {
        /*反相颜色后再将色相旋转 180 度,使亮度反相*/
        filter: invert(1) hue-rotate(180deg);
    }
}

三是避免让图片过亮。未经处理的图片通常会导致过高的对比度,降低可读性。在 MDx 中,我选择降低图片的亮度来避免图片刺眼,而 Google 的建议则是使图片灰度,不过这会影响图片的整体观感。无论是哪一种方案,都可以使用 CSS 滤镜来轻松实现。

@media (prefers-color-scheme: dark) {
    img { 
        /*MDx 方案*/
        filter: brightness(.8);
        /*Google 方案*/
        filter: grayscale(.5);
    }
}

Cats

这里是未优化的黑暗模式预览。接下来,让我们来看点可爱的猫猫吧。

Cats

这里是经过优化的黑暗模式预览。接下来,让我们来看点可爱的猫猫吧。

其他细节

还有一些细节,虽然简单,但不可或缺。我把一部分没有在上文提到的 MDx 中的细节列在了这里。

一是允许页面缩放。在开发响应式页面时,很多网站会在页面头部添加这样一个 meta

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

这样的 meta 设定了页面的宽度,却也固定了页面的缩放比例,用户难以手动放大网页。这样的设置对一部分视力障碍用户非常不友好,以至于苹果决定在自家的浏览器上忽略禁止缩放的 meta。按照 Google 的推荐,如果你在 MDx 设置中启用了“允许页面缩放”,那么 MDx 会输出这样的 meta 标签,允许页面进行五倍以内的缩放:

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">

尽管很多时候放大页面会导致页面结构被破坏,但这大大增强了页面的可访问性,利大于弊。因此,在 MDx 中,“允许页面缩放”是默认启用的。

二是在跨设备阅读时同步阅读记录。这属于 MDx 的“元老级”功能之一。注意到页面右上角的那个“在其他设备上继续阅读”的按钮了吗?如果你滚动页面后重新点击它,你会发现二维码发生变化了。通过在二维码中记录阅读进度,用户在扫描二维码切换设备阅读时,MDx 在新设备上会自动滚动到和原设备上一致的位置——好吧,目前基于百分比的算法在某些情况下可能不太准,但我已经设计了一个更复杂但更精确的阅读进度记录算法,之后就会更新到 MDx 中。

三是避免 Web 字体阻塞渲染。如果你在页面中使用了 Web 字体,在字体加载完成之前,浏览器可能不会渲染任何文本,这就导致用户可能需要等待一段时间才能开始浏览网页。Google 推荐使用 font-display: swap; 来向浏览器指定网页的 Web 字体渲染策略。这一规则会提供一个比较短的等待周期,如果在这段时间内 Web 字体没有完成加载,那么浏览器会以 fallback 字体渲染文本。任意时刻一旦 Web 字体完成渲染,浏览器就会使用 Web 字体重新渲染文本。这在大多数情况下都能满足可访问性的需求。

四是确保 html 元素具有合适的 lang 属性。这一属性声明了页面的主要语言,浏览器可以据此调整渲染策略,或者触发自动翻译;屏幕阅读器也能更好地阅读网页。

五是确保页面主要内容在丢失/禁用了 Javascript 甚至 CSS 的情况下仍然可用。作为一个以内容为主的网站,MDx 在禁用了 Javascript 的情况下页面主要内容仍然可用。这不只是为了 SEO,更是为了保证页面的可访问性。的确,目前的情况下,如果开启了 Lazyload,MDx 在禁用 Javascript 的情况下图片的确无法显示,但我会在后续版本中尽可能的优化这一问题。

附录:媒体查询开发技巧

有的时候,光有 CSS 响应媒体查询还不够,我们可能需要在 Javascript 中实时响应媒体查询。用如下代码就可以很简单地响应媒体查询:

let mql = window.matchMedia("(prefers-reduced-motion: reduce)");
mql.addListener(handleMediaChange);

//在添加监听后先调用一次,获得当前查询结果
handleMediaChange(mql);

function handleMediaChange(mql){
    if(mql.matches){
        //查询匹配
    }else{
        //查询不匹配
    }
}

在开发上述媒体查询的过程中,我们需要频繁地切换系统的相应模式或者调出打印窗口。实际上,Chrome 提供了更简单的调试方法:

只要在这里更改模式,相关媒体查询就能实时生效。这不会影响其他标签页,在关闭开发者工具之后也会自动恢复系统设置。

]]>
https://flyhigher.top/develop/1912.html/feed 16
GitHub 2019 统计无垠版 https://flyhigher.top/develop/1564.html https://flyhigher.top/develop/1564.html#comments Sun, 12 Jan 2020 13:02:00 +0000 https://flyhigher.top/?p=1564 缘起

2019 年底,我突发奇想想要自己统计一番 GitHub 上存储库的一些信息。尽管 GitHub 有自己的年度统计,我还是想试着自己爬取一下,说不定能挖出什么有意思的信息,何况这是我第一次有机会处理比较大量的数据,也算是一种学习的过程吧。于是花了一下午写了个简单的爬虫(时间都花在增加 Rate Limit 上了...),然后扔在了两台服务器上开始跑(GitHub  API 真是好文明)。

从 2019.11.21 3 时到 2020.1.12 24 时的 52 天 21 小时内,我的爬虫一共爬取了与 2,024,705 个用户有关*的 14,651,923 个公共存储库的基础信息,平均每秒爬取 3.2 个存储库。据 GitHub 的官方数据计算,我爬取了大约 15% 的存储库信息,但由于封禁库和私有库的数量未知,我暂时无法确定我爬取的存储库占公共库的比例。同时由于存储库数量较多,基本可以认为爬取到的样本在时间尺度上已经足够随机,可以通过统计得出一般结论。

爬到自己了,合影.jpg

那么下面就来看看统计出来的一些结果吧。爬虫的技术细节和数据集下载放在文末。

* 指这些用户拥有至少一个库

统计信息

以下统计结果均基于已放于文末的数据集。由于爬虫爬取的时间跨度长,加上我并没有完整爬取 GitHub 所有存储库,因此我不能保证以下统计结果符合真实情况,各位可以下载数据集或自行爬取进行验证。

一些数据

3,120,930
总爬取用户数
44.7%
的存储库创建
自 Fork
6.59%
的账户被删除或
封禁1
23,913
单用户拥有最大
库数量2

1 基于用户 ID 规律合理推断
2 统计范围仅限于当前数据集

比较出乎我意料的是创建自 Fork 的存储库的占比。我预估这样的存储库占比不会太低,但没有想到能接近一半。此外单用户拥有最大库数量也大大超出了我的预期,pombredanne 这个用户名下拥有将近 24k 的存储库,不过绝大多数都是 Fork 来的,在情理之中。此外这个数据集中拥有最多存储库的账户其实是一个组织 gitpan,这个组织拥有 36,377 个存储库。

语言


首先必须要说明的是,在这个榜单中我排除了 HTML 和 CSS,因为在严格意义上它们不属于“编程语言”。如果把它们计算进来的话,分别有 640,368 个和 361,425 个存储库的主要语言分别是 HTML 和 CSS,这样在这份榜单中它们可以排到第 6 和 第 10 名。JavaScript 毫无疑问获得第一,第二名 Python 的热度则和 JavaScript 相差将近一半。此外还有 2,448,486 个存储库未能识别出语言,占比 16.7%。在上面的榜单之外,与机器学习和数据科学相关的语言还有 Jupyter Notebook 排名第 13,R 排名 20,Julia 排名 43。

说实话这份排名和 GitHub 官方的排名差别很大,除了前三名,后面的基本都不太一样。我的排名统计结果完全基于 GitHub 对于存储库的主要语言识别,加上我的存储库数据不完整,和官方的数据不同是很正常的,可以做一个参考,但大概率还是官方排名更加准确。

许可证

自 GitHub 推出许可证功能以来,拥有许可证的存储库比例升升降降,却始终没有超过一半。选择一个合适的许可证对项目的良好发展真的很有帮助,要了解如何选择合适的许可证,请参阅这篇文章

对于拥有许可证的存储库,MIT 麻省理工许可证 总是占比最大的;第二则是 Apache-2.0 许可证。此外,WTFPL 许可证也挤入了前 15 名。

星标


Star 数量毫无疑问是一个存储库受欢迎程度的体现,而 Star 数高的项目基本上大家都了解过。截至爬取结束时间,GitHub 中 Star 数最多的库是 freeCodeCamp 非常完美的驼峰命名法,其次则是著名项目 996.ICUVue 现在的 Star 数已经稳压 React 一头,Vue YES! 此外 awesome 也挤进第七名。

由于 Star 需要时间积累,新项目的 Star 数量很可能是比不过老项目的,这就会导致有实力的新项目无法出现在榜单中。因此我还统计了日均 Star 数,试图通过日均 Star 数来反映项目受欢迎的程度。由于正热门的项目会比老牌热门项目有优势,这一项的统计范围是 2020-01-01 之前的所有存储库。996.ICU 和 freeCodeCamp 再次出现在前 10 名,而最近的热门项目 wenyan 则飙升至第 2 名。此外还有 BullshitGenerator,即最近热门的“狗屁不通文章生成器”和 evil-huawei 分列第 3 和第 5 名。

说实话这个曲线比我预估的陡多了。你可能已经注意到了,这个图表的横轴不是均匀缩放的,实际上曲线要比看起来陡很多。同时受爬取方式的影响,GitHub 中 Star 数较少的存储库数量远比我爬取到的多。也就是说,实际情况下曲线远比这个图表上的陡。拿点 Star 不容易啊。

名称


终于知道了原来存储库的名称是有长度限制的...尽管最长有 100 位,大部分人还是喜欢 8 位长的存储库名。此外 1 位长的名称也比我预估的要多一些。举几个存储库名称长度为 100 的例子吧。

  • testing-something-elsetesting-something-elsetesting-something-elsetesting-something-elsetesting-some
  • acts_as_validated_config_so_app_will_not_run_in_random_situation_and_qa_gays_will_not_cry_to_you_whe
  • ............................................________-....................................-.---......
  • ----------------------------------------------------------------------------------------------------
  • nyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyannyan

...创造力有够丰富的。

最近 GitHub 上有一种奇怪的风气,那就是建立 Awesome 合集骗 Star。看起来 Awesome 存储库满地都是,甚至还出现了关于 Awesome 的 Awesome 合集这种迷惑行为。于是我统计了一下,还好,占比 0.5% 不到,Awesomer 们任重而道远呐

尽管要在 github.io 上托管网站,存储库不一定要以 .github.io 结尾,但我还是统计了一下。拥有这类存储库的用户比我预想的要少一些,看来还有很多人没有完全发挥 GitHub 的完整实力啊(比如我 Doge)。同时这类存储库在所有存储库中占比 1.12%,看起来不多,不过至少比 Awesome 多

看得出来 GitHub 对于用户名长度的限制是 1-40 位。用户名最短的 27 位占据了 A-Z 外加 - 的所有可能,而用户名最长的则是一个组织 UOIT-RESEARCH-database-information-group。不知道是巧合还是某种规律,最受欢迎的用户名长度和存储库名称长度一样,都是 8,有点意思。

创建时间


由于我并没有完整爬取所有存储库,我只能以相对值来统计每月新增存储库数量的变化趋势。在这个图表中,我将 2017-09 的数据设为了 100%。你一定一眼就能注意到 2017 年 6,7,8 月的“一柱擎天”。我第一次看到这个数据的时候的确愣了一下,不过就着这条新闻看就能明白为什么了:2017 年 6 月微软收购 GitHub。

重新确认了一下,微软收购 GitHub 比这个高峰晚了一年,目前我对这个高峰没有什么很好的解释,如果你有什么思路的话欢迎评论。

需要注意的是这张图表中我排除了 Fork 存储库,因为 Fork 存储库在 API 中的创建时间是原始存储库的时间,会影响整体趋势。此外,GitHub 中还有一个创建于 2007 年 10 月 29 日的存储库,那就是 id: 1 的...

而它的创建者正是 GitHub 的创始人之一 Tom Preston-Werner。

一些有趣的结果

当初打算自己爬的目的之一就是想看看能不能挖出什么有意思的信息,结果真的有一些不挖不知道的信息。

奇怪的存储库

在爬取到的所有存储库中,有 3 个存储库是“无主”的,即它们的 owner 属性为空。这三个存储库的基本信息如下。

ID名称ForkedStar语言许可证创建于-----72385291vscode-redprl10TypeScriptapache-2.02016-10-31 08:50:01-----181218346electron-sys10Rustother2019-04-14 03:20:56-----181391880node-sys6Rustother2019-04-15 09:33:08

更奇怪的是这三个存储库“无主”的情况还不一样。第一个存储库可以通过 /repositories 这个 GitHub API 找到, 这个链接中的第一个存储库就是它;而其余两个存储库甚至无法在 /repositories API 中找到。不过就算能在 API 中找到第一个存储库,它的 html_url,即 https://github.com//vscode-redprl 仍然是不可访问的。这可能是 GitHub 早期的一个 Bug 导致的,不过我仍然对爬虫是如何找到这三个存储库的以及这些 Stars 是哪里来的感到好奇。

奇怪的用户名

在爬虫爬了一段时间以后,API 中突然开始出现大量的以 fdp 开头的 18 位乱码作为用户名的用户。这些用户拥有的存储库和 starred 的存储库都为 0,而他们的 html_url 为 404。以下是几个例子。

  • fdpEpolGCEdQX4ZlRJ
  • fdp8XaVrdtmDZnO5pR
  • fdp8XRAGnwAOkTr2Ya

由于我的爬虫并没有超过 Rate Limit,所以我排除了这是污染数据的可能。我怀疑这可能是某种 Bot 账户,于是一边更新爬虫过滤掉了这些用户,一边给 GitHub 支持发邮件问了具体情况。过了两天 GitHub 回复我说这些是被自动判为可疑进而被封禁的用户(原话是 they have been flagged by our automated measures for detecting suspicious behavior)。想到我正在疯狂爬取 GitHub,突然害怕.webp

行吧。

技术细节

爬虫使用 Python3 编写,数据库使用 MySQL。爬虫共使用了 4 个 API Token,在两台服务器上分布式爬取。爬取思路为:

  1. 通过 /users API 遍历用户,每个请求最多获取 30 个用户信息
  2. 循环 30 个用户,分别获取 /users/<user_name>/starred/users/<user_name>/repos API 中的存储库信息,每个请求最多获取 100 个存储库信息,超过 100 个的分页获取
  3. 提取存储库信息,插入数据库。对于已存在的存储库,更新数据
  4. 获取下 30 个用户信息

由于爬取过程中我多次调整了爬虫逻辑,爬虫爬取到的用户 ID 区间并非连续的,具体区间为1-839586, 14800001-14885493 及 28965251-31161101。

免责声明

本站会尽可能地提供准确信息,但本站不对此文章中信息的准确性和即时性及带来的任何影响负责。

本站不代表 GitHub 官方,本文仅供学习之用,请不要将本文内容直接用于任何商业项目中。

数据集下载

导出的 SQL 文件大约为 1.38GB,全部放在了 GitHub 上(在危险的边缘试探.webp

此外我也提供了 MEGA 下载,链接在这里

]]>
https://flyhigher.top/develop/1564.html/feed 11
Milkice 跨年红包解谜 Writeup 及无垠跨年红包解谜答案 https://flyhigher.top/develop/1519.html https://flyhigher.top/develop/1519.html#comments Thu, 02 Jan 2020 04:29:19 +0000 https://flyhigher.top/?p=1519

又到了水文章的时间呢

先说明一下,这篇文章原本不是在计划中的,而原来说好的「在路上的两篇」文章绝对不会咕。我自己的红包答案本来打算直接塞在年度总结里的,结果发现稍微有些长,再加上我还花了一些时间解了奶冰的红包解谜游戏,就打算把两个解谜过程放在一起单独写一篇文章。真是个水文章的好理由(被打死

冰的红包

奶冰的红包题目比我的难多了,所以让我们先从难的开始吧。首先看一下题目

入口是 happy.2020.milkice.me,扔到浏览器里面表示找不到 IP,那么大概率就是 DNS 的 TXT 记录了,查一下得到 nb2hi4dthixs63ljnrvwsy3ffzwwkl3tivrvezluomxxg5dsmvqw2ltqmnqxa3th

第一眼没看出来是什么编码,直觉是 BaseXX 系列的编码,加上提示“不只有 Base64”,可以推断出是 Base32,转大写解码得到 https://milkice.me/sEcRets/stream.pcapng。是个 URL,下载得到一个 Wireshark 文件,打开一看是和某 USB 设备的通讯抓包。不会做,不做了。

奶冰提示我“Google 上搜得到”,结合第二条提示搜索“wacom pcapng”找到了这篇文章这™就是答案啊)。看起来是一样的套路,即通过解析数据得到数位板上画的内容,于是按文章的思路筛选出数位板发给主机的数据,导出,然后再用 tshark 导出文本数据。本来我想直接用文章中的 POC 的,结果折腾半天因为源的蜜汁原因库没装上,于是按文章中给出的数据结构自己写了段代码,分别导出 X、Y 轴数据扔进 Excel 画散点图。(别问我为什么用 Excel,因为我手边没有更好的工具了

翻车现场之一

懵逼奶冰.webp

看得出有手写文字,不过是上下颠倒的,翻转图片得 https://milkice.me/2020/uso.zip。是个 zip,下载打开一看,有密码。不会解,不做了。 第一反应是暴力解密码,但是暴力跑了 15 分钟还是没有跑出来。暴力太不优雅了,肯定不是正确思路,但奶冰什么都不肯说了。不过在这时我终于发现第一个红包口令码就藏在压缩包的注释中,Get。

然而题还没解完,回去看提示,看到提供了 osu 谱面下载,于是打算不管怎么样先下载来看看。然而 osu 谱面下载要登录,只好又花了几分钟找到第三方站点,成功下载。这时候可以注意到 uso.zip 里有个同名文件(432314 Mike Greene - Bill Nye the Science Guy Theme Song (Chinese Intro).osz),可以联想到是明文攻击。好吧,上 ARCHPR,十秒解出。

现场还原.jpg

.osz 文件并不是明文,猜测是压缩包(和 .pptx 一个思路嘛),于是改后缀 .zip 顺利打开。然后红包口令码就藏在奶冰的 .osu 文件中了,由提示可知是明文,打开即可得到。

我的红包

相比起来我的题目就简单很多了嘛...三天时间一共发出了 25 个红包,包括一位重复领取的🌚,比前几年还是多了一些的。

这位同学以后请不要多次领取🌚

那么来看看答案吧。

第一阶段

=CE=B1=D1=85t=D0=BEn.=D1=96m/=D1=81=D0=BEd=D0=B5_=D0=BEf_2020_=E2=92=88m=CF=81=D0=B7

首先观察字符串,可以发现 . / _ 等一些 ASCII 字符原样留在了字符串中,而其他字符被由 = 和十六进制构成的某种编码所替代。如果你正好有所了解,你应该能认出这是 Quoted-printable 编码,这是一种常用于邮件中的编码,可以用这个页面解码。如果你不了解,你也应该可以发现这种编码和 URL 编码非常相似。如果你把 = 替换为 %,那么你就可以用各种方法顺利地把它按 URL 编码来进行解码,最后可以得到 αхtоn.іm/соdе_оf_2020_⒈mρз。这个字符串里大部分字符都不是 ASCII 字符,只是长得像而已。根据第一个提示“全是 ASCII 字符”,按样子把字符串用 ASCII 字符抄一遍,可以得到最终 URL 为 axton.im/code_of_2020_1.mp3

第二阶段

下载音频,可以听出音频是经过某种处理的白噪音。白噪音的特点是各频段的功率都均匀分布,在白噪音上做文章,很容易就可以联想到线索藏在频谱中。

频谱被处理成了 24x3 的网格,结合第二条提示“24x3,横向读取”可以推断出是将频谱当作二进制读取,得到 01111001 01110010 01100011 00101110 01101101 01100101 00101111 00110010 00110000,转为 ASCII 字符可得 yrc.me/20

第三阶段

打开上一步得到的页面,提示要求输入十位数密码。

如果你在上一步注意到了这个音频文件奇怪的属性,你可以反应过来这里的密码就是 2000202020

如果你没有注意到,查看页面代码可以看到一条注释 <!-- Tips: the attribute of the audio --> 将你引导向音频文件的属性。输入密码,解锁即可进入下一步。

第四阶段

可以看到是一张图片,内容为“.JPG”和“.ZIP”。

和去年一样的套路,下载图片,将后缀名改为 .zip 然后打开,可以看到 README.txt。

最后一步

查看 README.txt 的内容,可以了解到需要去 🤔.axton.cc 获取红包口令码。

扔到浏览器里会出现奶冰同款找不到 IP,于是可以联想到 TXT 记录。唯一的问题是 🤔 这个 Emoji 需要如何处理。域名使用 Punycode 将 Unicode 字符转换为 ASCII 字符,特点是以 xn-- 开头。如果你把上面这个域名扔进浏览器,你会发现它变成了 xn--wp9h.axton.cc。然后查询 TXT 记录,即可得到红包口令码。

]]>
https://flyhigher.top/develop/1519.html/feed 10
更新到下一代服务器架构 https://flyhigher.top/develop/1245.html https://flyhigher.top/develop/1245.html#comments Sun, 04 Nov 2018 10:02:56 +0000 https://flyhigher.top/?p=1245 才不是更新到XXX博主呢

缘起

前几天看到 网红羊驼 LWL 大佬的一篇文章 更新至第三代服务器环境 | Docker 萌新体验记,正好随着手头的服务器变多,多服务器管理越来越麻烦,我自己也在更新服务器的架构,就想着写一篇文章讲讲我实现的(和打算实现的)新架构 来水一把,于是有了这篇文章。

设计

LWL 给他的架构取了一个听起来很高端的名字叫 LFS,那么我也给新架构取了个名字叫 ABS 防抱死制动系统 其实是 Axton Base System,既然是下一代那么就可以叫 ABS Next Generation,简称 ABS-NG,嗯,听起来骚多了。

跑偏了。

我把所有服务分为两类:基础服务模块服务。基础服务是 Nginx、PHP、MySQL、Radis 和 Python 这些每一台服务器上都有、频繁使用而又不会经常修改的服务。为了确保稳定性和方便配置,这些服务使用的是常规编译方式直接安装。而模块服务是一些可能只部署在部分服务器上的程序,为了方便部署,大部分模块使用了 Docker。

基础服务没什么好讲的,唯一要花点心思的就是编译参数了。这一点有很多大佬写过文章了,我也就不赘述了。而模块服务才是新架构的重点。我们讲讲其中的部分模块。

这些模块也分为两类,中心模块分布式模块。听起来很高端,其实它们只是运行的地方不同而已。中心模块运行在作为“控制中枢”的一台内网的服务器上,方便控制所有其他服务器;分布模块运行在公网服务器上,只服务于这一台服务器。先来讲讲分布式模块。


SSL 证书管理模块

这个模块从最早的那个 Python 检查 SSL 证书 项目发展而来,现在已经可以做到扫描指定目录下的 Nginx 配置文件来自动管理 SSL证书,包括自动检查、自动申请和续期(使用 acme.sh)等。这个模块基于 Python3 的 Docker 镜像修改而来。

不过如果直接作为分布式模块部署,这个模块还会出一些问题,如 LWL 在文章中所说:

其次还有自动证书签发问题。目前的自动证书换新机制是由各服务器各自为政进行签发,如果进行分布式部署,这样做势必会导致在一个周期内签发的证书数量大幅上涨,带来潜在的安全问题。同时,分布式部署会导致无法预知对特定请求进行响应的服务器,因此无法使用 web 方式进行签发(无法确定 Let’s Encrypt 的请求会被分配到哪个服务器);而如果使用 DNS 方式签发,则多服务器同时续签时将有可能导致互相覆盖验证记录,同样存在问题。

所以这个模块还有一个中心模块(后面会讲到),它们能够协同工作。

基础服务更新模块

作为一个重度懒癌患者,每次 Ngnix 更新时我都会陷入“想更新而懒得更新”的矛盾心理。于是我花了点时间写了这个模块,它可以做到定时检查基础服务软件的更新,并在更新可用时提醒我。一旦确认,它就会使用我的常用编译参数编译安装新版本并自动升级。同时,一旦编译中出现问题,它也会自动停止并回滚到最近版本。至于为什么不全自动更新...我怕不经过我的确认它可能会炸...(事实证明会炸的总是会炸,后面会说到_(:з」∠)_ )

这个模块由一系列 Shell 脚本和 Python 脚本构成,为了方便更新基础服务而没有使用 Docker。相关脚本我会在整理后放在 Github 上。

服务健康度检查模块(开发中)

要什么 UptimeRobot! 由于不想过于依赖第三方服务,我计划了这个模块。这个模块会部署在所有服务器上,可以互相检查其他服务器上公开服务的可用性,也会收集自己所在服务器的 CPU 使用率、内存使用率、网络流量和进程数等一系列信息,还会分析 Nginx 的日志,并把收集到的所有信息上传给中心服务器上的中枢模块(后面会讲到)。

这个模块本质上是一个 Python 脚本,依然基于 Python3 的 Docker 镜像构建。

管理模块(开发中)

其实这个模块应该叫做被管理模块,因为这是中心服务器控制其他服务器的入口。当然安全性方面花了一些工夫,比如限制访问 IP、权限检查、高危操作通知和延时执行等。这个模块可以管理同一台服务器上的所有模块和基础服务,甚至包括管理模块自己。中枢模块高度依赖于这个模块来管理各服务器。

这个模块计划也由一系列 Shell 脚本和 Python 脚本构成,很没有新意地 仍然基于 Python3 的 Docker 镜像构建。

分布式模块讲得差不了,当然还有一些比较私人的模块就不讲了(逃

接着讲讲中心模块。


凭证管理模块

这个模块已经计划很久了,这一次趁架构更新就直接上了。这个模块的开发动力是我发现由于我使用的 API 越来越多,手头积了许多 API 的 SK,但是并没有一个好的方式妥善保管,经常找不到一个 SK 又不敢重新申请,怕不知道在哪里使用着的旧 SK 作废。

于是我花了些时间写了这个模块,它可以帮我妥善保存各种 SK,并通过我封装好的简单的接口在其他程序里调用,也可以通过 API 或者图形界面添加新的 SK。所有数据会每天定时加密后同步到其他服务器的 Radis 中以保证程序调用足够快速,通过对应密码解密即可获得数据。

比如在 Python 中这样就可以调用:

import secretdb

sk = secretdb.get(sk_name, password)

当然保存到不只有 SK,还有...(突然警惕

这个模块只有一个 Python 脚本,基于 Python3 的 Docker 镜像构建。

SSL 证书管理中心模块

这就是上面提到的 SSL 证书管理模块的中心模块啦。不像 LWL 大佬设计的方案这么高端,在我最初的设想中中心模块的主要任务是负责维护和同步主要的几张通配符证书,这几张证书可以覆盖我绝大部分的域名,而同步使用 rsync。分布式的 SSL 证书管理模块会负责将这些证书部署到正确的位置,当然也会管理其他证书。

当然现实没有这么简单。在准备开始写这个模块的时候我才突然意识到:特么内网服务器不通公网,证书怎么自动续期啊!!!但又不想只是为了续期证书而开公网导致安全性降低,最后只好凑合一下,把主要证书的续期工作分配给其中一台公网服务器上的 SSL 证书管理模块,中心模块只负责把新的证书 rsync 回来并 rsync 到其他服务器上以及保存和更新证书的 Key...(菜了好多啊)

和分布式的 SSL证书管理模块一样,这个模块基于 Python3 的 Docker 镜像构建。

通知管理模块(开发中)

Axton 特慢邮寄系统了解一下

这个模块是所有服务的通知中心,所有要发送的通知都会先到达这里再被发送出去,目前支持邮件(阿里云接口)、短信(腾讯云接口)和 Webhook 三种通知方式。由于使用的第三方接口都有频率限制(尤其是短信),这个模块自己维护了一个通知队列,可以做到合并时间相近的通知、适时延迟低优先级通知等来保证通知发送不会触发频率限制。

这个模块目前仍在开发,技术栈还有待简化(目前在 Nginx 的 Docker 镜像里面塞了 Python 和 PHP)。不过我的目标是有一个简单可维护的技术架构并且可以提供像凭证管理模块那样简洁的 API。

import absnotification

notification_id = absnotification.send(title, content, {send_mode_dict}, priority)

和中心 SSL 证书管理模块一样,这个模块也有不连公网没法用的问题,最后在一台公网服务器里塞了一个转发模块凑合了一下。

中枢模块(开发中)

这大概已经不能算是一个模块了,因为它太复杂了。作为整个新架构的核心它又分为了许多子模块,其实是一大堆 PHP、Python 脚本、Julia 脚本和 Shell 脚本粗暴地直接塞在中心服务器的 Nginx 后面,还有好几个 Docker 镜像(比如 Julia)。

这个模块的核心是提供一个 Web UI 来方便地管理所有其他服务器,所以功能复杂且安全要求高(所以要咕咕很久了...)。Web UI 并不直接暴露于公网,而是要通过 VPN 进入内网才能访问。Web UI 计划提供性能监控和预警、网站和数据库管理、Docker 管理、文件管理和基础服务管理等等一大堆功能(然后意识到等于是自己写了一个服务器面板...)。稍稍介绍下已实现的和打算实现的功能吧。

这个系统做了单点登录,这意味着在任何一个系统内的子管理面板登录都会在整个系统中登录。仍在开发中的权限管理系统会按照登录的入口不同赋予单次会话最小的权限,并在需要其他权限进行操作时向用户请求授权,尽可能地保证系统的安全性。

目前接近完成的部分大概只有网站管理和基础服务管理了。不过个人认为最棒的一个功能就是自动网站迁移了。(就算只有我一个人用我也要写 UI!界面是第一生产力!(逃)

只要这么一拖,整个网站的配置、文件和关联的数据库都会自动迁移。

复制也可以。甚至在计划接入域名服务商的 DNS API 做到自动切换域名指向(但阿里云的 DNS API 似乎不能直接从内网访问,需要回头测试下)。

基础服务管理现在能做的只有启动、停止、重启、重载和更新某个基础服务。

这就是上文提到过的“一旦确认就会自动升级”。

在这个模块的开发中我第一次大规模使用了 Julia 语言开发一部分功能(对就是前段时间火过一阵子的那个语言)。Julia 开发起来真的很方便,要不是我不太熟练这个语言开发可能会更舒服,尤其是分数计算和复数计算不要太好用,模块中很多数学方面的计算都用 Julia 来实现了。

此外在网页设计中,我一开始使用的是轮询来保证页面上的数据能够实时更新,但是后来随着页面越来越复杂,轮询变得越来越多;同时由于每个页面都不一样,也不方便合并轮询,干脆上了 WebSocket。然而 WS 的服务端又很折腾,最后花了很久才完成了个别几个项目的 WS。

不过话说回来,这个模块的复杂也导致新架构的“Bug 首杀”就发生在这个模块里,这也导致我不得不在小高考的前一天抱着仅有的手机躲在学校的自习教室里疯狂找 Bug...

事故的起因是我接连数天收到来自 SSL 证书管理模块的通知,其表示包括博客主站在内的多个站点的证书即将到期。理论上证书应该在触发通知之前(也就是到期 15 天之前)就会自动续期,因此我意识到有什么东西出问题了。这几个站点的证书用的都是同一张,即是由中心 SSL 证书管理模块管理的那张通配符证书。一番操作之后我发现在中心服务器上的那张证书已经正确地被更新了,其他服务器也正确地同步了新的证书。

这就奇怪了,证书没问题,难不成是 Nginx 的锅?我先是 Reload 了一下 Nginx,发现无效;又 Restart 了一下,还是不行, Nginx 发回的始终是已经不存在了的老证书。

折腾半天之后,终于在与 Zohar 大佬交流一番后被一句“nginx -s stop && nginx 有这样子重启过?”猛然点醒然后跑去从 SSH 进服务器执行了一下。

嗯,启动的时候报错称端口绑定失败。这时候我已经意识到问题所在了,应该是在更新 Nginx 完毕后旧进程没有正确退出导致将近大半个月以来在跑的一直是旧的 Nginx 进程...因为 Nginx 最近一次的更新是我第一次从上面说到的“基础服务管理”里面操作的(就是录上面那个 GIF 图的时候),后来发现不巧的是更新脚本多打了一个“.”导致新版本编译成功后旧的进程没有正确退出;后来 Reload 等操作也是通过“基础服务管理”进行的,在逻辑里所有输出都被抛弃了所以根本看不到错误输出...

于是手动杀掉旧进程重新启动新的 Nginx,再手动触发一次 SSL 证书检查,发现证书正常更新了...

我发现这种低级错误的时候真想把自己打一顿...于是之后修改了更新脚本和“基础服务管理”功能,加上了输出内容的获取,至少现在不会出这种太低级的错误了。

Zohar: 我依然喜欢命令行,面板难免有 Bug

最后还是命令行救了我,真香


其他

当然还有一些是为新架构服务的内容。随便讲两个吧。

Docker 管理

看起来我在这个新架构里面用了很多 Docker,但我真的是 Docker 萌新什么都不会...一开始装 Docker 的时候从包管理器安装的版本还有问题死活启动不了,后面又被 Network 的配置折腾得死去活来。现在 Docker 的管理就是朴素流了,Docker Compose 一把梭,Swarm 什么都是不存在的。

由于我比较菜,虽然写了面板来管理 Docker,但这仅限于一些简单的操作。由于没有时间仔细看文档来开发一些“进阶的” Docker 管理功能,我最后不得已部署了一个 Portainer 镜像来对 Docker 做一些进阶设置。

Setup.sh(开发中)

像 LWL 一样,为了方便在新的服务器上快速部署环境并向中心服务器注册以便被控,我也写了一个部署脚本 Setup.sh。不同于 LWL 的 Deploy.sh,我的目标是可以利用这个脚本实现通用的、可配置的环境部署(通过 JSON 进行配置)。目前我还在完善这个脚本,之后会开源在 Github(但愿不会咕咕咕)。


呼,终于总结的差不多了。如果你能看到这里那么恭喜你坚持看完了上面 5000+ 字的菜鸡总结。这个新架构的目标是尽可能实现自动化,但是在实际操作后才发现有的时候不用心的自动化会也是 Bug 的来源...通过这个大坑自己学到了不少新知识, Docker、Shell 脚本什么的,但也发现要学习的还有很多。这个新架构也还需要很长时间的开发,至少短期内是不可能完成的了,希望可以和我一起慢慢进步吧。

水完撒花!溜了溜了

]]>
https://flyhigher.top/develop/1245.html/feed 44
AOSP 使用报告 https://flyhigher.top/develop/1176.html https://flyhigher.top/develop/1176.html#comments Thu, 02 Aug 2018 04:35:11 +0000 https://flyhigher.top/?p=1176 起因

我日常使用的手机是一台小米 5,已经使用了 2 年了,最近掉电越来越快简直到了没法用的地步,而且就算是 820 也经常性出现卡顿。本来想干脆换成 1+6,但是在升高三这个节点上父母并不允许我换手机,多次交涉之后也只愿意给我一台吃灰的移动合约红米 Note 5A...由于并不想拿 Note 5A 做主力机使用(何况那块 720p的屏幕我看着都快瞎了...),又考虑到升高三之后我碰电脑的机会寥寥,手机作为我唯一的开发工具必须要适合长期使用,于是按着刷机养老的心态打算把两台机子都刷上 AOSP 养老。

于是我首先给米 5 换了块新电池,又花了点时间还借了同学的小米账户才在一星期内把两台手机都解锁了并刷上了 LineageOS。在使用了一个月左右的时间后,我觉得有必要把使用的感受写出来,好让观望党 入坑 能更好地了解 AOSP 使用上的的优缺点。

所以本文主要面向想尝试 AOSP 的小伙伴们。由于我也不是熟练玩机选手,欢迎各位大佬提出意见。

时效性信息:本文写于 2018.8,可能不再会更新。

配置

被刷机的有两台,分别是:

  • 小米 5:骁龙 820/3GB LP-DDR4/64GB/1080p/5.15'
  • 红米 Note 5A:骁龙 435/4GB LP-DDR3/64GB/720P/5.5'

都刷入 LineageOS。由于后者目前并没有 Official 的 ROM 包,我只在 xda 上找到了一个个人编译的、基于 LineageOS 14(也就是 Android N)魔改的版本;而前者有 Offical 的包,自然就选择刷入最新的 LineageOS 15.1 (Android 8.1.0),Android P 就算了。同时均刷入了 OpenGApps 和 Magisk(暂时没有刷入 Xposed),桌面使用的是 Lawnchair,一款我在使用 MIUI 时已经在使用的桌面,能做到几乎完整的 Pixel 体验。

目前米 5 依然是我的主力机,而 Note 5A 作为备用机,一个月的使用中真正使用的频率较少,因此本文着重介绍 Android O 的使用体验。

优点

最大的优点大概就是掉电终于不快了。曾经我的米 5 在中度使用下只能撑 5 个小时左右,而就算完全不使用,一晚上也能掉电 60% 左右,而现在米 5 在中度使用和 Note 5 在极轻度使用的情况下一天不充电使用完全没问题,用电数据大概是这样:

当然是要打压某些国产毒瘤才可以实现如此省电。我使用氪金版黑阈来压住后台并使用 MyAndroidTools 禁用某些广播接收器、服务和活动,基本只有启动某些毒瘤的时候才会有稍快的掉电,使用中的耗电也可以接受。介于我不常开启微信/QQ,这样的效果我完全可以接受。

权限管理我使用氪金版 AppOps 配合系统自带的权限管理使用,基本可以确保权限安安稳稳呆在那里,至少比国产 ROM 好用。

最喜欢的两个功能是主动显示和 SmartLock。先来说主动显示,虽然我知道一加是有这项功能的,但是 ADUI...这项功能可以在有通知到达时让屏幕稍稍亮起,完全不会打扰我,而需要时也可以快速查看通知。虽然米 5 并不是 AMOLED 屏效果并不是非常好,但是还是非常好用。同时除了通知亮屏,主动显示也有一些其他功能:

第二个是 SmartLock,尤其是面部识别在 Note 5A 这种后置指纹的手机上简直不要太好用,放在桌子上时都不需要拿起来,只要探个头就可以解锁手机。不过其他功能感觉使用情况比较少,就没有使用。同时米 5 前置指纹也足够好用了,就没有使用面部识别。虽然 MIUI 有面部识别,不过稳定版万年没等到...我还是用 AOSP 吧。

除此之外,相机也非常好用。当然系统自带的相机只能用表现平平来形容,不过通过 Magisk 模块“Camera2 API Enabler” 可以启用 Camera2 API,通过安装 Google Camera 获得 HDR+ 加持,还有单镜头焦外虚化之类的功能,还能生成 360° 照片和全景照片等(不过米 5 的陀螺仪有点问题,合成出的照片拼接不齐,也不方便评价了)。

样片没有,不过 Google Camera 的照片就算不能说惊艳,也能说是足够优秀了。

除这些之外,AOSP 最吸引人的地方大概在于正版的 Material Design 体验了。尤其是彩色音乐通知不知道比国产 ROM 一票好看到哪里去了(虽然 Android N 并没有)。还有一些 Android 新特性也可以使用了,比如 App Shortcuts 、快捷回复之类。

国产有的我也有

总有人担心某些好用的功能会在 AOSP 缺失,事实上很多功能还是存活的。比如来电悬浮通知,甚至还有空间清理(吉字节的翻译真的没话说)。

NFC 模拟卡也可以通过 Root+应用来解决,可以做到点亮屏幕,把手机凑到刷卡器前即可解锁,不过不能模拟公交卡(不过之前 MIUI 也不支持我所在的城市,我这里仅支持二维码扣费,并不好用)。

屏幕校准依然存活,甚至更加好用,还有阅读模式简直棒哉。下图中左侧是米 5,右侧是 Note 5A。

通话录音也有,不过根据更新日志这项功能曾经被移除过...后来又加回来了。

一些不足

明显缺点大概只能分成两部分,一个是基础功能不足,一个是推送的缺陷。至于的国产毒瘤的打压,自然可以用一票优化软件解决,不能算作是不足。此外有两个小问题,一个是屏幕亮度自动调整并不好用,另一个是长时间使用 4G(连续几天的那种)后偶尔会掉到 2G,必须关闭再打开移动网络才行,不过我认为这大概是机型特别的问题。

系统自带的系统应用非常的基础,浏览器基本不能用,文件浏览器功能非常弱,相册过于简单,相机表现平平,默认短信应用没有我最需要的验证码复制功能,甚至缺失一些应用,比如指南针,又很难找到合适的替代品(Google Play 中的要么界面丑陋,要么有广告,啧)。Google Play 上很多国内需要的软件找不到,必须再使用一款国内的应用商店,或是寻找应用的官网下载。

解决方案大概是:浏览器用 Chrome 代替,或者用你喜欢的其他浏览器;文件浏览器我使用 Solid Explorer 并解锁了完整版本,可以连接云盘、加密文件等,超级好用。

应用商店使用酷安,不过我一般会先前往 Google Play 查找,没有再转战酷安。至于验证码复制,我没有刷入 Xposed,暂时无解。

在没有安装其他推送框架的情况下,一票国产软件在推出后台的情况下基本没法推送,我的日常使用中基本只有 Telegram 可以正常推送(因为是 FCM)。不过我日常也只是使用 Telegram,这些问题对我来说并不严重。这其实只能算是国内特色,不能算作是系统的锅。

基本就这些。在一个月左右的使用中,我感觉这是真正达到了我认为“用着舒服”的水平(当然小飞机必不可少),不用担心电量掉光,不用纠结于 820 都会卡,还可以获得系统真正的控制权,很棒,养老够了。

]]>
https://flyhigher.top/develop/1176.html/feed 25
村通网之也谈 HTTPS https://flyhigher.top/develop/1093.html https://flyhigher.top/develop/1093.html#comments Sun, 03 Jun 2018 02:50:44 +0000 https://flyhigher.top/?p=1093

这是一种能保证你花掉工资/零花钱时的愉悦感的技术

从咕果谈起

就在不久之前,咕果宣布 Chrome 将在不久之后提前移除对于 HTTPS 网站的“安全”标识,同时对于 HTTP 网站强制显示“不安全”。虽然咕果表示目前 HTTPS 的普及已经达到他们认为足够的地步了,但是我看着国内的进展...啧。

为什么要使用 HTTPS?HTTPS 提供的不只是加密功能。事实上,许多 Web 新技术,如 HTTP/2Service Worker 等都需要依托 HTTPS 才能实现,相信这也会成为未来的趋势。

什么是 SSL?

从网络模型来看,SSL 在 HTTP 应用层之下,TCP 传输层之上提供了一层传输安全层,确保了经过传输层的数据都是经过加密的。

SSL 作为 HTTPS 的基础最早由网景于 1994 年提出。虽然 HTTP 协议本身提供了基本认证和摘要认证(后来提出)两种客户端认证方案,但是毕竟除了用户名和密码外的所有数据仍然是明文传输的(甚至基本认证只对密码信息做了 Base64 编码),要保证传输的安全性还得依靠 SSL。SSL 本身是一种二进制协议,避免了随随便便一个代理就能读取/修改数据的问题。

随着技术发展,SSL 本身也在不断改进。从 SSL 1.0、SSL 2.0 到 SSL 3.0,再到标准化后的 TLS(目前 TLS 1.3 刚刚定稿),这个协议本身也在完善当中。在本文中,我们按照一般惯例使用 SSL 一词同时指代 SSL 和 TLS 两种协议。

翻开历史书

加密方法离不开数学基础,为了更好地解释加密原理,我们先来了解一下加密的基础知识。当然,如果已经了解了就直接跳过吧。

人类使用加密已经有几千年的历史了。最初,人们使用特定的方法处理文本使其在被截获之后也不会泄露信息。我们通常把这种特定的方法叫做密码。传说凯撒曾使用过一种三位位移密码,也就是将文本中的所有字母替换为这个字母在字母表中后三位的字母。这种方法是一种密码。也就是说,密码 C 接受明文 R 一个参数,输出密文 E。

$$E = C(R)$$


然而,使用单一密码的加密非常容易被破译,而且一旦被破译,以往和未来的所有密文也不再安全。为了解决这个问题,人们提出了新的方法也就是密钥。用同一种密码加密,输入不同的密钥就会产生不同的结果。比如上文中的三位位移密码,如果把 3 当做密钥,那么不同的密钥(1,2,3,4,5...)就会产生不同的输出。这种时候,就算密码被破译,只要密钥的可能值足够多,要得到明文还是相当困难的。也就是,密码 C 现在接受明文 R 和密钥 K 两个参数,产生密文 E。不同的 R 和 K 组合会产生不同的 E。

$$E = C(R, K)$$

然而这种方法仍然有问题。密文的接收方要解密得到明文,必须要得知密码和密钥两个信息。通常密码和密钥组合是变化的(不然就和没有密钥一样了),要正确解密,发送方和接收方每次都必须要交换密码和密钥信息,通常是只交换密钥而密码不变。但密钥存在被截获的可能,要确保安全必须对密钥进行加密,然后加密密钥也需要交换密钥,然后第二个密钥也需要加密...很快这就变成了一个鸡生蛋问题,很明显是不可能实现的。


到目前为止,我们讨论的都是对称加密,也就是加密和解密用的是相同的密码和密钥。对于无限加密的问题,使用对称加密是无解了。此时,我们的救星非对称加密就登场了。

非对称加密可以使用不同的密钥来分别加密和解密文本,它们通常是配对的,我们叫它们公钥和私钥。使用相同的密码时,接收方只需生成一对公钥和私钥,然后保管好自己的私钥并公开公钥。发送方只需要用商定的密码和接收方的公钥作为密钥来加密文本即可。加密后的文本只有使用私钥才能解开,公钥本身是无法解开的。也就是说,密码 C 对于明文 R 和公钥 PK 产生的输出只能由与密码 C 对应的解码 D 和公钥 PK 对应的私钥 SK 才能解密回明文。

$$D(C(R, PK), SK) = R$$

这里就不详述数学上的证明了,有兴趣的可以自行了解。

通过这种方式,接收方可以保证密文只能由接收方进行解码,交换公钥并不会破坏加密的安全性。更棒的是,私钥和公钥是可以互换的,即是,使用公钥 PK 加密并使用私钥 SK 解密得到的结果和使用私钥 SK 加密并使用公钥 PK 解密得到的结果是一致的,都是原始明文。

$$D(C(R, PK), SK) = D(C(R, SK), PK) = R$$

这样的话,我们甚至可以使用非对称加密验证接收方的身份。毕竟只有接收方拥有私钥,可以要求接收方用私钥加密明文并试图用其公钥解密来确保对方就是拥有正确私钥的那一方。如此看来,有了非对称加密,似乎一切都完美了。

等等!

你可能会发现一些问题。如果有一个聪明的中间人(或者说网络中的某一台代理),他可能可以轻松破解非对称加密。

对的!试想以下的场景: A 和 B 作为两位优秀的魔法少女常常需要互通信件,然而不巧邪恶的 E 同学总是试图偷看 A 和 B 的对话,于是 A 和 B 决定使用非对称加密来加密信件以解决问题。

然而 E 同学想出了一个办法。A 向 B 发送信件时,B 会先向 A 发送她的公钥以便 A 加密文本,这时 E 将其截获,记下 B 的公钥然后自己生成一套公钥和私钥并将自己的公钥伪装成 B 的信件重新发送给 A。不知情的 A 会用 E 的公钥加密文本并发回给 B。E 可以再次截获信件,此时他利用自己的私钥便可以轻松读取信中的内容了。然后 E 将信件重新使用 B 的公钥加密并发送给 B,这样 B 也不会发现异常,认为自己成功与 A 完成了一次沟通,殊不知 E 已经读取到了信的内容。当 B 要向 A 寄信时,E 可以如法炮制读取 A 和 B 的对话。

怎么办才能避免 E 读取内容呢?幸好,我们有一种方法可以在 E 偷偷摸摸更换密钥时就发现问题并及时停止。要使用这种方法,我们需要有请我们的下一位选手登场。

欢迎,公正的第三方

现在,作为第三方的 C 登场了。他会作为一个验证人帮助信件的接收方验证收到的信件有没有经过篡改以及是不是真的由 B 发出的。

整个过程这样进行。C 先生成一对公私钥,然后将公钥亲自告诉 A 和 B。A 发信给 B 时,C 会提前把 B 的公钥和一些 B 和 C 的基本信息放在一起,然后用自己的私钥加密这段信息并将密文交给 B。B 随后只需将这段密文交给 A 即可。A 随后会试着使用 C 的公钥解开密文,如果成功了,那就说明信件没有经过篡改,并且 A 也安全地获得了 B 的公钥。如果失败了,就说明有人篡改了这段密文,信件即作废。当 B 给 A 寄信时将整个过程反过来即可。看,这样我们就可以安全的交换密钥而不用担心内容被窃听了。可怜的 E 现在束手无策。

说好的 HTTPS 去哪了?

别激动,其实 A、B、C、E 的故事就是一次典型的 HTTPS 连接。

慢慢来,实际情况会比上面的故事稍稍复杂一些。我们把 A 看作客户端,通常情况下也就是浏览器,B 看作服务器,E 看作网络链路上某台邪恶的代理,C 是一位公正的第三方,顺便,我们把 C 交给 B 的那段含有一些基本信息和 B 的公钥的密文叫做数字证书。现在,我们可以把 C 叫做证书颁发机构。

在真实情况下,数字证书会包含一些加密的基本信息。毕竟数字证书并没有统一的标准,这些加密信息会用来告诉 A 如何解密数字证书。当然这些信息不会被加密,不然 A 完全无法解密数字证书。因此现实情况下,C 会对数字证书包含的所有内容建立一个摘要(你可以把它当做简介),然后只对这个摘要用自己的私钥进行加密。这个过程叫做签名。当 A 收到证书后,会解密这段摘要,然后自己通过相同的算法独立算出证书的摘要——如果是一样的,那就没问题了。


那么现在开始连接。A 向 B 发出了一个请求。在建立连接后,B 会将数字证书发送给 A。A 会验证数字证书(甚至会询问 C 确保这张证书没有作废)。如果成功,A 会利用从证书中取得的 B 的公钥在加密环境下协商出一个一致的临时密钥,接下来双方会用这个密钥进行对称加密来互相交流。

等等!在这种情况下,B 没有 A 的公钥,他是怎么回答 A 来完成临时密钥的协商的呢?啊哈!实际上 B 并不需要回复 A 来完成协商。在整个过程中,只需要 A 向 B 发送一条加密信息,双方即可计算出相同的临时密钥。至于技术细节,我们稍后就谈。

那为什么需要临时密钥呢?那是因为非对称加密需要的计算量远大于对称加密。只需要保证密钥不泄露,对称加密是安全的。利用非对称加密来交换对称加密密钥完美解决了密钥交换问题,两种加密方法的混合使用也保证了安全和性能的平衡。

这就完成了!现在 A 和 B 可以使用相同的密钥进行对称加密通信在这个基础上,A 和 B 可以使用标准的 HTTP 协议进行交流。同时由于没能截获密钥,可怜的 E 同学再次扑了个空。我们成功完成了一次加密的 HTTPS 通信。

接下来就是技术细节!

在加密建立的过程中,算法不同会导致实际实现的不同——尽管并没有太大差别。

简单来看,我们可以将其分为 RSA 算法和 DH 算法。两种算法建立加密的过程我们都会介绍。


开始时两种加密算法的实现是一致的。浏览器准备发出一个请求到一台服务器。首先浏览器打开了到该服务器 443 端口的 TCP 连接,准备开始 SSL 握手。

随后,浏览器生成一串随机数并附上自己支持的协议版本和加密方法等一并发送给服务器。协议版本即指 SSL/TLS 协议版本,而加密方式是指实现加密使用的具体算法,也就是密码。这个过程被称为 ClientHello

接着,服务器会发回确定下来的协议版本和加密方式,以及一串由服务器生成的随机数及数字证书。此时,如果双方没有共同支持的协议版本或加密方式,连接就会断开,浏览器显示错误信息。这个过程被称为 SeverHello

浏览器会检查服务器证书。数字证书目前没有一个统一的标准,不过使用最广泛的标准是 X.509,版本 3。浏览器会解析证书,检查其签发者是否是可信的,并会试图使用该签发者的公钥来检查证书是否经过篡改。浏览器还会检查证书是否到期、证书包含的主机名和当前主机名是否匹配,甚至会向证书颁发机构的在线服务询问证书是否被注销。一旦有任何一条检查出现问题,浏览器就会中断连接并显示错误信息。

浏览器是如何检查证书颁发机构是否可信的?基本上,在每一台联网设备里,都有一套可信机构列表。这些机构和它们的公钥被直接存储于每一台计算机中,因此不用担心被篡改(还记得上面的故事里 C 是亲自告诉 A 和 B 他的公钥的么?)。当然,用户可以添加自己的证书来让浏览器信任自己签发的证书,但这仅限于单台计算机。通常,这些在列表中的机构还会为其他机构签发证书。此时,这些其他机构签发的证书也会被系统所信任,这便构成了证书链。

通常情况下,浏览器还会检查一些其他信息,如证书透明度信息等,就不再概述了。


不同算法的不同实现在这里开始出现分歧。我们先来介绍 RSA 算法的实现。

在这时,浏览器产生整个过程中的最后一个随机数,并将它使用服务器的公钥加密,发送给服务器,即 Client Key Exchange

这时,双方会通过已有的 3 个随机数各自计算出临时密钥。如果没有差错,双方的计算结果应该是相同的。随后,双方会互发 Change Cipher Spec 消息确认接下来的数据传输已经可以切换到加密方式。

为什么需要随机数?因为数字证书本身是静态的,要保障安全性,我们需要每次连接时都有不同的密钥,这个密钥便是由双方提出的随机数计算而得的,这种方式增强了连接的安全性。


对于 DH 算法,此时服务器会利用私钥将浏览器生成的随机数、服务器随机数和服务器 DH 参数签名,生成服务器签名,并随后发送服务器 DH 参数和服务器签名,这是 Server Key Exchange

紧接着浏览器也会发送客户端 DH 参数(Client Key Exchange),随后双方即可独立计算出临时密钥,切换至加密协议(Change Cipher Spec)。


最后,两种加密算法的实现又变得一致。双方会互换这一轮握手过程中发送/接收到的信息的散列值以确保在握手过程中没有数据被恶意替换,这是 Finished

通过抓包可以看出两种实现的不同。(图源网络)

RSA

DH

到这里,浏览器和服务器已经成功建立了安全的 SSL 连接。接下来,标准的 HTTP 协议就可以由 SSL 传输安全层负载进行传输了。从 TCP 传输层来看,这些数据是原始的二进制数据,即使有人截获也无法将其解密。

还有一些小问题...

通常情况下,你要访问一个网站,浏览器会在发给服务器的请求中附带一个 host 字段来告诉服务器要访问哪个网站。这对于一个服务器上托管了多个网站的情况(很常见)很有用,服务器可以立刻明白你要访问哪个网站并把对应的数据发回浏览器。

但是,在访问使用 HTTPS 的网站时,SSL 握手的过程中浏览器并不会告诉服务器要访问哪个网站(这是在 HTTP 协议下发出的,而 HTTP 通信会在 SSL 连接建立之后才开始)。这会导致服务器不知道你需要哪张证书且往往会将错误的证书发送给你。这通常会导致证书验证失败。

为了解决这一问题,人们提出了 SNI,一个 TLS 拓展协议。SNI 会在 SSL 握手时就向服务器发送要访问的主机名,以便服务器发回正确的证书。目前绝大部分服务器软件和客户端都支持这一拓展协议。


在 SSL 加密开始后,在 TCP 传输层上传输的就是完完全全的二进制数据。对于代理来说,这不是什么好事。对于这样的数据,它们完全不知道应该向哪里转发。

当然,盲转发代理是不可取的。盲转发是指对数据包不做处理直接转发的行为。这会彻底地破坏 HTTP/1.1 中引入的长连接,在拖慢速度的同时还大大加重的服务器的负担。因此通常情况下代理会解析 HTTP 头以获得一些连接的基本信息。

一种解决方案是使用 HTTPS SSL 隧道协议,这通常是使用 HTTP CONNECT 方法实现的,具体细节就不再详述了。除此之外也有很多方法,有兴趣可以自行了解。

附录:HTTPS 部署指北

活学活用了解一下。

现在,要为你的网站申请证书已经变得非常简单了。虽然上文描述的过程有些复杂,但所幸你不用亲自实现所有功能。主流的服务器软件只要经过一些简单的配置即可启用 HTTPS。通常,你只需要先申请,待证书签发后部署到服务器即可。


部署 HTTPS 的第一步是挑选符合要求的证书签发机构和合适的证书种类。不同的签发机构之间,以及同一机构签发的不同种类的证书之间,所需的价格是不同的。

证书种类是什么?真实的情况的确比上文提到的情况要再复杂一点。总的来说,证书分为 DV、OV、EV 三类。DV 证书只能提供最基本的加密功能,价格最低;而 OV 和 EV 证书会在签发前验证申请者的身份,流程更繁琐,价格也更高,但是这类证书可以提供验证网站身份的功能(比如浏览器会对不同种类的证书提供视觉上的差异,见下图),而且在出现安全问题时(比如签发机构的私钥泄露,虽然几率很小,但是有可能)的赔付金额也更高。

上:Github 使用的 EV 证书   下:本站使用的 DV 证书

对于签发机构,我推荐个人博客类网站还是选择 Let's Encrypt,这是由 Mozilla、思科、Akamai、IdenTrust 和 EFF 等组织发起的公共、免费的 SSL 项目。它所签发的 DV 证书完全免费,而且支持通配符和多域名。唯一的缺点是它只支持 3 个月的有效期,不过到期后可以免费续期。

什么是通配符?浏览器在验证证书时,会确保证书中指定的域名和当前访问的域名相符,而根域名(a.com)和子域名(b.a.com)会被当做不同的域名来处理,不同的子域名(b.a.com 和 c.a.com)也会被当做不同的域名。通配符证书(*.a.com)可以同时匹配不同的子域名,减少了所需签发证书的数量。但是,根域名和通配符子域名不会匹配,都需要签发证书。同时,Let's Encrypt 支持的多域名可以在同一张证书中包含多个域名,大大减少了所需证书的数量(如下图)。

可以匹配 axton.cc 和 flyhigher.top 根域和所有二级域的证书

要使用 Let's Encrypt 签发,我们可以使用 acme.sh,这是一个基于 Let's Encrypt 接口封装的开源命令行工具,支持多域名/通配符的申请,也支持自动续期。使用 acme.sh,我们可以使用这样的命令来申请证书:

~/.acme.sh/acme.sh --issue --dns dns_ali -d axton.cc -d flyhigher.top -d *.axton.cc -d *.flyhigher.top

关于 acme.sh 的详细信息可以前往 Github 了解。


稍等片刻,当证书签发下来,我们往往会得到两个文件,一个是你的私钥,一个是证书。

等等,这个私钥哪里来的?通常情况下,为了方便申请,用于申请证书的工具有时会自动为你生成一对私钥公钥,然后将私钥转交给你,而将公钥交给签发机构。当然大多数情况下你可以选择自己生成,然后将公钥提交给签发机构,自己保存好私钥。

这时,我们就可以把证书放到服务器上了,然后配置服务器来开启 HTTPS。不同服务器的配置过程通常不同,我也不再赘述了。如果你使用 Nginx,可以使用如下配置。详细信息可以查阅我的另一篇文章。

server{
    listen 80;
    listen 443 ssl http2;
    # 其他配置...

    # 如果是 HTTP 就跳转到 HTTPS
    if ($server_port !~ 443){
        rewrite ^(/.*)$ https://$host$1 permanent;
    }

    # 证书和私钥路径
    ssl_certificate /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    # SSL 参数
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
    ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:TLS13-AES-128-CCM-8-SHA256:TLS13-AES-128-CCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!DH:!DHE;
    ssl_prefer_server_ciphers on;
    ssl_session_timeout 1d;
    ssl_session_cache builtin:1000 shared:SSL:10m;
    ssl_buffer_size 1400;
    ssl_stapling on;
    ssl_stapling_verify on;

    # 其他配置...
}

当然,如果是面板之类的...大多有 SSL 的管理入口,依照提示部署证书即可。

最后,我们还需要对网页文件进行修改,尤其是页面上所有引用的资源必须同样使用 HTTPS,否则浏览器就不会显示安全小绿锁。

此文是对我了解到的一些 HTTPS 相关知识的总结,难免会有纰漏,还请各位斧正。

]]>
https://flyhigher.top/develop/1093.html/feed 32
新的个人页以及对 Nginx 启用 Brotli、TLS 1.3和 Server Push https://flyhigher.top/develop/1042.html https://flyhigher.top/develop/1042.html#comments Thu, 22 Feb 2018 10:37:14 +0000 https://flyhigher.top/?p=1042 趁着春节假期,作业一点没做,倒是抽空写了一个新的个人页。

Axton: About Me

到现在 axton.cc 这个域名的根域总算是启用了,之前一直在用二级域根域扔着没管。

个人页算是个假 shell,支持少数常用命令及历史命令和 Tab 补全。几乎所有操作都在前端完成,只有在获取博客最新文章时会和后台交互。用的是 WordPress 的 RESTful API,真的是一个超好玩的 API。CORS 靠反代解决,反代加了一层缓存减轻源站压力。

至于为啥不直接连后端而是去写1k+行的 js...

说到 WordPress,WordPress 最近被发现一个 Dos 漏洞,开发团队拒绝修复,于是自己补了一下。

然后突然 Nginx 1.13.9 就释出了,添加了 Server Push。在发现 Server Push 相关的提交之后就一直眼馋地盯着 1.13.9,在释出之后一些认识的大佬陆续升级了 Nginx,我盯着自己万年没升级 Stable 版本,最后还是下定决心升个级,顺便添加了 Brotli 支持和 TLS 1.3 的支持。

以下是正文

首先添加 Brotli 支持。

$ git clone https://github.com/google/ngx_brotli.git
$ cd ngx_brotli
$ git submodule update --init

由于系统自带的 OpenSSL 版本普遍偏低,需要自行下载一份 OpenSSL 给 Nginx 用。目前浏览器支持的大多是 Draft 18,所以 clone 的也是 Draft 18 版本。

$ git clone -b tls1.3-draft-18 --single-branch https://github.com/openssl/openssl.git openssl

接下来安装 Nginx。先下载最新版本。

$ wget http://nginx.org/download/nginx-1.13.9.tar.gz
$ tar zxvf nginx-1.13.9.tar.gz
$ cd nginx-1.13.9

然后执行 nginx -V 获取旧版本 Nginx 的编译参数,再输入:

$ ./configure --add-module=../ngx_brotli --with-openssl=../openssl --with-openssl-opt='enable-tls1_3 enable-weak-ssl-ciphers'

引用屈哥的说法:

enable-tls1_3 是让 OpenSSL 支持 TLS 1.3 的关键选项;而 enable-weak-ssl-ciphers 的作用是让 OpenSSL 继续支持 3DES 等不安全的 Cipher Suite,如果你打算继续支持 IE8,才需要加上这个选项。

再加上之前的编译参数,然后 make

由于之前我启用了旧版本 lua-nginx-module,编译会出错,换用新版本编译即可,不再赘述。

为了平滑升级,先备份旧版 Nginx。

$ mv /path/to/nginx/sbin/nginx /path/to/nginx/sbin/nginx.old

再把编译好的版本拷过去。

$ cp objs/nginx /path/to/nginx/sbin/

接着测试一下有没有问题。

$ /path/to/nginx/sbin/nginx -t

没问题的话会输出:

nginx: the configuration file /path/to/nginx.conf syntax is ok
nginx: configuration file /path/to/nginx.conf test is successful

那么接下来要平滑重启 Nginx。首先找一下 nginx.pid 在哪(如果你忘了的话)。

$ find / -name "nginx.pid" -print

then

$ kill -USR2 `cat /path/to/nginx.pid`
$ kill -WINCH `cat /path/to/nginx.pid`
$ kill -QUIT `cat /path/to/nginx.pid`

没问题的话就可以用 nginx -V 测试一下了。

Done!

然后编辑 Nginx 配置文件。在 nginx.conf 里加上:

brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;

这样就启用了 Brotli 支持。接着修改站点配置文件来支持 TLS 1.3。

ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:TLS13-AES-128-CCM-8-SHA256:TLS13-AES-128-CCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!DH:!DHE;

 

TLS13 开头的为 TLS 1.3 新增的 Cipher Suite。

到此为止 Nginx 已经支持了 Brotli 和 TLS 1.3。至于 Server Push 的用法,最简单的用法是在站点配置文件中加入:

http2_push /path/to/file1;
http2_push /path/to/file2;

进阶用法可以参考 Nginx 官方的 这篇博文

那么来检查一下吧

要检查 Brotli 支持,只要使用 Chrome,然后在开发者面板检查响应头。如果成功地启用了 Brotli,就可以看到:

这说明 Brotli 已经启用。再检查 TLS 1.3。Chrome 需将 chrome://flags/ 中的 TLS 1.3 改为 Enabled (Draft);Firefox 需将 about:config 中的 security.tls.version.max 改为 4。Firefox Developer Edition 已经默认启用。然后检查连接的安全信息即可看到:

或者使用 SSLLabs 也可以检查:

对于 Server Push,可以在浏览器的开发者面板中检查。

(本图来自 nginx.com,版权属于 NGINX Inc. 因为不知道为啥我没有在自己的博客页面上找到标记为来自 Server Push 的资源,尽管我已经正确(但愿)配置了 Nginx)

(逃

]]>
https://flyhigher.top/develop/1042.html/feed 24
MDx:轻于形,悦于心 https://flyhigher.top/develop/788.html https://flyhigher.top/develop/788.html#comments Mon, 06 Nov 2017 12:56:58 +0000 https://flyhigher.top/?p=788

MDx,一款轻快、优雅且强大的 Material Design 风格 WordPress 主题

欢迎来到 MDx 的世界。

English Version & English Docs are being translated.

目前 MDx 最新版本 v1.9.9。
请从 这里 下载 MDx 的最新版本。

MDx QQ 交流群687577787,欢迎来水~

Feature 特性

  • 完全的 Material Design 风格,每一个像素都赏心悦目,还可切换到 Material Design 2 风格
  • 3 种首页样式,5 种文章列表样式,3 种页脚样式 & 3 种文章页样式随意切换,未来还可能增加更多
  • 19 种主题颜色 & 16 种强调色随心搭配,还有夜间模式
  • 不仅有黑暗主题,更有专为 OLED 屏幕优化过的黑暗主题可选
  • SEO 友好,支持 Facebook / Twitter 结构化卡片分享
  • 丰富的设置选项
  • 一键生成分享图片,分享文章更美观
  • 内置文章目录功能
  • 内置 7 种短代码,与主题样式完美契合
  • 独家功能 实时搜索,搜索栏会随用户输入实时反馈搜索结果
  • 独家功能 不仅可以生成当前页面二维码,方便地转移到其他设备上阅读,还可以在转移时同步阅读进度
  • 多语言支持(简体中文、土耳其语、英语)
  • 完全响应式
  • 自带简单的 ImgBox
  • 支持小工具
  • ...

Demo 演示

此博客即使用了 MDx 主题。要了解 MDx 对于文章内容的展示情况,请查看 这个页面

注意,本站不仅是一个演示站点,还是我个人折腾及进行一些新技术试验的站点,有的时候本站效果会和公开版本的主题有一些差异甚至出现 Bug。因此在部分情况下本站效果并不能完全代表主题效果,如有不确定的情况可以自行下载主题并试用,或者访问 MDx Blog 查看未经特殊优化的 MDx 显示效果。

Render 渲染

Docs 文档

MDx 文档
English Docs are being translated.

Download 下载

You can download MDx here.

你可以前往 此处 下载MDx。

Github


当然,求Star!

License 许可证

Open sourced under the GPL v3.0 license.

根据 GPL V3.0 许可证开源。

About 关于

感谢你对于 MDx 的兴趣。无论如何,我都诚挚地邀请你试试这款主题。MDx 是免费发布的,你不需要为此付出任何费用。但是,你必须保留底部的“Theme: MDx By AxtonYao”标识。

然而,需要注意的是,这款主题才公开发布不久,尽管我已经做了详尽的测试,但它可能仍有问题。请不要担心,遇到主题问题请联系我,一旦确认我会尽快修复。

我仅仅只是一名在校高中生,开发了 MDx 主题之后已经没有精力与时间开发新的主题,因此在接下来较长的一段时间内,我会持续地维护并更新 MDx 主题。

很抱歉的是,由于这是我第一次尝试完成如此庞大的项目,我很没有经验地使用了一些错误的开发方法——比如,变量命名,或者 CSS 选择器 的命名,都没有统一的标准。在接下来的更新中,我会不断地优化代码。

Anyway, keep learning.

请我喝杯咖啡

作为一名高中生,我一人难以支撑服务器的运营费用。如果你有兴趣,不妨赞助我一下,以支持我完成更好的开发。


感谢!

捐助者列表展示于 关于

]]>
https://flyhigher.top/develop/788.html/feed 338
自动检查SSL证书到期情况 https://flyhigher.top/develop/755.html https://flyhigher.top/develop/755.html#comments Sun, 01 Oct 2017 06:25:44 +0000 https://flyhigher.top/?p=755 这是什么鬼?

自从我换了ECS,无垠旗下的各种项目也陆陆续续换成了HTTPS协议。然而,随着HTTPS使用越来越广泛,我手头的证书也越来越多。Let's的证书还好说嘛,可以搞自动续期,但各种CDN上的证书就难搞了...何况我在各大CDN服务商都有挂域名。我才懒得检查证书有效期嘞!So,我打算利用手头的技术搞点事情。

自动检查SSL证书情况!Yeah!Demo:无垠证书透明

让我们开始吧

如何获取证书信息让我纠结了很久。因为咱都知道你申请到的证书都是一坨乱码,直接读当然不行。一开始找到一个冷门的Python轮子结果并不支持Let's的证书,直接报不信任。花了老半天终于发现curl就自带检查emmmm

执行curl 你的URL -v可以看到(一定要加https://)

curl不止会输出连接信息,还会输出下载到的全文到终端。很显然如果任由curl把全文输出到终端非常不利于我们提取出我们需要的时间数据,何况这些是输出到stderr的。因此我们需要把输出重定向到一个文件然后用cat+grep控制输出。

所以咱这次不瞎扯了,直接上手程序。Python 3坠吼了!

# -*- coding:utf-8 -*-
#依赖库只有一个:pymysql,记得安装
#需要一个MySQL数据库,数据库结构看代码下面的图
import subprocess
import os
import time
import pymysql

db = pymysql.connect("数据库地址","数据库用户名","数据库密码","数据库名")
cursor = db.cursor()
cursor.execute("SET NAMES utf8");#设置数据库连接字符集

websitelist = ['flyhigher.top','acdn.flyhigher.top'] #需要检查的域名列表,无需填写协议名,同时支持端口号。建议先在数据库里面添加相应数量的记录,每条记录仅需填写ID,因为我懒没在代码里处理
for i in range(len(websitelist)):
    comm1 = "curl https://"+websitelist[i]+" --connect-timeout 10 -v -s -o /dev/null 2>/tmp/ca.info ; cat /tmp/ca.info | grep 'start date: '" #利用curl检查证书开始时间,注意一下ca.info保存路径,connect-timeout可以控制超时时间,避免假死
    out_bytes1 = subprocess.check_output(comm1, shell=True)
    out_text1 = out_bytes1.decode('utf-8')
    comm2 = "cat /tmp/ca.info | grep 'expire date: '" #检查证书到期时间
    out_bytes2 = subprocess.check_output(comm2, shell=True)
    out_text2 = out_bytes2.decode('utf-8')
    comm3 = "cat /tmp/ca.info | grep 'issuer: '" #获取证书颁发机构
    out_bytes3 = subprocess.check_output(comm3, shell=True)
    out_text3 = out_bytes3.decode('utf-8')
    os.system('rm -f /tmp/ca.info')
    sql = 'UPDATE 数据表名 SET domain = "'+websitelist[i]+'", check_time = "'+time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())+'", s_time = "'+time.strftime("%Y-%m-%d %H:%M:%S",time.strptime(out_text1[-25:-5],"%b %d %H:%M:%S %Y"))+'", e_time = "'+time.strftime("%Y-%m-%d %H:%M:%S",time.strptime(out_text2[-25:-5],"%b %d %H:%M:%S %Y"))+'", c_info = "'+out_text3[11:-1].replace('"', "'")+'" WHERE ID = '+str(i+1) #格式化时间及证书颁发机构名称,构造SQL查询
    cursor.execute(sql)
    db.commit()
    time.sleep(1) #睡一会儿,免得太High
db.close()

OK,注释都有了。只要把这段Python扔到服务器上,搞个定时任务每天跑一遍就行了,这就不赘述了。

数据库结构如下,if_s是我用于判断敏感域名的字段,非敏感域名会被展示在前端页面。

然后呢

emmm数据都在数据库里了你还想干啥?如果有空可以搞个邮件提醒,或者像我一样做个前端展示页。记得去掉敏感域名就好。

稍后我可能会将其整理并作为一个完整的、带Web面板、支持邮件提醒的项目发布到Github上。

溜了溜了

]]>
https://flyhigher.top/develop/755.html/feed 26
PJAX,真的好吗? https://flyhigher.top/develop/684.html https://flyhigher.top/develop/684.html#comments Sun, 13 Aug 2017 01:53:29 +0000 https://flyhigher.top/?p=684 本文从实用性的角度看待PJAX技术,而非技术角度。

最近发现越来越多的网站开始使用了PJAX无刷新加载。看起来这是一种趋势:PJAX是一种优秀的技术,大家都在用。但真的是这样吗?我看未必。

别把AJAX和PJAX放在一起

AJAX和PJAX,看起来非常相似,事实上PJAX也是基于AJAX发展而来。

AJAX 是一种用于创建快速动态网页的技术。

通过在后台与服务器进行少量数据交换,AJAX 可以使网页实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

传统的网页(不使用 AJAX)如果需要更新内容,必需重载整个网页面。

AJAX的确是一种优秀的技术,它赋予了网页全新的能力,大大拓展了网页的能力范围。

  • Google地图使用AJAX实现顺畅的拖动体验,而不用在每次选定地点后都需要等待整个页面刷新
  • 评论系统使用AJAX可以让用户在提交评论的同时继续浏览页面
  • AJAX让Facebook们的点赞得以流畅实现

这样例子有很多,也很好发现,就不多举了。AJAX通过简单的异步请求,可以实现的功能非常多,能带来丰富的体验。

但是,AJAX的优秀真的代表着PJAX的优秀吗?

人们为什么使用PJAX?我们需要从使用PJAX的原始目的来看。很多网站使用PJAX的目的无非两个:

  1. 减少页面加载所需的流量
  2. 避免页面白屏,提高用户体验

看起来好像没什么问题,而且它的作用也值得让人使用它。然而,在现实中,我发现很多人其实根本没有实现这两条就匆匆实现PJAX。这样做,恐怕会适得其反。

对于第一条,有相当一部分人并没有实现,他们只是简单引用了pjax.js或者turbolinks.js等项目,看起来实现了PJAX,但实际上加载的内容仍是完整的页面,有些情况下甚至会导致更多流量。使用PJAX,正确的做法应该是只加载页面显示所需要的内容,而不是一股脑儿全加载进来。

偶然见到的网站,却是一样地滥用PJAX。这样加载PJAX与完整加载网页有什么区别?

所以事实上实现PJAX需要一个专门的API来最小化数据传输,最好可以结构化数据方便前端解析,再不济也需要对PAJX发起的请求做优化。

对于第二条,有人说流量没减少没关系,至少我们提高了用户体验。但事实是添加PJAX后很多网站用户体验不升反降。以往常规加载的方式,浏览器会帮你做很多事情比如记忆浏览到哪里啦等等,但如今PJAX要求你需要自己实现很多特性。

举个栗子,LWL12的博客blog.lwl12.com使用了PJAX,但他的加载逻辑有些小问题导致在他的网站中浏览时,每次前进和后退都会重新定位到页面顶端而不是之前浏览到的位置。而首页AJAX加载文章列表的方式会导致在不知不觉中困在某一页而无法回到上一页。这就会让人很不爽。而使用常规加载方式,浏览器会在短时间内记忆用户的滚动位置,至少前进后退时是没有问题的,而AJAX状态也不会改变。

(当然这不是在说LWL12的网站不好,LWL12网站中的内容质量还是比较高的)

所以第二条也很难成立。要做到好的用户体验不是一件容易的事,你要额外做很多以前浏览器帮你做的事,所以很多网站根本做不到。PJAX带来了各种各样的问题,反而是大大降低了体验,更不用提提升体验了。

所以,我个人认为,要么不用PJAX,要么就在保证做好上面两点后再使用。

但做到完美太难了,甚至做到接近完美也很难,所以我倾向于不要使用PJAX。

PJAX的用武之地

然而,很容易可以发现,很多大型网站已经在使用PJAX。譬如百度,它在用户输入关键词时实时猜测用户的目的并改变搜索结果。

Google地图也可以看作使用PJAX,在用户浏览地图时,地址栏中的内容会实时改变以方便用户分享位置信息。

这么看起来,好像PJAX也不说像上面说的那么不实用嘛。

PJAX的确有用武之地,但有前提,就是“在连续性服务下”提供。

百度搜索,输入关键词是一个连续性的动作,Google地图,浏览地图是一个连续性的过程。而PJAX的最佳用地,就是在连续性服务上。

连续性是一个非常主观的概念,我认为只要是需要提供流畅服务的、任何等待都会造成严重体验下降的都算是连续性服务。而PJAX可以很好地满足不停顿的要求。

比如很多小说网站,现行页面会包含大量广告而导致页面加载速度严重下降,在看小说时每翻过一章就需要等待几秒让页面加载出来。而使用PJAX,翻页时可以只加载小说内容而不用加载广告框架(小说内容加载完成后再异步刷新广告即可),可以大大提高浏览的流畅度,会舒服很多。当然前提是做好上面两点。

而普通网站呢?恐怕是不需要。尤其是大量的个人博客,做不好最基础的要求就急吼吼地部署PJAX,但这根本不是硬需。网站内容不连续,用户也不一定感兴趣,不会发生小说网站的情况,这样提供PJAX功能,只会大大削弱用户体验。

毕竟,用户来网站是为了获取服务/信息而不是来看加载动画的,组织良好的服务/内容远比简单地开启低质量的PJAX来得重要。

举个正面的例子,大牛Jerry Qu的网站imququ.com就没有使用PJAX,但他使用了大量的前端后端优化,虽然是常规的加载方式,体验却完全不输PJAX,完全没有白屏时间。这样的加载速度,内容质量又很高,远不是面向动画的网站们能比的,还需要什么PJAX呢?

Jerry Qu的网站首页在禁用缓存后全部加载只花了232ms,一共3个请求,比绝大多数使用了PJAX的网站还快得多(当然第一次访问会慢些)。

所以,再重新审视PJAX,请把它当做一个Patch,只在需要的时候使用,没有理由就不要使用了。

PJAX不是万能的,不应该被滥用。

本文原创,首发于flyhigher.top/develop/684.html,转发请注明来源。

]]>
https://flyhigher.top/develop/684.html/feed 27