从公钥正确创建RSACryptoServiceProvider

18

我目前正在尝试仅通过解码的PEM文件创建一个RSACryptoServiceProvider对象。经过几天的搜索,我设法得到了一个可行的解决方案,但它并不是生产就绪状态。

简而言之,为了从PEM文件中组成公钥的字节序列创建RSACryptoServiceProvider对象,我必须创建指定密钥大小(当前使用SHA256,具体而言是2048)的对象,然后导入一个带有设置的RSAParameters对象中的ExponentModulus。我这样做的方法如下:

byte[] publicKeyBytes = Convert.FromBase64String(deserializedPublicKey.Replace("-----BEGIN PUBLIC KEY-----", "")
                                                                      .Replace("-----END PUBLIC KEY-----", ""));

// extract the modulus and exponent based on the key data
byte[] exponentData = new byte[3];
byte[] modulusData = new byte[256];
Array.Copy(publicKeyBytes, publicKeyBytes.Length - exponentData.Length, exponentData, 0, exponentData.Length);
Array.Copy(publicKeyBytes, 9, modulusData, 0, modulusData.Length);


// import the public key data (base RSA - works)
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(dwKeySize: 2048);
RSAParameters rsaParam = rsa.ExportParameters(false);
rsaParam.Modulus = modulusData;
rsaParam.Exponent = exponentData;
rsa.ImportParameters(rsaParam);

虽然这样可以运作,但并不可行假设deserializedPublicKey刚好是270个字节且所需的模数在第9位,长度总是256个字节。

我该如何正确地从一组公钥字节中选择出模数和指数字节?我尝试理解ASN.1标准,但很难从中找到我需要的内容 - 这些标准有点复杂。

任何帮助都将不胜感激。

3个回答

36
你不需要导出现有参数,然后再在其上重新导入。这会强制你的计算机生成一个RSA密钥,然后丢弃它。因此,在构造函数中指定密钥大小并不重要(如果你不使用密钥,它不会生成密钥...通常)。公钥文件是DER编码的二进制大对象。
-----BEGIN PUBLIC KEY-----
MIGgMA0GCSqGSIb3DQEBAQUAA4GOADCBigKBggC8rLGlNJ17NaWArDs5mOsV6/kA
7LMpvx91cXoAshmcihjXkbWSt+xSvVry2w07Y18FlXU9/3unyYctv34yJt70SgfK
Vo0QF5ksK0G/5ew1cIJM8fSxWRn+1RP9pWIEryA0otCP8EwsyknRaPoD+i+jL8zT
SEwV8KLlRnx2/HYLVQkCAwEAAQ==
-----END PUBLIC KEY-----

如果您获取PEM装甲内部的内容,那么它是一个Base64编码的字节数组。

30 81 A0 30 0D 06 09 2A 86 48 86 F7 0D 01 01 01 
05 00 03 81 8E 00 30 81 8A 02 81 82 00 BC AC B1 
A5 34 9D 7B 35 A5 80 AC 3B 39 98 EB 15 EB F9 00 
EC B3 29 BF 1F 75 71 7A 00 B2 19 9C 8A 18 D7 91 
B5 92 B7 EC 52 BD 5A F2 DB 0D 3B 63 5F 05 95 75 
3D FF 7B A7 C9 87 2D BF 7E 32 26 DE F4 4A 07 CA 
56 8D 10 17 99 2C 2B 41 BF E5 EC 35 70 82 4C F1 
F4 B1 59 19 FE D5 13 FD A5 62 04 AF 20 34 A2 D0 
8F F0 4C 2C CA 49 D1 68 FA 03 FA 2F A3 2F CC D3 
48 4C 15 F0 A2 E5 46 7C 76 FC 76 0B 55 09 02 03 
01 00 01 

ITU-T X.690定义了如何读取使用基本编码规则(BER)、规范编码规则(CER,我从未看到明确使用)和专用编码规则(DER)编码的内容。在大多数情况下,CER限制了BER,而DER限制了CER,使得DER最容易阅读。(ITU-T X.680描述了抽象语法符号一(ASN.1),它是DER的二进制编码语法)

现在我们可以进行一些解析:

30

这段文字指的是一个带有构造位设置(0x20)的序列(0x10),这意味着它包含其他DER/标记值。(在DER中,SEQUENCE始终是CONSTRUCTED)。
81 A0

下一部分是一个长度。因为它的高位被设置了(> 0x7F),第一个字节是“长度长度”值。它表示真实长度编码在接下来的1个字节中(lengthLength & 0x7F)。因此,这个SEQUENCE的内容总共有160个字节。(在这种情况下,“其余数据”,但SEQUENCE可能包含在其他东西中)。所以让我们读取内容:

30 0D

我们再次看到我们的“CONSTRUCTED SEQUENCE”(0x30),长度值为0x0D,因此我们有一个13字节的有效负载。
06 09 2A 86 48 86 F7 0D 01 01 01 05 00 

这里的06是一个对象标识符,带有一个0x09字节的有效载荷。OID的编码略微不直观,但这个等同于文本表示1.2.840.113549.1.1.1,也就是id-rsaEncryptionhttp://www.oid-info.com/get/1.2.840.113549.1.1.1)。

这样我们还剩下两个字节(05 00),我们可以看到它是一个NULL(有效载荷为0字节,因为它是NULL)。

到目前为止,我们有:

SEQUENCE
  SEQUENCE
    OID 1.2.840.113549.1.1.1
    NULL
  143 more bytes.

继续:
03 81 8E 00

"03"代表比特字符串。比特字符串的编码格式为[标签][长度][未使用位数]。未使用位数通常为零。因此,这是一个由位组成的序列,长度为0x8E字节,并且所有位都被使用。
从技术上讲,我们应该在此停止,因为CONSTRUCTED未设置。但由于我们已经知道了这个结构的格式,所以我们将该值视为如果CONSTRUCTED位已经设置:"
30 81 8A

这里再次出现我们的好朋友CONSTRUCTED SEQUENCE,0x8A字节的有效负载,便于对应“剩下的所有内容”。

02 81 82

02 表示一个整数,这个整数有 0x82 字节的有效载荷:

00 BC AC B1 A5 34 9D 7B 35 A5 80 AC 3B 39 98 EB 
15 EB F9 00 EC B3 29 BF 1F 75 71 7A 00 B2 19 9C 
8A 18 D7 91 B5 92 B7 EC 52 BD 5A F2 DB 0D 3B 63 
5F 05 95 75 3D FF 7B A7 C9 87 2D BF 7E 32 26 DE 
F4 4A 07 CA 56 8D 10 17 99 2C 2B 41 BF E5 EC 35 
70 82 4C F1 F4 B1 59 19 FE D5 13 FD A5 62 04 AF 
20 34 A2 D0 8F F0 4C 2C CA 49 D1 68 FA 03 FA 2F 
A3 2F CC D3 48 4C 15 F0 A2 E5 46 7C 76 FC 76 0B 
55 09 

在 DER 编码中,前导的 0x00 会被视为一种违规情况,除非下一个字节设置了最高位。这意味着 0x00 的存在是为了防止符号位被设置,从而使其成为正数。

02 03 01 00 01

另一个整数,3个字节,值为01 00 01。我们完成了。

SEQUENCE
  SEQUENCE
    OID 1.2.840.113549.1.1.1
    NULL
  BIT STRING
    SEQUENCE
      INTEGER 00 BC AC ... 0B 55 09
      INTEGER 01 00 01

阅读https://www.rfc-editor.org/rfc/rfc5280,我们可以看到这与SubjectPublicKeyInfo结构非常相似:

SubjectPublicKeyInfo  ::=  SEQUENCE  {
  algorithm            AlgorithmIdentifier,
  subjectPublicKey     BIT STRING  }

AlgorithmIdentifier  ::=  SEQUENCE  {
  algorithm               OBJECT IDENTIFIER,
  parameters              ANY DEFINED BY algorithm OPTIONAL  }
                            -- contains a value of the type
                            -- registered for use with the
                            -- algorithm object identifier value

当然,它不知道什么是RSA公钥格式。但oid-info网站告诉我们要查看RFC 2313,在那里我们可以看到:

An RSA public key shall have ASN.1 type RSAPublicKey:

RSAPublicKey ::= SEQUENCE {
  modulus INTEGER, -- n
  publicExponent INTEGER -- e }

这段话的意思是第一个整数是模数,第二个整数是(公共)指数。

DER编码采用大端序,这也是RSAParameters编码方式,但对于RSAParameters,您需要从模数中删除前导的0x00值。

虽然没有直接给出代码,但是根据这些信息编写RSA密钥的解析器应该相当简单。我建议您将其编写为internal static RSAParameters ReadRsaPublicKey(...),然后只需执行以下操作即可。

RSAParameters rsaParameters = ReadRsaPublicKey(...);

using (RSA rsa = RSA.Create())
{
    rsa.ImportParameters(rsaParameters);
    // things you want to do with the key go here
}

12
CreateRsaProviderFromPublicKey方法能够完成这个任务! - Mathieu Momal
流行的Bouncy Castle库也有一个实现可以为您获取RSAParameters。 DotNetUtilities.ToRSAParameters(...) - MattWazEre
2
在此答案发布的4年里,对此的支持也已经被构建进去了:key.ImportSubjectPublicKeyInfo(derBytes, out int bytesRead)。但是,该答案仍然解释了该方法正在做什么。 - bartonjs

8
经过长时间的搜索,以及bartonjs出色的回答,最终实现这个功能的代码非常简单,但对于不熟悉公钥结构的人来说可能有些难以理解。 TL;DR 基本上,如果您的公钥来自非.NET源,则此答案无法帮助,因为.NET没有提供一种原生解析正确格式化PEM的方法。然而,如果生成PEM的代码是基于.NET的,则此答案描述了如何创建仅包含公钥的PEM以及如何重新加载它。
公钥PEM可以描述多种密钥类型,而不仅仅是RSA,因此我们不能像new RSACryptoServiceProvider(pemBytes)那样进行操作,而是必须根据其结构/语法、ASN.1来解析PEM,并告诉我们它是否是RSA密钥(它可能是其他一系列密钥)。知道这一点;
const string rsaOid = "1.2.840.113549.1.1.1";   // found under System.Security.Cryptography.CngLightup.RsaOid but it's marked as private
Oid oid = new Oid(rsaOid);
AsnEncodedData keyValue = new AsnEncodedData(publicKeyBytes);           // see question
AsnEncodedData keyParam = new AsnEncodedData(new byte[] { 05, 00 });    // ASN.1 code for NULL
PublicKey pubKeyRdr = new PublicKey(oid, keyParam, keyValue);
var rsaCryptoServiceProvider = (RSACryptoServiceProvider)pubKeyRdr.Key;

注意: 上述代码 不是 生产就绪的!您需要在对象创建周围放置适当的保护(例如,公钥可能不是 RSA),强制转换为 RSACryptoServiceProvider 等等。这里的代码示例很短,以说明可以相当干净地完成。

我是如何得到这个的?通过在ILSpy中深入挖掘 Cryptographic 命名空间,我注意到了 AsnEncodedData,这与 bartonjs 的描述相符。进行更多的研究,我偶然发现了this的帖子(看起来很熟悉?)。这试图特别确定密钥大小,但它在途中创建了必要的 RSACryptoServiceProvider

我会将bartonjs的回答标记为已接受,并且这段代码是该研究的结果,我将其保留在此处,以便其他人可以干净地执行相同的操作,而不需要像我在OP中那样进行任何数组复制操作。
此外,为了解码和测试目的,您可以使用ASN.1解码器 here 检查您的公钥是否可解析。 更新 .NET路线图上计划使这个easier 在Core >2.1.0 中使用 ASN.1 parsing更新 2 现在在Core .NET 2.1.1中有一个私有实现。微软正在使用dogfooding直到满意所有情况都好,然后我们(希望)会在随后的版本中看到公共API。 更新 3

我通过一个问题这里发现,上述信息是不完整的。缺失的是,使用此解决方案加载的公钥是从已加载的公钥和私钥对程序生成的。一旦从密钥对(而不仅仅是公钥)创建了RSACryptoServiceProvider,您可以导出公共字节并将其编码为公钥PEM。这样做将与此处的解决方案兼容。这是怎么回事?

将公钥和私钥对加载到RSACryptoServiceProvider中,然后像这样导出它;

var cert = new X509Certificate2(keypairBytes, password,
                                X509KeyStorageFlags.Exportable 
                                | X509KeyStorageFlags.MachineKeySet);
var partialAsnBlockWithPublicKey = cert.GetPublicKey();

// export bytes to PEM format
var base64Encoded = Convert.ToBase64String(partialAsnBlockWithPublicKey, Base64FormattingOptions.InsertLineBreaks);
var pemHeader = "-----BEGIN PUBLIC KEY-----";
var pemFooter = "-----END PUBLIC KEY-----";
var pemFull = string.Format("{0}\r\n{1}\r\n{2}", pemHeader, base64Encoded, pemFooter);

如果您使用此密钥创建PEM,则可以使用先前描述的方法将其加载回来。为什么会有所不同?调用cert.GetPublicKey()实际上会返回ASN.1块结构;
SEQUENCE(2 elem)
  INTEGER (2048 bit)
  INTEGER 65537

这实际上是一个不完整的DER二进制大对象,但.NET可以解码(目前.NET不支持完整的ASN.1解析和生成 - https://github.com/dotnet/designs/issues/11)。
正确的DER(ASN.1)编码的公钥字节具有以下结构;
SEQUENCE(2 elem)
  SEQUENCE(2 elem)
     OBJECT IDENTIFIER   "1.2.840.113549.1.1.1" - rsaEncryption(PKCS #1)
     NULL
BIT STRING(1 elem)
  SEQUENCE(2 elem)
    INTEGER (2048 bit)
    INTEGER 65537

好的,上面的代码可以获取一个公钥(有点),您可以加载它。它很丑陋,技术上不完整,但使用了.NET自己的RSACryptoServiceProvider.GetPublicCert()方法的输出。当仅加载公钥时,构造函数可以使用相同的字节。不幸的是,它不是真正的、完整的PEM格式。我们仍在等待.NET Core 3.0以上版本中的MS ASN.1解析器。


以上代码可以简化为前两行缩减为:Oid oid = new Oid("RSA"); - markf78
这真的有效吗?我得到了一个异常,请参见此处https://dev59.com/xbfna4cB1Zd3GeqPvZf2以获取更多详细信息。 - markf78
@markf78,是的,虽然我看到Reza也有类似的问题,但我直到现在才看到评论。我会查看你链接的问题。 - DiskJunky

1

PEM文件只是一系列base64编码的DER文件,.net可以直接导入DER文件,所以您可以像这样做(我假设您只使用公钥):

byte[] certBytes = Convert.FromBase64String(deserializedPublicKey
    .Replace("-----BEGIN PUBLIC KEY-----", "")
    .Replace("-----END PUBLIC KEY-----", ""));

X509Certificate2 cert =  new X509Certificate2(certBytes);
RSACryptoServiceProvider publicKeyProvider = 
(RSACryptoServiceProvider)cert.PublicKey.Key;

2
如果事情真的那么简单就好了 :-) 如果我将公钥传递到构造函数中,会导致“CryptographicException”异常,提示“找不到请求的对象”。 - DiskJunky
1
这应该很简单,但似乎X509Certificate2要求DER文件包括私钥... - Gusman
正因为如此,我才使用了上面的暴力方法。仅从公钥创建所需的对象非常困难。Java有一个很好的实现,C#有一个名为BouncyCastle的旧库,但当前文档不存在(字面上是一个空白维基),考虑到它目前的法律地位是慈善机构,我不太放心使用它。所有这些都意味着需要采用低级解析方法。 - DiskJunky
2
我实际上也无法使用BouncyCastle来完成这个任务。现有的帖子和信息非常过时,当前文档也不存在。 - DiskJunky
让我们在聊天室中继续这个讨论 - DiskJunky
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接