205a072b7f3b3d6a922f22abb603fca0
与 JOSE 战斗的日子 - 写给 iOS 开发者的密码学入门手册 (实践)

概述

这是关于 JOSE 和密码学的三篇系列文章中的最后一篇,你可以在下面的链接中找到其他部分:

  1. 基础 - 什么是 JWT 以及 JOSE
  2. 理论 - JOSE 中的签名和验证流程
  3. 实践 - 如何使用 Security.framework 处理 JOSE 中的验证 (本文)

这一篇中,我们会在 JOSE 基础篇和理论篇的知识架构上,使用 iOS (或者说 Cocoa) 的相关框架来完成对 JWT 的解析,并利用 JWK 对它的签名进行验证。在最后,我会给出一些我自己在实现和学习这些内容时的思考,并把一些相关工具和标准列举一下。

解码 JWT

JWT,或者更精确一点,JWS 中的 Header 和 Payload 都是 Base64Url 编码的。为了获取原文内容,先需要对 Header 和 Payload 解码。

Base64Url

Base64 相信大家都已经很熟悉了,随着网络普及,这套编码有一个很大的“缺点”,就是使用了 +/=。这些字符在 URL 里是很不友好的,在作为传输时需要额外做 escaping。Base64Url 就是针对这个问题的改进,具体来说就是:

  1. + 替换为 -
  2. / 替换为 _
  3. 将末尾的 = 干掉。

相关代码的话非常简单,为 DataString 分别添加 extension 来相互转换就好:

extension String {
    // Returns the data of `self` (which is a base64 string), with URL related characters decoded.
    var base64URLDecoded: Data? {
        let paddingLength = 4 - count % 4
        // Filling = for %4 padding.
        let padding = (paddingLength < 4) ? String(repeating: "=", count: paddingLength) : ""
        let base64EncodedString = self
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
            + padding
        return Data(base64Encoded: base64EncodedString)
    }
}

extension Data {
    // Encode `self` with URL escaping considered.
    var base64URLEncoded: String {
        let base64Encoded = base64EncodedString()
        return base64Encoded
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

结合使用 JSONDecoder 和 Base64Url 来处理 JWT

因为 JWT 的 Header 和 Payload 部分实际上是有效的 JSON,为了简单,我们可以利用 Swift 的 Codable 来解析 JWT。为了简化处理,可以封装一个针对以 Base64Url 表示的 JSON 的 decoder:

class  Base64URLJSONDecoder: JSONDecoder {
    override func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
        guard let string = String(data: data, encoding: .ascii) else {
            // 错误处理
        }

        return try decode(type, from: string)
    }

    func decode<T>(_ type: T.Type, from string: String) throws -> T where T : Decodable {
        guard let decodedData = string.base64URLDecoded else {
            // 错误处理
        }
        return try super.decode(type, from: decodedData)
    }
}

Base64URLJSONDecoder 将 Base64Url 的转换封装到解码过程中,这样一来,我们只需要获取 JWT,将它用 . 分割开,然后使用 Base64URLJSONDecoder 就能把 Header 和 Payload 轻易转换了,比如:

struct Header: Codable {
    let algorithm: String
    let tokenType: String?
    let keyID: String?

    enum CodingKeys: String, CodingKey {
        case algorithm = "alg"
        case tokenType = "typ"
        case keyID = "kid"
    }
}

let jwtRaw = "eyJhbGciOiJSUzI1NiI..." // JWT 字符串,后面部分省略了
let rawComponents = text.components(separatedBy: ".")
let decoder = Base64JSONDecoder()
let header = try decoder.decode(Header.self, from: rawComponents[0])

guard let keyID = header.keyID else { /* 验证失败 */ }

在 Header 中,我们应该可以找到指定了验证签名所需要使用的公钥的 keyID。如果没有的话,验证失败,登录过程终止。

对于签名,我们将解码后的原始的 Data 保存下来,稍后使用。同样地,我们最好也保存一下 {Header}.{Payload} 的部分,它在验证中也会被使用到:

let signature = rawComponents[2].base64URLDecoded!
let plainText = "\(rawComponents[0]).\(rawComponents[1])"

这里的代码基本都没有考虑错误处理,大部分是直接让程序崩溃。实际的产品中验证签名过程中的错误应该被恰当处理,而不是粗暴挂掉。

在 Security.framework 中处理签名

我们已经准备好签名的数据和原文了,万事俱备,只欠密钥。

处理密钥

通过 keyID,在预先设定的 JWT Host 中我们应该可以找到以 JWK 形式表示的密钥。我们计划使用 Security.framework 来处理密钥和签名验证,首先要做的就是遵守框架和 JWA 的规范,通过 JWK 的密钥生成 Security 框架喜欢的 SecKey 值。

在其他大部分情况下,我们可能会从一个证书 (certificate,不管是从网络下载的 PEM 还是存储在本地的证书文件) 里获取公钥。像是处理 HTTPS challenge 或者 SSL Pinning 的时候,大部分情况下我们拿到的是完整的证书数据,通过 SecCertificateCreateWithData 使用 DER 编码的数据创建证书并获取公钥:

guard let cert = SecCertificateCreateWithData(nil, data as CFData) else {
    // 错误处理
    return
}

let policy = SecPolicyCreateBasicX509()
var trust: SecTrust? = nil
SecTrustCreateWithCertificates(cert, policy, &trust)
guard let t = trust, let key: SecKey = SecTrustCopyPublicKey(t) else {
    // 错误处理
    return
}
print(key)

但是,在 JWK 的场合,我们是没有 X.509 证书的。JWK 直接将密钥类型和参数编码在 JSON 中,我们当然可以按照 DER 编码规则将这些信息编码回一个符合 X.509 要求的证书,然后使用上面的方法再从中获取证书。不过这显然是画蛇添足,我们完全可以直接通过这些参数,使用特定格式的数据来直接生成 SecKey

有可能有同学会迷惑于“公钥”和“证书”这两个概念。一个证书,除了包含有公钥以外,还包含有像是证书发行者,证书目的,以及其他一些元数据的信息。因此,我们可以从一个证书中,提取它所存储的公钥。

另外,证书本身一般会由另外一个私钥进行签名,并由颁发机构或者受信任的机构进行验证保证其真实性。

使用 SecKeyCreateWithData 就可以直接通过公钥参数来生成了:

func SecKeyCreateWithData(_ keyData: CFData, 
                          _ attributes: CFDictionary, 
                          _ error: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> SecKey?

第二个参数 attributes 需要的是密钥种类 (RSA 还是 EC),密钥类型 (公钥还是私钥),密钥尺寸 (数据 bit 数) 等信息,比较简单。

关于所需要的数据格式,根据密钥种类不同,而有所区别。在这个风马牛不相及的页面 以及 SecKey 源码 的注释中有所提及:

The method returns data in the PKCS #1 format for an RSA key. For an elliptic curve public key, the format follows the ANSI X9.63 standard using a byte string of 04 || X || Y. ... All of these representations use constant size integers, including leading zeros as needed.

The requested data format depend on the type of key (kSecAttrKeyType) being created:

  • kSecAttrKeyTypeRSA PKCS#1 format, public key can be also in x509 public key format
  • kSecAttrKeyTypeECSECPrimeRandom ANSI X9.63 format (04 || X || Y [ || K])

JWA - RSA

简单说,RSA 的公钥需要遵守 PKCS#1,使用 X.509 编码即可。所以对于 RSA 的 JWK 里的 ne,我们用 DER 按照 X.509 编码成序列后,就可以扔给 Security 框架了:

extension JWK {
    struct RSA {
        let modulus: String
        let exponent: String
    }
}

let jwk: JWK.RSA = ...
guard let n = jwk.modulus.base64URLDecoded else { ... }
guard let e = jwk.exponent.base64URLDecoded else { ... }

var modulusBytes = [UInt8](n)            
if let firstByte = modulusBytes.first, firstByte >= 0x80 {
    modulusBytes.insert(0x00, at: 0)
}
let exponentBytes = [UInt8](e)

let modulusEncoded = modulusBytes.encode(as: .integer)
let exponentEncoded = exponentBytes.encode(as: .integer)
let sequenceEncoded = (modulusEncoded + exponentEncoded).encode(as: .sequence)

let data = Data(bytes: sequenceEncoded)

关于 DER 编码部分的代码,可以在这里找到。对于 modulusBytes,首位大于等于 0x80 时需要追加 0x00 的原因,也已经在第一篇中提及。如果你不知道我在说什么,建议回头仔细再看一下前两篇的内容。

使用上面的 data 就可以获取 RSA 的公钥了:

let sizeInBits = data.count * MemoryLayout<UInt8>.size
let attributes: [CFString: Any] = [
    kSecAttrKeyType: kSecAttrKeyTypeRSA,
    kSecAttrKeyClass: kSecAttrKeyClassPublic,
    kSecAttrKeySizeInBits: NSNumber(value: sizeInBits)
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(data as CFData, attributes as CFDictionary, &error) else {
    // 错误处理
}
print(key)

// 一切正常的话,打印类似这样:
// <SecKeyRef algorithm id: 1, key type: RSAPublicKey, version: 4, 
// block size: 1024 bits, exponent: {hex: 10001, decimal: 65537}, 
// modulus: DD95AB518D18E8828DD6A238061C51D82EE81D516018F624..., 
// addr: 0x6000027ffb00>

JWA - ECSDA

按照说明,对于 EC 公钥,期望的数据是符合 X9.63 中未压缩的椭圆曲线点座标:04 || X || Y。不过,虽然在文档说明里提及:

All of these representations use constant size integers, including leading zeros as needed.

但事实是 SecKeyCreateWithData 并不喜欢在首位追加 0x00 的做法。这里的 XY 必须是满足椭圆曲线对应要求的密钥位数的整数值,如果在首位大于等于 0x80 的值前面追加 0x00,反而会导致无法创建 SecKey。所以,在组织数据时,不仅不需要添加 0x00,我们反而最好检查一下获取的 JWK,如果首位有不必要的 0x00 的话,应该将其去除:

```swift
extension JWK {
struct RSA {
let x: String
let y: String
}
}

let jwk: JWK.RSA = ...
guard let decodedXData = jwk.x.base64URLDecoded else { ... }

top Created with Sketch.