如何在 C# 中生成与 OpenSSL 等效的 PKCS #7 签名
结论
HSM 返回的 RSA PKCS#1 v1.5 签名值可以直接封装到 detached CMS SignedData 中,无需再由 BouncyCastle 执行 RSA 签名。
对应的 OpenSSL 命令如下:
openssl smime -sign -binary -outform DER -noattr -nosmimecap \
-signer mycert.crt \
-inkey managedhsm:<keyvault>:<keyname>
生成的结构包括:
- 内容类型:
signedData - 摘要算法:SHA-256
- 内容:detached,不嵌入原始数据
- 签名属性:无,对应
-noattr - 签名算法:RSA PKCS#1 v1.5
- 证书:包含签名证书
- 输出编码:DER
封装前需要确认 rawSignedDigest 的实际内容:
- 如果它是 HSM 返回的 RSA 签名字节,可以直接写入
SignerInfo.signature。 - 如果它只是 32 字节的 SHA-256 摘要,就不能直接生成 PKCS #7。需要先让 HSM 使用
RS256,即 RSASSA-PKCS1-v1_5 with SHA-256,对摘要进行签名。
建议将变量名改为 rsaSignature,避免将“signed digest”和普通摘要混淆。
OpenSSL 实际签署的内容
使用 -noattr 时,SignerInfo.signedAttrs 不存在,签名过程可以概括为:
digest = SHA256(content)
signature = RSASSA-PKCS1-v1_5-SIGN(privateKey, digest)
这里的 RSA 操作并非直接对 32 字节摘要执行私钥运算。RSASSA-PKCS1-v1_5 会先构造包含 SHA-256 算法标识和摘要的 DigestInfo,然后添加 PKCS#1 v1.5 填充。
使用 Azure Key Vault 或 Managed HSM 时,应选择 RS256。不要使用无填充的原始 RSA 操作,也不要把摘要当作普通数据再次计算 SHA-256。
content 必须与 OpenSSL 收到的字节完全一致。-binary 表示 OpenSSL 不会进行换行规范化。RDP 签名流程中的编码、签名范围和待签名数据拼接规则仍需沿用现有实现。
使用 BouncyCastle 封装签名
以下代码使用与问题中相同的低层 PKCS ASN.1 API。其中,rsaSignature 是 HSM 已生成的 RSA PKCS#1 v1.5 签名,而非 SHA-256 摘要。
using System;
using System.IO;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Pkcs;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.X509;
public static class Pkcs7Builder
{
public static byte[] BuildDetachedSignature(
byte[] rsaSignature,
string certificatePath)
{
if (rsaSignature == null || rsaSignature.Length == 0)
throw new ArgumentException("RSA signature is required.", nameof(rsaSignature));
var certificateBytes = File.ReadAllBytes(certificatePath);
var certificate = new X509CertificateParser()
.ReadCertificate(certificateBytes);
// id-sha256
var sha256Algorithm = new AlgorithmIdentifier(
new DerObjectIdentifier("2.16.840.1.101.3.4.2.1"),
DerNull.Instance);
// rsaEncryption
var rsaAlgorithm = new AlgorithmIdentifier(
PkcsObjectIdentifiers.RsaEncryption,
DerNull.Instance);
var certificateAsn1 = Asn1Object.FromByteArray(
certificate.GetEncoded());
/*
* SignerInfo version 1 is used because sid is
* IssuerAndSerialNumber.
*
* authenticatedAttributes is null because OpenSSL was invoked
* with -noattr.
*/
var signerInfo = new SignerInfo(
DerInteger.One,
new IssuerAndSerialNumber(
certificate.CertificateStructure.Issuer,
certificate.SerialNumber),
sha256Algorithm,
null,
rsaAlgorithm,
new DerOctetString(rsaSignature),
null);
/*
* A null content value produces detached SignedData:
*
* EncapsulatedContentInfo {
* eContentType id-data,
* eContent absent
* }
*/
var contentInfo = new ContentInfo(
PkcsObjectIdentifiers.Data,
null);
var signedData = new SignedData(
DerInteger.One,
new DerSet(sha256Algorithm),
contentInfo,
new DerSet(certificateAsn1),
null,
new DerSet(signerInfo));
var outerContentInfo = new ContentInfo(
PkcsObjectIdentifiers.SignedData,
signedData);
// 强制输出 DER,而不是 BER indefinite-length 编码。
return outerContentInfo.GetDerEncoded();
}
}
不同 BouncyCastle 版本使用的 DER 编码方法名称可能略有差异。如果当前版本没有 GetDerEncoded(),可以改用该版本提供的等价形式,例如:
byte[] result = outerContentInfo.GetEncoded(Asn1Encodable.Der);
具体写法应以项目实际引用的 BouncyCastle API 为准,重点是明确要求输出 DER 编码。
HSM 签名阶段
假设已经取得与 OpenSSL 输入完全相同的 content:
using System.Security.Cryptography;
byte[] digest = SHA256.HashData(content);
接着调用 HSM 的摘要签名接口:
rsaSignature = HSM.SignHash(
algorithm: RSASSA-PKCS1-v1_5 with SHA-256,
digest: digest
)
在 Azure Key Vault 或 Managed HSM 中,这类算法通常表示为 RS256。调用原始数据签名接口时,应传入 content,由服务计算 SHA-256;调用摘要签名接口时,则应传入 digest。不能混用这两种方式,否则可能造成重复哈希。
取得签名后进行封装:
byte[] pkcs7Signature = Pkcs7Builder.BuildDetachedSignature(
rsaSignature,
"./path/to/mycert.crt");
原代码中的问题
原代码的方向基本正确,但有几处容易出错。
代码先创建了:
var signedDigest = new DerOctetString(rawSignedDigest);
但在构造 SignerInfo 时,传入的却是未定义的 digestSig。这里应将 RSA 签名字节传给 SignerInfo:
new DerOctetString(rsaSignature)
SignerInfo.signature 中保存的是完整的 RSA 签名值,而非普通的 SHA-256 摘要。如果 rawSignedDigest.Length == 32,它通常只是摘要,不能直接写入该字段。RSA 签名长度通常等于 RSA 模数长度。例如,2048 位 RSA 密钥会产生 256 字节签名,实际长度取决于密钥。
证书集合和外层结构应采用 DER 编码。混用 BerSet,或者让 ContentInfo 使用 BER indefinite-length 编码,可能会产生语义正确、字节格式却不同的结果。
构造 IssuerAndSerialNumber 时,建议直接使用证书中的 ASN.1 issuer 表示:
certificate.CertificateStructure.Issuer
这样可以降低重新编码 distinguished name 后,其表示方式与证书原始内容不一致的风险。
如何验证结果
保留生成摘要时使用的原始待签名内容,例如 payload.bin,然后通过 OpenSSL 验证:
openssl cms -verify \
-inform DER \
-in signature.p7s \
-content payload.bin \
-binary \
-noverify \
-out /dev/null
-noverify 只会跳过证书链验证,不会跳过签名值验证。正式使用时,仍需单独验证证书链、有效期和用途。
还可以检查 ASN.1 结构:
openssl asn1parse -inform DER -in signature.p7s -i
预期输出中应包含:
pkcs7-signedData
sha256
pkcs7-data
rsaEncryption
SignerInfo 中不应出现 signed attributes。
“等效”不代表字节完全相同
当签名值、证书、issuer/serial、算法标识和 detached content 一致时,生成的 CMS 可以与 OpenSSL 结果语义相同并通过验证,但两个文件未必逐字节一致。常见差异有:
AlgorithmIdentifier参数使用NULL或省略;- ASN.1 集合的编码顺序;
- BER 与 DER 编码;
- 证书集合中包含的证书数量;
- issuer distinguished name 的重新编码方式;
- OpenSSL 实际选择的摘要算法。
进行对照测试时,建议在 OpenSSL 命令中明确指定摘要算法:
openssl smime -sign \
-binary \
-outform DER \
-noattr \
-nosmimecap \
-md sha256 \
-signer mycert.crt \
-inkey managedhsm:<keyvault>:<keyname>
上述代码只生成 DER 编码的 PKCS #7/CMS 签名块。后续还需要单独完成 RDP 封装,包括转换为 RDP 文件要求的文本形式、写入长度字段,以及将签名附加到 .rdp 文件。
备注:内容仅供参考。
