深入解析 Expo 内置 ASN1Decoder:X.509 证书解析、SSL Pinning 与 App Store 收据验证实战
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
ASN1Decoder 是 Expo 开源仓库packages/expo-updates/ios/EXUpdates/ASN1Decoder/目录下内置的一套纯 Swift 实现的 ASN.1 DER 解码器,专门用于解析 X.509 数字证书与 PKCS#7 容器。本文以该目录的 README 为骨架,结合仓库内全部源码实现,系统讲解证书解析、SSL 公钥锁定(Pinning)与 App Store 收据验证三大场景,并深入 expo-updates 的代码签名链路,展示这套解码器在真实生产环境中的用途。读完本文,你将掌握在 Swift 项目中直接使用或参考移植这一套 ASN.1 解码能力的完整方案。
背景:为什么 Expo 需要一套 ASN.1 DER 解码器
X.509 是数字证书的标准格式,证书在编码层面遵循 ASN.1(Abstract Syntax Notation One)抽象语法,并以 DER(Distinguished Encoding Rules)作为二进制编码规则。无论是校验 HTTPS 服务器证书、做 SSL Pinning,还是解析 App Store 返回的 PKCS#7 收据,第一步都是把这些二进制 DER 数据解析成结构化对象树。
在 Expo 的 OTA 更新体系(expo-updates)中,这一能力被用于代码签名(Code Signing)验证:客户端需要解析开发者签发的证书链,读取证书中的扩展字段(如 Expo Project Information、Extended Key Usage),并校验证书有效期、CA 属性与签名。你可以在 CertificateChain.swift 中看到完整的消费逻辑。正因如此,Expo 仓库将 ASN1Decoder 以源码形式内置于 ASN1Decoder 目录,共 14 个 Swift 文件,形成一套自包含、无第三方依赖的解码工具链。
目录结构:一套完整的 ASN.1 工具链
先鸟瞰这套模块的文件组成,理解各文件职责,便于后文对照:
| 文件 | 职责 |
|---|---|
| ASN1Decoder.swift | 核心 DER 流式解码器,把字节流转为ASN1Object树 |
| ASN1Identifier.swift | 定义 ASN.1 标识字节(Tag、Class、构造位)解析 |
| ASN1Object.swift | ASN.1 对象节点模型,提供子树遍历、OID 查找等能力 |
| ASN1DistinguishedNames.swift | 可辨识名称(DN)格式化 |
| ASN1Encoder.swift | 反向编码工具 |
| X509Certificate.swift | X.509 证书解析门面,对外暴露全部证书属性 |
| X509PublicKey.swift | 从证书中提取公钥(RSA / EC) |
| X509Extension.swift | 扩展字段解析基类 |
| X509ExtensionAltName.swift | Subject/Issuer 备用名称(SAN)解析 |
| X509ExtensionClasses.swift | 常见扩展的专用解析类(BasicConstraints、KeyUsage 等) |
| PKCS7.swift | PKCS#7 签名数据容器解析 |
| PKCS7_AppleReceipt.swift | App Store 收据字段解析 |
| PKCS7_Signature.swift | PKCS#7 签名者信息(SignerInfo)解析 |
| OID.swift | 常见对象标识符(OID)常量与名称映射 |
环境要求与集成方式
原文档对运行环境与集成方式有明确说明,逐条保留:
- 系统要求:iOS 9.0+ 或 macOS 10.10+,Xcode 9 及以上;
- 语言依赖:纯 Swift + Foundation 实现,无任何第三方依赖,可直接拷入项目使用(Expo 正是以源码形式内置)。
CocoaPods 集成
在Podfile中添加:
platform :ios, '9.0' use_frameworks! target 'MyApp' do pod 'ASN1Decoder' end然后执行pod install即可。若你需要的是 Expo 仓库内的这一份源码(而非发布到 CocoaPods 的独立版本),也可以直接把 ASN1Decoder 目录下的 14 个 Swift 文件拖入工程,它们之间通过public修饰符对外暴露 API,模块边界清晰。
Carthage 集成
在Cartfile中添加依赖声明:
github "filom/ASN1Decoder"然后执行carthage update,将构建出的 framework 链接进 target。两种方式二选一,推荐优先使用 CocoaPods 以获得依赖版本管理。
用法一:解析 DER/PEM 格式的 X.509 证书
这是最基础的用法:把证书数据交给X509Certificate,即可读取证书的全部结构化信息。
import ASN1Decoder do { let x509 = try X509Certificate(data: certData) let subject = x509.subjectDistinguishedName ?? "" } catch { print(error) }从 X509Certificate.swift 的源码可以看到,X509Certificate(data:)会自动嗅探输入格式:如果数据中包含-----BEGIN CERTIFICATE-----PEM 头,则走 PEM 解码路径,否则按 DER 二进制解析。也就是说,DER 与 PEM 两种常见格式无需手动区分。
证书 API 速查
结合源码逐项整理X509Certificate对外暴露的属性(均可在 X509Certificate.swift 中核验):
| 属性 / 方法 | 返回 | 说明 |
|---|---|---|
version | Int? | 证书版本(DER 中存储 0/1/2,对外加 1 返回 1/2/3) |
serialNumber | Data? | 序列号原始字节 |
subjectDistinguishedName | String? | 主体可辨识名称(Subject DN) |
issuerDistinguishedName | String? | 签发者可辨识名称(Issuer DN) |
subject(oid:)/issuer(oid:) | [String]?/String? | 按 OID 精确取 DN 字段,如OID.commonName、OID.organizationName |
notBefore/notAfter | Date? | 证书有效期起止(自动解析 UTC Time / GeneralizedTime) |
checkValidity(_:) | Bool | 校验某日期(默认当前时间)是否在有效期内 |
signature/sigAlgOID/sigAlgName | Data?/String?/String? | 签名值与签名算法 |
publicKey | X509PublicKey? | 公钥对象(见下文) |
keyUsage | [Bool] | KeyUsage 扩展位图(digitalSignature=0、keyCertSign=5 等) |
extendedKeyUsage | [String] | 扩展密钥用途 OID 列表 |
subjectAlternativeNames/issuerAlternativeNames | [String] | SAN / IAN 备用名称 |
criticalExtensionOIDs/nonCriticalExtensionOIDs | [String] | 关键 / 非关键扩展 OID 列表 |
extensionObject(oid:) | X509Extension? | 按 OID 获取扩展对象(支持 BasicConstraints、KeyUsage 等专用解析类) |
X509PublicKey位于 X509PublicKey.swift,提供:
algOid/algName:公钥算法标识与名称(如rsaEncryption、ecPublicKey);derEncodedKey:公钥的 DER 编码序列;key:提取的原始密钥数据——对 RSA 返回模数(modulus),对 EC 返回密钥字节。
这些 API 是后文 SSL Pinning 与 Expo 代码签名验证的基础。
用法二:SSL Pinning —— 校验服务器证书公钥
SSL Pinning 的核心思想是:客户端预置服务器证书的公钥指纹,在 TLS 握手回调中比对服务器实际下发的证书公钥,防止中间人攻击。原文档给出了完整实现,本文完整保留并补充注释。
第一步:定义 URLSessionDelegate
import Foundation import Security import ASN1Decoder class PinningURLSessionDelegate: NSObject, URLSessionDelegate { let publicKeyHexEncoded: String public init(publicKeyHexEncoded: String) { self.publicKeyHexEncoded = publicKeyHexEncoded.uppercased() } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) { guard challenge.protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust, let serverTrust = challenge.protectionSpace.serverTrust else { completionHandler(.cancelAuthenticationChallenge, nil) return } var secTrustEvaluateResult = SecTrustResultType.invalid let secTrustEvaluateStatus = SecTrustEvaluate(serverTrust, &secTrustEvaluateResult) guard secTrustEvaluateStatus != errSecSuccess, let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else { completionHandler(.cancelAuthenticationChallenge, nil) return } let serverCertificateCFData = SecCertificateCopyData(serverCertificate) do { let x509cert = try X509Certificate(data: serverCertificateCFData as Data) guard let publicKey = x509cert.publicKey?.key else { completionHandler(.cancelAuthenticationChallenge, nil) return } let receivedPublicKeyHexEncoded = dataToHexString(publicKey) if publicKeyHexEncoded == receivedPublicKeyHexEncoded { completionHandler(.useCredential, URLCredential(trust:serverTrust)) } } catch { completionHandler(.cancelAuthenticationChallenge, nil) } } func dataToHexString(_ data: Data) -> String { return data.map { String(format: "%02X", $0) }.joined() } }流程拆解:
- 通过
challenge.protectionSpace.serverTrust拿到服务器信任对象; SecTrustEvaluate先做一次系统级信任评估;- 取证书链第一张证书(
SecTrustGetCertificateAtIndex(serverTrust, 0)),转成 CFData; - 交给
X509Certificate解析,通过publicKey?.key提取公钥(对 RSA 即模数); - 将公钥字节转大写十六进制字符串,与预置值比对,一致则
useCredential放行,否则取消认证。
第二步:创建带 Pinning 的 URLSession
let publicKeyHexEncoded = "..." // your HTTPS certifcate public key let session = URLSession( configuration: URLSessionConfiguration.ephemeral, delegate: PinningURLSessionDelegate(publicKeyHexEncoded: publicKeyHexEncoded), delegateQueue: nil)此后所有经由该 session 发起的 HTTPS 请求都会经过上述认证回调,实现传输层公钥锁定。注意原文档中两处guard条件(!= NSURLAuthenticationMethodServerTrust与!= errSecSuccess)为示例写法,实际接入时通常应改为“等于”判定后放行并继续处理,请结合你的安全策略调整。
如何用 openssl 提取证书公钥
原文档给出的命令行,用于预取你服务器证书的公钥(模数)作为 Pinning 的比对基准:
openssl x509 -modulus -noout < certificate.cer将输出中的十六进制模数填入publicKeyHexEncoded即可。注意该命令输出的是 RSA 模数,与X509PublicKey.key对 RSA 返回模数的逻辑(见 X509PublicKey.swift)是一致的。
用法三:解析 App Store 收据(PKCS#7)
App Store 收据本身是一个 PKCS#7 SignedData 容器,内部嵌有以 ASN.1 编码的收据字段。ASN1Decoder 通过PKCS7+ReceiptInfo提供开箱即用的解析能力,原文档示例完整保留:
import ASN1Decoder if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { do { let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) let pkcs7 = try PKCS7(data: receiptData) if let receiptInfo = pkcs7.receipt() { print(receiptInfo.originalApplicationVersion) } } catch { print(error) } }收据字段覆盖
从 PKCS7_AppleReceipt.swift 的解析逻辑(基于 Apple 官方 Receipt Fields 文档的字段编号)可以看出,ReceiptInfo支持以下顶层字段:
| 字段编号 | ReceiptInfo 属性 | 含义 |
|---|---|---|
| 2 | bundleIdentifier/bundleIdentifierData | 应用 Bundle Identifier(校验 SHA-1 哈希时使用字节形式) |
| 3 | bundleVersion | 版本号(iOS 取 CFBundleVersion) |
| 4 | opaqueValue | 不透明值(参与 SHA-1 校验计算) |
| 5 | sha1 | 收据 SHA-1 哈希(用于校验) |
| 12 | receiptCreationDate/receiptCreationDateString | 收据创建时间 |
| 17 | inAppPurchases | 内购记录数组 |
| 19 | originalApplicationVersion | 首次安装的原始版本号 |
| 21 | receiptExpirationDate/receiptExpirationDateString | 订阅到期时间 |
InAppPurchaseInfo则覆盖每条内购记录的quantity(1701)、productId(1702)、transactionId(1703)、originalTransactionId(1705)、purchaseDate(1704)、originalPurchaseDate(1706)、expiresDate(1708)、cancellationDate(1712)、webOrderLineItemId(1711)、isInIntroOfferPeriod(1719)等字段。
两个值得注意的实现细节:
- 日期容错:收据日期标准格式为 RFC 3339(如
2017-01-01T12:00:00Z),但源码额外兼容带毫秒的格式(2017-01-01T12:00:00.123Z),两个DateFormatter均使用en_US_POSIXlocale 与 UTC 时区,避免区域设置导致的解析差异; - 沙盒兼容:
receipt()解析时会检测"Xcode"占位节点(模拟器/沙盒环境),自动下钻一层,保证本地调试可用。
原理深挖:ASN1DERDecoder 如何解析 DER 字节流
理解了用法之后,再看核心实现。DER 编码的本质是TLV(Tag-Length-Value)三元组递归嵌套,ASN1DERDecoder.swift 的parse函数正是按此模型工作:
- 读取 Tag 字节:交给
ASN1Identifier解析——低 5 位是 tag number(如0x02INTEGER、0x06OBJECT IDENTIFIER、0x30SEQUENCE),第 6 位(0x20)是 constructed 构造位,高 2 位是 class(universal / application / context-specific / private),见 ASN1Identifier.swift; - 读取长度:
getContentLength支持短格式(单字节长度)与长格式(首字节高位置 1 时,后续若干字节拼接成大整数),并做Int.max边界保护,见 ASN1Decoder.swift; - 读取 Value:对构造类型(SEQUENCE/SET 等)递归解析子对象并挂到
parent指针;对基本类型按 tag 分派解码。
各基本类型的解码逻辑同样清晰:INTEGER 会去除前导零;OID 通过decodeOid把字节流转成点分字符串(首字节拆成first/40 . first%40,后续字节按 7 位分组、高位为延续位,见 ASN1Decoder.swift);字符串类型区分 UTF-8、ASCII、Unicode 三种编码;时间类型支持 UTC Time 与 GeneralizedTime 两种格式,见 ASN1Decoder.swift。
解码完成后,ASN1Object.swift 提供的树模型支持:
sub(_ index:)/subCount():按索引访问子节点;findOid(_:):深度优先查找指定 OID 的节点(证书扩展定位的核心手段);description:以缩进树形式打印整棵 ASN.1 结构,调试证书时非常实用。
X509Certificate正是基于这棵对象树做位置定位的:证书主体(block1)按固定槽位索引version / serialNumber / signatureAlg / issuer / dateValidity / subject / publicKey / extensions逐项提取,见 X509Certificate.swift。
生产实践:ASN1Decoder 在 expo-updates 代码签名中的应用
这套解码器并非孤立工具,而是 expo-updates 代码签名验证链路的基础设施,这是它在本仓库中最有说服力的真实用例。
证书链验证
CertificateChain.swift 定义了完整的证书链校验流程:
- 将 PEM 字符串数组依次转成
(SecCertificate, X509Certificate)二元组(constructCertificate):- 通过 Crypto.swift 的
decodePEMToDER把 PEM 转为 DER——该方法注释明确说明“Mostly from ASN1Decoder with the fix for disallowing multiple bodies in the PEM”,即复用了 ASN1Decoder 的 PEM 解码思路,并额外加强了对“单个 PEM 文件内只允许一个证书体”的校验; - 用
X509Certificate(der:)解析,调用checkValidity()校验有效期; - 通过
SecCertificateCreateWithData桥接到系统安全框架;
- 通过 Crypto.swift 的
- 对整条链调用
validateChain():根证书必须自签名、必须是 CA(复用X509Certificate.isCACertificate(),检查 BasicConstraints 与 KeyUsage 第 5 位 keyCertSign); - 通过
SecTrust系统 API 做信任评估(设置锚点、禁用网络获取、SecTrustEvaluateWithError); - 逐级核对 Expo 项目信息扩展的一致性。
基于扩展字段的证书能力判断
ASN1Decoder 的扩展解析能力被直接用于业务判断:
isCodeSigningCertificate():要求 KeyUsage 第 0 位 digitalSignature 置位,且 Extended Key Usage 包含代码签名 OID1.3.6.1.5.5.7.3.3,见 CertificateChain.swift;expoProjectInformation():读取自定义扩展 OID1.2.840.113556.1.8000.2554.43437.254.128.102.157.7894389.20439.2.1中编码的<projectId>,<scopeKey>字符串,用于绑定更新包与特定 Expo 项目。
这些逻辑完全构建在X509Certificate的extensionObject(oid:)、keyUsage、extendedKeyUsage之上——如果你要为自家业务做类似的证书能力判断(如判断一张证书是否可用于某种特定用途),可以直接参照这段模式。
使用限制与注意事项
结合源码如实说明几点边界:
- 签名验证不在本模块职责内:ASN1Decoder 负责“解析”而非“验证”。SSL Pinning 示例中的公钥比对、expo-updates 中的
SecTrust评估与 Expo 项目信息核对,都是解析之后由业务层或系统安全框架完成的。App Store 收据的最终有效性验证(含 SHA-1 哈希计算)也需要基于bundleIdentifierData、opaqueValue、sha1字段自行实现; - 支持的签名算法:OID 表(OID.swift)覆盖 RSA、ECDSA、SHA-1/SHA-256/SHA-512 等常见算法;
X509PublicKey.key目前仅对 RSA(返回模数)与 EC(返回密钥字节)两类 OID 返回数据; - 通用 tag 兜底:遇到未识别的 universal tag 会打印
unsupported tag并以原始字节作为 value,不影响整棵树解析; - 证书链依赖系统评估:expo-updates 的
validateChain依赖SecTrustEvaluateWithError,这是 Apple 平台特性,跨平台(Android)场景由 CertificateChain.kt 提供对应实现。
小结
从 API 使用到源码剖析,本文覆盖了 ASN1Decoder 的完整能力:X509Certificate让 DER/PEM 证书解析一行可达;X509PublicKey为 SSL Pinning 提供公钥提取;PKCS7系列类让 App Store 收据字段唾手可得;而 expo-updates 的代码签名链路则示范了如何把这套解码器嵌入生产级安全流程。无论你是要为自己的 App 实现证书公钥锁定、解析收据做内购校验,还是构建自定义证书能力判断,都可以直接在仓库 packages/expo-updates/ios/EXUpdates/ASN1Decoder 中按需查阅实现细节,或将这套零依赖的 Swift 源码移植进自己的工程。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考