与 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 Data {
    // Encode `self` with URL escaping considered.
    var base64URLEncoded: String {
        let base64Encoded = base64EncodedString()
        return base64Encoded
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

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)
    }
}

结合使用 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 的话,应该将其去除:

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

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

let xBytes: [UInt8]
if decodedXData.count == curve.coordinateOctetLength {
    xBytes = [UInt8](decodedXData)
} else {
    xBytes = [UInt8](decodedXData).dropFirst { $0 == 0x00 }
}
            
let yBytes: [UInt8]
if decodedYData.count == curve.coordinateOctetLength {
    yBytes = [UInt8](decodedYData)
} else {
    yBytes = [UInt8](decodedYData).dropFirst { $0 == 0x00 }
}

let uncompressedIndicator: [UInt8] = [0x04]
let data = Data(bytes: uncompressedIndicator + xBytes + yBytes)

创建公钥时和 RSA 类似:

let sizeInBits = data.count * MemoryLayout<UInt8>.size
let attributes: [CFString: Any] = [
    kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
    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 curve type: kSecECCurveSecp256r1, algorithm id: 3, 
// key type: ECPublicKey, version: 4, block size: 256 bits, 
// y: 3D4F8B27B29E5C77FCF877367245F3D75C2FBA806C54A0A0C05807E1B536E68A, 
// x: FFB00CF903B79BB0F6C049208A59C448049BE0A2A1AF4692C486085CBD9057EF, 
// addr: 0x7fcafd80ced0>

验证签名

Security 框架中为使用公钥进行签名验证准备了一个方法:SecKeyVerifySignature

func SecKeyVerifySignature(_ key: SecKey, 
                         _ algorithm: SecKeyAlgorithm, 
                         _ signedData: CFData, 
                         _ signature: CFData, 
                         _ error: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Bool

key 我们已经拿到了,signedData 就是之前我们准备的 {Header}.{Payload} 的字符串的数据表示 (也就是 plainText.data(using: .ascii)。注意,这里的 plainText 不是一个 Base64Url 字符串,JWS 签名所针对的就是这个拼凑后的字符串的散列值)。我们需要为不同的签名算法指定合适的 SecKeyAlgorithm,通过访问 SecKeyAlgorithm 的静态成员,就可以获取 Security 框架预先定义的算法了。比如常用的:

let ecdsa256 = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
let rsa256 = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256

你可以在 Apple 的文档里找到所有支持的算法的定义,但是不幸的是,这些算法都只有名字,没有具体说明,也没有使用范例。想要具体知道某个算法的用法,可能需要在源码级别去参考注释。为了方便,对于签名验证相关的一些常用算法,我列了一个表说明对应关系:

算法 输入数据 (signedData) 签名 (signature) 对应 JWT 算法
rsaSignatureDigestPKCS1v15SHA{x} 原数据的 SHA-x 摘要 PKCS#1 v1.5 padding 的签名 RS{x}
rsaSignatureMessagePKCS1v15SHA{x} 原数据本身,框架负责计算 SHA-x 摘要 PKCS#1 v1.5 padding 的签名 RS{x}
rsaSignatureDigestPSSSHA{x} 原数据的 SHA-x 摘要 使用 PSS 的 PKCS#1 v2.1 签名 PS{x}
rsaSignatureMessagePSSSHA{x} 原数据本身,框架负责计算 SHA-x 摘要 使用 PSS 的 PKCS#1 v2.1 签名 PS{x}
ecdsaSignatureDigestX962SHA{x} 原数据的 SHA-x 摘要 DER x9.62 编码的 r 和 s ES{x}
ecdsaSignatureMessageX962SHA{x} 原数据本身,框架负责计算 SHA-x 摘要 DER x9.62 编码的 r 和 s ES{x}

不难看出,这些签名算法基本就是 {算法类型} + {数据处理方式} + {签名格式} 的组合。另外还有一些更为泛用的签名算法,像是 .ecdsaSignatureRFC4754 或者 .rsaSignatureRaw,你需要按照源码注释给入合适的输入,不过一般来说还是直接使用预设的散列的 __Message__SHA___ 这类算法最为方便。

SecKeyAlgorithm 中除了签名算法,也包括了使用 RSA 和 EC 进行加密的相关算法。整体上和签名算法的命名方式类似,有兴趣和需要相关内容的同学可以自行研究。

对于 JWT 来说,RS 算法的签名已经是 PKCS#1 v1.5 padding 的了,所以直接将 signedDatasignature 配合使用 rsaSignatureMessagePKCS1v15SHA{x} 就可以完成验证。

var error: Unmanaged<CFError>?
let result = SecKeyVerifySignature(
    key, 
    .rsaSignatureMessagePKCS1v15SHA256, 
    signedData as CFData, 
    signature as CFData, &error)

对于 ES 的 JWT 来说,事情要麻烦一些。我们收到的 JWT 里的签名只是 {r, s} 的简单连接,所以需要预先进行处理。按照 X9.62 中对 signature 的编码定义:

ECDSA-Sig-Value ::= SEQUENCE {
    r INTEGER,
    s INTEGER }

因此,在调用 SecKeyVerifySignature 之前,先处理签名:

let count = signature.count
guard count != 0 && count % 2 == 0 else {
    // 错误,签名应该是两个等长的整数
}
var rBytes = [UInt8](signature[..<(count / 2)])
var sBytes = [UInt8](signature[(count / 2)...])

// 处理首位,我们已经做过很多次了。
if rBytes.first! >= UInt8(0x80) {
    rBytes.insert(0x00, at: 0)
}

if sBytes.first! >= UInt8(0x80) {
    sBytes.insert(0x00, at: 0)
}

// 完成签名的 DER 编码
let processedSignature = Data(bytes: 
    (rBytes.encode(as: .integer) + sBytes.encode(as: .integer))
    .encode(as: .sequence))

var error: Unmanaged<CFError>?
let result = SecKeyVerifySignature(
    key, 
    .ecdsaSignatureMessageX962SHA256, 
    signedData as CFData, 
    processedSignature as CFData, &error)

上面 RSA 和 ECDSA 的验证,都假设了使用 SHA-256 作为散列算法。如果你采用的是其他的散列算法,记得替换。

验证 Payload 内容

签名正确完成验证之后,我们就可以对 JWT Payload 里的内容进行验证了:包括但不限于 “iss”,”sub”,”exp”,”iat” 这些保留值是否正确。当签名和内容都验证无误后,就可以安心使用这个 JWT 了。

一些问题

至此,我们从最初的 JWT 定义开始,引伸出 JWA,JWK 等一系列 JOSE 概念。然后我们研究了互联网安全领域的通用编码方式和几种最常见的密钥的构成。最后,我们使用这些知识在 Security 框架的帮助下,完成了 JWT 的签名验证的整个流程。

事后看上去没有太大难度,但是由于涉及到的名词概念很多,相关标准错综复杂,因此初上手想要把全盘都弄明白,还是会有一定困难。希望这系列文章能够帮助你在起步阶段就建立相对清晰的知识体系,这样在阅读其他的相关信息时,可以对新的知识进行更好的分类整理。

最后,是一些我自己在学习和实践中的考虑。在此一并列出,以供参考。如果您有什么指正和补充,也欢迎留言评论。

为什么不用已有的相关开源框架

现存的和这个主题相关的 iOS 或者 Swift 框架有一些,比如 JOSESwiftJSONWebToken.swiftSwift-JWTvaper/jwt 等等。来回比较考察,它们现在 (2018 年 12 月) 或多或少存在下面的不足:

  • 没有一个从 JWK 开始到 JWT 的完整方案。JWT 相关的框架基本都是从本地证书获取公钥进行验证,而我需要从 JWK 获取证书
  • 支持 JWK 的框架只实现了部分算法,比如只有 RSA,没有 ECDSA 支持。
  • 一些框架依赖关系太复杂,而且大部分实现是面向 Swift Server Side,而非 iOS 的。

LINE SDK 中,我们需要,且只需要在 iOS 上利用 Security 框架完成验证。同时 Server 可能会变更配置,所以我们需要同时支持 RSA 和 ECDSA (当前默认使用 ECDSA)。另外,本身作为一个提供给第三方开发者的 SDK,我们不允许引入不可靠的复杂依赖关系 (最理想的情况是零依赖,也就是 LINE SDK 的现状)。基于这些原因,我没有使用现有的开源代码,而是自己从头进行实现。

为什么不把你做的相关内容整理开源

在 LINE SDK 中的方案是不完备的,它是 JOSE 中满足我们的 JWT 解析和验证需求的最小子集,因此没有很高的泛用性,不适合作为单独项目开源。不过因为 LINE SDK 整个项目是开源的,JOSE 部分的代码其实也都是公开且相对独立的。如果你感兴趣,可以在 LINE SDK 的 Crypto 文件夹下找到所有相关代码。

为什么要用非对称算法,各算法之间有什么优劣

不少 JWT 使用 HS 的算法 (HMAC)。和 RSA 或 ECDSA 不同,HMAC 是对称加密算法。对称算法加密和解密比较简单,因为密钥相同,所以比较适合用在 Server to Server 这种双方可信的场合。如果在客户端上使用对称算法,那就需要将这个密钥存放在客户端上,这显然是不可接受的。对于 Client - Server 的通讯,非对称算法应该是毋庸置疑的选择。

相比与 RSA,ECDSA 可以使用更短的密钥实现和数倍长于自己的 RSA 相同的安全性能。

For example, at a security level of 80 bits (meaning an attacker requires a maximum of about 2^80 operations to find the private key) the size of an ECDSA public key would be 160 bits, whereas the size of a DSA public key is at least 1024 bits.

由于 ECDSA 是专用的 DSA 算法,只能用于签名,而不能用作加密和密钥交换,所以它比 RSA 要快很多。另外,更小的密钥也带来了更小的计算量。这些特性对于减少 Server 负担非常重要。关于 ECDSA 的优势和它相对于 RSA 的对比,可以参考 Cloudflare 的这篇文章

签名的安全性

JWT 签名的伪造一直是一个困扰人的问题。因为 JWT 的 Header 和 Payload 内容一旦确定的话,它的签名也就确定了 (虽然 ECDSA 会产生随机数使签名每次都不同,但是这些签名都可以通过验证)。这带来一个问题,攻击者可以通过截取以前的有效的 JWT,然后把它作为新的响应发给用户。这类 JWT 依然可以正确通过签名验证。

因此,我们必须每次生成不同的 JWT,来防止这种替换攻击。最简单的方式就是在内存中存储随机值,发送 JWT 请求时附带这个随机值,然后 Server 将这个随机值嵌入在返回的 JWT 的 Payload 中。Client 收到后,再与内存中保存的值进行比对。这样保证了每次返回的 JWT 都不相同,让签名验证更加安全。

OpenSSL 版本的问题

macOS 上自带的 OpenSSL 版本一般比较旧,而大部分 Linux 系统的 OpenSSL 更新一些。不同版本的 OpenSSL (或者其他的常用安全框架) 实现细节上会有差异,比如有些版本会在负数首位补 0x00 等。在测试时,最好让 Server 的小伙伴确认一下使用的 OpenSSL 版本,这样能在验证和使用密钥上避免一些不必要的麻烦。(请不要问我细节!都是泪)

JWT 可以用来做什么,应该用来做什么

JWT 最常见的使用场景有两个:

  • 授权:用户登录后,在后续的请求中带上一个有效的 JWT,其中包含该用户可以访问的路径或权限等。服务器验证 JWT 有效性后对访问进行授权。相比于传统像是 OAuth 的 token 来说,服务器并不需要存储这些 token,可以实现无状态的授权,因此它的开销较小,也更容易实现和理解。另外,由于 JWT 不需要依赖 Cookie 的特性,跨站或者跨服务依然可能使用,这让单点登录非常简单。
  • 信息交换:LINE SDK 中对用户信息进行签名和验证,就属于信息交换的范畴。依赖 JWT 的签名特性,接收方可以确保 JWT 中的内容没有被篡改,是一种安全的信息交换方式。

最近有非常多的关于反对使用 JWT 进行授权的声音,比如这篇文章这篇文章。JWT 作为授权 token 来使用,最大的问题在于无法过期或者作废,另外,一些严格遵守标准的实现,反而可能引入严重的安全问题

不过对于第二种用法,也就是信息交换来说,JWT 所提供的便捷和安全性是无人质疑的。

我也想读读看相关标准

如你所愿,我整理了一下涉及到的标准。祝武运昌隆!

关于编码和算法
关于 JOSE
杂项

验证和速查工具汇总

你的这篇文章或者代码好像有问题!

我是初学者,文章中的纰漏请不吝赐教指出!

关于代码方面的不足,LINE SDK 欢迎各种 PR。但是如果您发现的问题涉及安全漏洞,或者会导致比较严重后果的话,还请先不要公开公布。如果能按照这里的说明给我们发送邮件联系的话,实在感激不尽。

更早的文章

与 JOSE 战斗的日子 - 写给 iOS 开发者的密码学入门手册 (理论)

概述这是关于 JOSE 和密码学的三篇系列文章中的第二篇,你可以在下面的链接中找到其他部分: 基础 - 什么是 JWT 以及 JOSE 理论 - JOSE 中的签名和验证流程 (本文) 实践 - 如何使用 Security.framework 处理 JOSE 中的验证这一篇中,主要介绍网络传输的密钥的编码和处理方法,以及进行数字签名和验证的基本流程。我们在之后实践一篇里,会使用到这些知识。密钥的表现形式显然 JWK 是一种密钥的表现形式,它使用 JSON 的方式,遵守 JWA 的参数...…

能工巧匠集继续阅读