PDF 生成模板编辑器
开源可视化 PDF 生成引擎,支持自定义模板,并提供面向开发者的 API,灵活构建文档生成流程。
PAdES(PDF Advanced Electronic Signatures,ETSI EN 319 142)是欧盟 eIDAS 法规认可的 PDF 高级电子签名格式。CPDFPadesSigner 可以生成以下四个基线级别的签名,每个级别都包含前一级别的全部内容:
| 级别 | 说明 | 输出文件 |
|---|---|---|
CPDFPadesBaselineProfileB | 基础签名,包含签名证书引用。 | signedPath |
CPDFPadesBaselineProfileT | Baseline-B 加可信签名时间戳,需要 TSA。 | signedPath |
CPDFPadesBaselineProfileLT | Baseline-T 加写入 DSS / VRI 的证书链与 OCSP / CRL 撤销信息,签名证书过期后仍可验证。 | signedPath、ltvPath |
CPDFPadesBaselineProfileLTA | Baseline-LT 加覆盖长期验证材料的文档时间戳,可定期续期。 | signedPath、ltvPath、ltaPath |
每个级别写入单独的文件,各路径不能相同。最终文档是最后写入的文件(LTA 为 ltaPath,LT 为 ltvPath,其余为 signedPath),之前的文件是中间状态,可作为审计记录保留,也可删除。
注: CPDFPadesSigner 与 CPDFLtvService 的实例持有核心库状态,同一实例只能在一个线程中使用,即使处理的是不同文档也是如此。
生成 PAdES-B / PAdES-T 签名
CPDFPadesCreateOptions 的 signatureOptions 描述内层签名(签名身份、签名域、外观等),其 profile 会根据基线级别自动设置,无需填写。T 及以上级别的签名时间戳来自 signatureOptions 的 tsaClient。
在返回的 CPDFPadesCreateResult 中,signatureResult.success 表示签名已写入,signatureResult.subFilter 为 ETSI.CAdES.detached 表示已生成 PAdES 签名;success 表示请求的级别已完整达成。
以下是生成 PAdES-T 签名的示例代码:
let tsaClient = CPDFURLSessionTSAClient(url: URL(string: "https://tsa.example.com/tsr")!)
// 内层签名:签名身份、签名域与签名时间戳
let signatureOptions = CPDFSignatureCreateOptions()
signatureOptions.signer = CPDFSigningIdentity(pkcs12Path: certificatePath, password: "123")
signatureOptions.tsaClient = tsaClient
signatureOptions.fieldName = "Signature1"
signatureOptions.pageIndex = 0
signatureOptions.bounds = CGRect(x: 28, y: 420, width: 150, height: 70)
let padesOptions = CPDFPadesCreateOptions()
padesOptions.profile = .T
padesOptions.signatureOptions = signatureOptions
padesOptions.trustedCertPath = trustedRootsPath
DispatchQueue.global(qos: .userInitiated).async {
let signer = CPDFPadesSigner(document: document)
let result = signer.sign(toPath: signedPath, ltvPath: nil, ltaPath: nil, options: padesOptions)
if result.signatureResult.success {
// ETSI.CAdES.detached 表示已生成 PAdES 签名
print("Signed: \(result.signedPath), subFilter: \(result.signatureResult.subFilter)")
}
for item in result.errors {
print("\(item.code): \(item.message)")
}
}CPDFURLSessionTSAClient *tsaClient = [[CPDFURLSessionTSAClient alloc] initWithURL:[NSURL URLWithString:@"https://tsa.example.com/tsr"]];
// 内层签名:签名身份、签名域与签名时间戳
CPDFSignatureCreateOptions *signatureOptions = [[CPDFSignatureCreateOptions alloc] init];
signatureOptions.signer = [CPDFSigningIdentity identityWithPKCS12Path:certificatePath password:@"123"];
signatureOptions.tsaClient = tsaClient;
signatureOptions.fieldName = @"Signature1";
signatureOptions.pageIndex = 0;
signatureOptions.bounds = CGRectMake(28, 420, 150, 70);
CPDFPadesCreateOptions *padesOptions = [[CPDFPadesCreateOptions alloc] init];
padesOptions.profile = CPDFPadesBaselineProfileT;
padesOptions.signatureOptions = signatureOptions;
padesOptions.trustedCertPath = trustedRootsPath;
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
CPDFPadesSigner *signer = [[CPDFPadesSigner alloc] initWithDocument:document];
CPDFPadesCreateResult *result = [signer signToPath:signedPath ltvPath:nil ltaPath:nil options:padesOptions];
if (result.signatureResult.success) {
// ETSI.CAdES.detached 表示已生成 PAdES 签名
NSLog(@"Signed: %@, subFilter: %@", result.signedPath, result.signatureResult.subFilter);
}
for (CPDFValidationReportItem *item in result.errors) {
NSLog(@"%@: %@", item.code, item.message);
}
});在 iOS 上写入长期验证材料(LT / LTA)
LT 与 LTA 需要签名证书链及 OCSP / CRL 撤销信息。CPDFPadesCreateOptions 的 autoCollectLTVValidationInfo 默认为 YES,此时由核心库自行发起同步 HTTP 请求下载撤销信息;该请求不经过 NSURLSession,不受 ATS、系统代理与超时设置的约束。在 iOS 上,建议由 App 自行获取撤销信息,再通过 ltvOptions 传入,步骤如下:
使用 CPDFPadesSigner 生成 PAdES-T 签名(见上文)。
使用 CPDFCertificateChainBuilder 在本地构建签名证书链,并通过 CPDFRevocationService 的 discoverSourcesInCertificateDER: 读取证书中的 OCSP / CRL 地址。
使用 App 自己的 NSURLSession 下载 CRL 或 OCSP 响应,再调用 CPDFRevocationService 的 validateCRL: 或 validateOCSPResponse: 在本地验证,这两个方法不会联网。
将证书链与撤销信息放入 CPDFLtvValidationInfo,调用 prolongLTVFromPath:ltvPath:ltaPath:options: 写入 DSS / VRI。appendDocumentTimestamp 为 YES 时会同时追加文档时间戳,得到 LTA 级别的文档。
CPDFLtvValidationInfo 的 signatureContentsDER 必须是签名 /Contents 的完整原始字节(包含预留的填充字节),而不是裁剪后的 CMS 容器,否则写入的 VRI 条目无法被验证程序找到。请使用 CPDFSignatureCoverageChecker 的 rawSignatureContentsForSignature:atPath: 获取。
以下是在 iOS 上写入长期验证材料并追加文档时间戳的示例代码(以 CRL 为例):
let tsaClient = CPDFURLSessionTSAClient(url: URL(string: "https://tsa.example.com/tsr")!)
DispatchQueue.global(qos: .userInitiated).async {
// 1. 从已签名文件中取出签名及其证书(签名者证书位于数组末尾)
guard let signedDocument = CPDFDocument(url: URL(fileURLWithPath: signedPath)),
let signature = signedDocument.signatures()?.first,
let certificates = signature.signers?.first?.certificates,
let leafDER = certificates.last?.derData() else { return }
// 2. 在本地构建证书链(不联网),链按叶证书在前排列
let store = CPDFCertificateStore()
store.trustedCertificatePath = trustedRootsPath
for certificate in certificates.dropLast() {
if let der = certificate.derData() {
store.addIntermediateCertificateDER(der)
}
}
let chain = CPDFCertificateChainBuilder().buildChain(forCertificateDER: leafDER, store: store, allowPartialChain: true)
guard chain.success, chain.chainDER.count >= 2 else { return }
// 3. 读取证书中的 CRL 分发点,用 App 自己的 URLSession 下载 CRL,再在本地验证
let revocation = CPDFRevocationService()
let sources = revocation.discoverSources(inCertificateDER: leafDER)
guard let crlURL = sources.crlURLs.first.flatMap({ URL(string: $0) }),
let crlData = downloadData(from: crlURL) else { return }
let input = CPDFRevocationCheckInput()
input.subjectCertificateDER = leafDER
input.issuerCertificateDER = chain.chainDER[1]
input.crlDER = crlData
let check = revocation.validateCRL(input)
guard check.success, check.status == .good else { return }
// 4. 组装 LTV 材料;签名的 /Contents 必须是包含填充字节的原始内容
let info = CPDFLtvValidationInfo()
info.signatureContentsDER = CPDFSignatureCoverageChecker().rawSignatureContents(for: signature, atPath: signedPath)
info.certificatesDER = chain.chainDER
info.crlsDER = [crlData]
let ltvOptions = CPDFLtvAddOptions()
ltvOptions.validationInfo = [info]
// 5. 写入 DSS / VRI(LT),并追加文档时间戳(LTA)
let prolongOptions = CPDFPadesProlongOptions()
prolongOptions.ltvOptions = ltvOptions
prolongOptions.autoCollectLTVValidationInfo = false
prolongOptions.appendDocumentTimestamp = true
prolongOptions.ltaTSAClient = tsaClient
prolongOptions.trustedCertPath = trustedRootsPath
let result = CPDFPadesSigner().prolongLTV(fromPath: signedPath, ltvPath: ltvPath, ltaPath: ltaPath, options: prolongOptions)
print("LTV written: \(result.ltvResult.success), LTA file: \(result.ltaPath)")
}CPDFURLSessionTSAClient *tsaClient = [[CPDFURLSessionTSAClient alloc] initWithURL:[NSURL URLWithString:@"https://tsa.example.com/tsr"]];
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
// 1. 从已签名文件中取出签名及其证书(签名者证书位于数组末尾)
CPDFDocument *signedDocument = [[CPDFDocument alloc] initWithURL:[NSURL fileURLWithPath:signedPath]];
CPDFSignature *signature = [signedDocument signatures].firstObject;
NSArray<CPDFSignatureCertificate *> *certificates = signature.signers.firstObject.certificates;
NSData *leafDER = [certificates.lastObject DERData];
if (!leafDER) {
return;
}
// 2. 在本地构建证书链(不联网),链按叶证书在前排列
CPDFCertificateStore *store = [[CPDFCertificateStore alloc] init];
store.trustedCertificatePath = trustedRootsPath;
for (NSUInteger i = 0; i + 1 < certificates.count; i++) {
NSData *der = [certificates[i] DERData];
if (der) {
[store addIntermediateCertificateDER:der];
}
}
CPDFCertificateChainBuildResult *chain = [[[CPDFCertificateChainBuilder alloc] init] buildChainForCertificateDER:leafDER store:store allowPartialChain:YES];
if (!chain.success || chain.chainDER.count < 2) {
return;
}
// 3. 读取证书中的 CRL 分发点,用 App 自己的 URLSession 下载 CRL,再在本地验证
CPDFRevocationService *revocation = [[CPDFRevocationService alloc] init];
CPDFRevocationSources *sources = [revocation discoverSourcesInCertificateDER:leafDER];
NSString *crlURLString = sources.crlURLs.firstObject;
NSData *crlData = crlURLString ? downloadData([NSURL URLWithString:crlURLString]) : nil;
if (!crlData) {
return;
}
CPDFRevocationCheckInput *input = [[CPDFRevocationCheckInput alloc] init];
input.subjectCertificateDER = leafDER;
input.issuerCertificateDER = chain.chainDER[1];
input.crlDER = crlData;
CPDFRevocationCheckResult *check = [revocation validateCRL:input];
if (!check.success || check.status != CPDFRevocationStatusGood) {
return;
}
// 4. 组装 LTV 材料;签名的 /Contents 必须是包含填充字节的原始内容
CPDFLtvValidationInfo *info = [[CPDFLtvValidationInfo alloc] init];
info.signatureContentsDER = [[[CPDFSignatureCoverageChecker alloc] init] rawSignatureContentsForSignature:signature atPath:signedPath];
info.certificatesDER = chain.chainDER;
info.crlsDER = @[crlData];
CPDFLtvAddOptions *ltvOptions = [[CPDFLtvAddOptions alloc] init];
ltvOptions.validationInfo = @[info];
// 5. 写入 DSS / VRI(LT),并追加文档时间戳(LTA)
CPDFPadesProlongOptions *prolongOptions = [[CPDFPadesProlongOptions alloc] init];
prolongOptions.ltvOptions = ltvOptions;
prolongOptions.autoCollectLTVValidationInfo = NO;
prolongOptions.appendDocumentTimestamp = YES;
prolongOptions.ltaTSAClient = tsaClient;
prolongOptions.trustedCertPath = trustedRootsPath;
CPDFPadesSigner *signer = [[CPDFPadesSigner alloc] init];
CPDFPadesProlongResult *result = [signer prolongLTVFromPath:signedPath ltvPath:ltvPath ltaPath:ltaPath options:prolongOptions];
NSLog(@"LTV written: %d, LTA file: %@", result.ltvResult.success, result.ltaPath);
});如果您的使用场景可以接受核心库直接联网,也可以将 profile 设为 CPDFPadesBaselineProfileLT 或 CPDFPadesBaselineProfileLTA,同时传入 ltvPath、ltaPath 并设置 ltaTSAClient,由 signToPath:ltvPath:ltaPath:options: 一次完成签名与长期验证材料的收集。
注: CPDFRevocationService 的 fetchOCSPResponse:、fetchCRL: 与 fetchAndValidateBestAvailable: 同样由核心库通过 OpenSSL 发起阻塞式网络请求,绕过 ATS 与系统代理,不建议在 iOS 上使用。
为存档文档续期
prolongLTVFromPath:ltvPath:ltaPath:options: 也用于为已签名的存档文档续期:在已有撤销信息或文档时间戳的证书过期之前,重新获取最新的撤销信息并追加新的文档时间戳。该方法不需要打开文档,也适用于其他软件签署的 PDF。注意每次追加都会重新嵌入 DSS 中已有的证书与 CRL,文件会随之增大。
检查文档的 LTV 状态
CPDFLtvService 只读取文档中的 DSS / VRI,不会联网。inspectDSSInDocument: 汇总 DSS 中的内容;assessReadinessOfDocument: 判断文档中的每个签名是否都具备完整的长期验证材料,未就绪时,missingMaterials 会列出缺少的材料,可据此决定重新获取撤销信息还是重新签名。
以下是检查文档 LTV 状态的示例代码:
DispatchQueue.global(qos: .utility).async {
guard let document = CPDFDocument(url: URL(fileURLWithPath: ltaPath)) else { return }
// CPDFLtvService 只读取文档结构,不联网;每个线程使用独立实例
let service = CPDFLtvService()
let dss = service.inspectDSS(in: document)
if dss.success {
print("DSS: \(dss.dssInfo.hasDSS), VRI entries: \(dss.dssInfo.vriEntriesCount), CRLs: \(dss.dssInfo.crlsCount)")
}
let readiness = service.assessReadiness(of: document)
if readiness.success {
let info = readiness.readinessInfo
print("LTV ready: \(info.ltvReady), hint: \(info.profileHint)")
// 未就绪时,missingMaterials 说明缺少哪些材料
for missing in info.missingMaterials {
print("Missing: \(missing)")
}
}
}dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
CPDFDocument *document = [[CPDFDocument alloc] initWithURL:[NSURL fileURLWithPath:ltaPath]];
// CPDFLtvService 只读取文档结构,不联网;每个线程使用独立实例
CPDFLtvService *service = [[CPDFLtvService alloc] init];
CPDFLtvInspectResult *dss = [service inspectDSSInDocument:document];
if (dss.success) {
NSLog(@"DSS: %d, VRI entries: %ld, CRLs: %ld", dss.dssInfo.hasDSS, (long)dss.dssInfo.vriEntriesCount, (long)dss.dssInfo.crlsCount);
}
CPDFLtvReadinessResult *readiness = [service assessReadinessOfDocument:document];
if (readiness.success) {
CPDFLtvReadinessInfo *info = readiness.readinessInfo;
NSLog(@"LTV ready: %d, hint: %@", info.ltvReady, info.profileHint);
// 未就绪时,missingMaterials 说明缺少哪些材料
for (NSString *missing in info.missingMaterials) {
NSLog(@"Missing: %@", missing);
}
}
});