PAYATHON 2026

如何在 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 文件。

备注:内容仅供参考。