加密互操作性:数字签名






4.98/5 (44投票s)
使用 Crypto++、Java 和 C# 签名和验证消息。
- 下载 C++ 代码 - 5.09 KB
- 下载 C# 代码 - 32.3 KB
- 下载 Java 代码 - 10.8 KB
下载文件的校验和可在此处获取.
引言
Crypto++ 邮件列表偶尔会收到关于在各种库之间创建和验证数字签名的问题。本文将探讨 Crypto++、C# 和 Java 之间的消息签名和验证。此外,C# 示例展示了 AsnKeyBuilder
和 AsnKeyParser
,它们允许我们以 PKCS#8 和 X.509 格式序列化和重构密钥。这使我们摆脱了 CLR 使用 RFC 3275 不规则格式进行 XML 序列化的限制。RFC 3275 规定了 XML 签名语法和处理 [13]。第 4.4.1 节和 4.4.2 节规定了与 DSA(和 RSA)参数相关的定义,例如 KeyInfo
元素和 KeyValue
元素。
数字签名算法将用作测试用例。选择它有几个原因。首先是其普及性。其次,正如我们将在下面看到的,由于每个消息的随机变量,相同的密钥和消息会创建不同的签名。接下来,DSA 签名至少以三种不同的格式表示,这导致了必要的转换。最后,我们将使用字符串和流而不是字节数组,这会增加更多的互操作性问题。
下面,我们将看到已签名消息是元组 { 消息,签名 }。当我们验证消息时,我们需要消息、签名和签名者的公钥。这揭示了两个问题领域。第一个问题是密钥及其交换。第二个是定义到底要签名和稍后验证什么。
第一个问题在密码学互操作性:密钥[1] 中进行了探讨。密钥互操作性文章讨论了使用 PKCS#8 和 X.509 以可移植方式在 Crypto++、Java 和 C# 中导入和导出公钥和私钥。
本文将探讨第二个问题——理解将要(或已经)签名的内容。与之前的文章一样,我们详细研究了该过程,以便在出现问题时,我们可以理解原因,然后纠正问题。本文将探讨的主题如下。尽管字符串和流的影响很早就出现,但我们最后讨论这个主题。
- 数字签名
- 密钥生成
- 消息签名
- 消息验证
- 签名格式
- IEEE P1363
- DER 编码
- OpenPGP
- 生成密钥、签名和验证
- Crypto++
- Java
- C#
- 字符串和流
- Crypto++
- Java
- C#
我们的示例将使用 FIPS 186-2 [11] 中指定的数字签名标准。该标准规定了三种批准的签名方案。我们将使用数字签名(DS)算法,而不是 RSA 数字签名算法(RSASS)或椭圆曲线数字签名算法(ECDSA)。
FIPS 186-2 规定使用 1024 位 p、160 位 q 和 SHA-1 作为哈希。FIPS 186-3 [2] 使用更大的哈希(SHA-2)、更大的 p 值(高达 3072 位)和更大的 q 值(高达 256 位)。FIPS 186-3 目前处于草案状态。
下载次数
有三个可用的下载。每个存档都是用于创建和验证签名的项目。对于那些只想要源代码的人,表 1 列出了感兴趣的下载。
文件名 | 语言 |
CryptoPPInteropSign.zip | C++/Crypto++ |
JavaInteropSign.zip | Java |
CSInteropSign.zip | C# |
表 1:源代码存档 |
数字签名
数字签名是手写签名的电子等效物。它使用公钥和私钥对进行操作。签名者使用私钥对消息进行签名,验证者使用公钥确认消息上的签名。
DSA 是 ElGamal 签名系统 [12] 的特例。DSA 的安全性源于离散对数。实际上有两个实例问题:第一个是乘法群 Zp 中的对数,其中适用索引计算方法。第二个是循环子群 q 中的对数问题,其中当前方法以平方根时间运行。
DSA 是附录签名方案。这意味着消息必须呈现给验证函数。这与恢复签名方案形成对比。在恢复系统中,消息被折叠到签名中,因此消息不必与签名一起发送。验证例程将从恢复系统中的签名中提取消息。
密钥生成
DSA 密钥的生成方式如下 [12]。下面,q 的大小由 FIPS 186 固定为 160 位。尽管最初的 FIPS 186 规范 [7] 规定 p 在 512 到 1024 位之间(含),但 FIPS 186-2 [11] 将 p 固定为 1024。这意味着某些库在第三步强制执行 1024 位的位大小。
- 选择一个素数 q,使得 2159 < q < 2160
- 选择 t 使得 0 ≤ t ≤ 8
- 选择一个素数 p,使得 2511+64t < p < 2512+64t,并额外满足 q 整除 (p-1)
- 选择 Z*p 中阶为 q 的唯一循环群的生成元 α
- 要计算 α,在 Z*p 中选择一个元素 g 并计算 g(p-1)/q mod p
- 如果 α = 1,则使用不同的 g 再次执行第五步
- 选择一个随机数 a 使得 1 ≤ a ≤ q-1
- 计算 y = αa mod p
公钥是 (p, q, α, y)。私钥是 a。我们通常会遇到私钥指定为 x。
消息签名
要使用附录方案签署任意大小的文档,需要执行两个步骤
- 对文档进行哈希
- 使用私钥解密文档的哈希,就像它是密文的一个实例一样
在 DSA 中,签名任意长度的二进制消息 m(文档)的详细信息如下 [12]。请注意,我们正在签名二进制消息(在这个级别没有字符串的概念),并且消息可以是任何长度。由于消息可以是任何长度,因此使用哈希函数 — h(m) 对消息进行摘要。
- 生成一个随机的每消息值 k,使得 0 < k < q
- 计算 r = (αk mod p) mod q
- 如果 r = 0,则使用不同的 k 再次执行第一步
- 计算 k-1 mod q
- 计算 s = k-1{h(m) + ar} mod q
- 如果 s = 0,则使用不同的 k 再次执行第一步
m 上的签名是 (r, s)。消息 m 和 (r, s) 应该发送给验证者。我们需要注意的是,r 和 s 都是 20 字节,因为使用 q(一个 160 位的值)执行了模还原(步骤 2 和 5)。当我们在 Crypto++ 和 C#(它们使用 IEEE P1363 签名格式)以及 Java(它使用签名的 DER 编码)之间开始验证消息时,这将变得很重要。
消息验证
要使用附录方案验证任意大小的文档,需要执行三个步骤
- 对文档进行哈希
- 使用签名者的公钥加密先前生成的文档哈希(来自消息签名过程的步骤 2)
- 验证消息验证过程步骤 1 中恢复的哈希是否与消息验证过程步骤 2 中计算的哈希匹配
上述的简短说明是,我们在删除签名者的加密操作后,将我们计算的文档哈希与签名者计算的文档哈希进行比较。DSA 详细信息如下 [12]。下面,请记住 (r, s) 是二进制消息 m 上的签名,其中 h(m) 摘要任意长度的消息。
- 获取公钥 (p, q, α, y)
- 验证 0 < r < q 和 0 < s < q(否则拒绝签名)
- 计算 w = s-1 mod q
- 计算 u1 = w•h(m) mod q
- 计算 u2 = rw mod q
- 计算 v = (αu1yu2 mod p) mod q
当且仅当 v = r 时,签名才有效。
签名格式
对于我们这些遵循密码互操作性:密钥[1] 的人来说,我们还没有完成标准和格式。Crypto++ 支持三种格式,其中 IEEE P1363 是库的原生格式。其余两种格式是 DER 编码和 OpenPGP。如果我们收到 P1363 以外的格式,我们将使用 Crypto++ 的 DSAConvertSignatureFormat 将签名 (r, s) 转换为 P1363 格式。
回想一下 m 上的签名是 (r, s)。从我们对消息签名的探索中,回想一下 q 是 160 位。r 和 s 都是使用 q 进行模还原的余数,因此每个都是 160 位(20 字节)。
IEEE P1363
Crypto++ 和 C# 都使用 IEEE P1363 [9] 中描述的格式。P1363 签名是 r 和 s 的连接,表示为 r || s。连接导致签名的长度恰好为 40 字节。
DER 编码
Java 使用 (r, s) 的 DER 编码。根据 Java 密码体系结构 API 规范和参考 [8],签名的语法如下。Java 签名与 RFC 3279(互联网 X.509 公钥基础设施的算法和标识符 [14])的 DSS-Sig-Value 一致。请参阅第 2.2.2 节,DSA 签名算法。
SEQUENCE ::= { r INTEGER, s INTEGER }
看来我们无法从 Java 请求任何其他格式。这在 Crypto++ 中只会造成轻微的不便,因为 Crypto++ 库提供了转换例程。
在 C# 中,我们需要将格式从 DER 转换为 P1363。要为 C# 创建一个 DSASignatureConverter
类,请查看 AsnKeyParser
的代码。转换器没有太多内容——它是一个定义良好的结构。调用 NextSequence
以删除外部序列,然后返回两个解析的整数 r 和 s 的连接。在返回 r || s 之前,验证每个的长度是否为 20 字节(或相应调整)。请参阅下面的讨论 CryptoInteropSign.aspx?msg=3240277#xx3240277xx。
OpenPGP
OpenPGP 在 RFC 2440,“OpenPGP 消息格式”[10] 中指定。OpenPGP 使用签名包来表示消息上的签名。在 DSA 的情况下,这些是两个 MPI(多精度整数)r 和 s。第 5.2.2 节指定了版本 3 签名包格式,而第 5.2.3 节指定了版本 4 签名包格式。同样,Crypto++ 库提供了转换例程。
生成密钥、签名和验证
本节将探讨签名和验证过程。密钥生成已在密钥互操作性中讨论过,因此我们将重点关注 DSA 所需的内容。我们还将详细介绍 Crypto++,因为它不如 Java 和 C# 文档齐全。最后,为了实现互操作性,我们将加密转换应用于使用 UTF-8 编码的字符串的字节数组。我们的消息将是宽字符串“Crypto Interop: \u9aa8”,如图 1 所示。
![]() |
图 1:要签名的消息
|
Crypto++
密钥生成
为了生成用于消息签名的 DSA 密钥,我们在 Crypto++ 中执行以下操作。尽管我们可以使用 DSA::Signer
构造函数生成密钥,但我们选择推迟,以便我们可以使用该库。生成密钥后,我们通过覆盖 PKCS8PrivateKey
类的 Save
来保存它。PrivateKey
和 PublicKey
类没有复制构造函数,因此对 AccessPrivateKey
和 AccessPublicKey
的调用会接收引用。
DSA::Signer signer;
PrivateKey& privateKey = signer.AccessPrivateKey();
privateKey.GenerateRandom( prng );
privateKey.Save(FileSink("private.dsa.cpp.key"));
然后我们构建一个验证器对象。我们这样做是为了我们可以访问该对的公钥。不幸的是,我们无法通过私钥访问它。然后我们使用覆盖的 X509PublicKey::Save
保存它。
DSA::Verifier verifier( signer );
PublicKey& publicKey = verifier.AccessPublicKey();
publicKey.Save(FileSink("public.dsa.cpp.key"));
消息签名
我们使用 Crypto++ 签名消息如下。我们从一个宽字符串开始。然后将消息转换为 UTF-8 字符串并存储在 narrow
中。
// Crypto++ Load Private Key
DSA::Signer signer;
...
// Convert Wide String to UTF-8
wstring wide = L"Crypto Interop: \u9aa8";
string narrow;
WideCharToMultiByte( UTF8, ... );
...
const byte* data = narrow.c_str();
int length = narrow.length();
// Set up for SignMessage
byte* signature = new byte[ signer.MaxSignatureLength() ];
// PGP RandPool
AutoSeededRandomPool prng;
size_t length = signer.SignMessage( prng, data, length, signature );
使用 WideCharToMultiByte
将宽字符串转换为 UTF8 后,我们设置一个缓冲区(signature
)来保存签名。签名最多为 MaxSignatureLength
字节。Crypto++ 返回 0x28(40 字节)作为最大 DSA 签名长度。我们预计会这样,因为签名 (r, s) 是两个 160 位余数的连接。
最后,我们调用签名者对象上的 SignMessage
。由于每个消息变量 k,SignMessage
需要一个伪随机源。该函数返回签名的实际长度(以字节为单位),它也是 MaxSignatureLength
。接下来,我们将消息(我们签名的)和签名都保存到文件中。请注意,我们不能假设原始字符串 (L"Crypto Interop: \u9aa8") 将在编译和字符串和流构造后实际签名。
// mfs: message filestream
// sfs: signature filestream
ofstream mfs, sfs;
mfs.open("dsa.cpp.msg", ios_base::binary );
sfs.open("dsa.cpp.sig", ios_base::binary );
// Save Message which was Signed
mfs.write( narrow.c_str(), narrow.length() );
// Save Signature on Message
sfs.write( (const char*)signature, length );
在图 2 中,我们检查文件 out.cpp.msg 中的消息内容。我们看到字符串上的常规 UTF-8 压缩,除了最后一个汉字扩展为三个字节。
![]() |
图 2:消息文件内容
|
接下来,我们检查在同一消息上创建多个签名的结果以及文件 dsa.cpp.sig 的内容。我们使用相同的私钥运行两次例程,并在图 3 中并排比较结果。如果我们回顾消息签名过程,我们需要选择一个随机的每消息值 k。由于 k 是随机的,算法在同一消息上生成不同的签名。
![]() |
图 3:由于随机 k 导致消息上的不同签名
|
重要的是,我们必须区分在图 2 中,dsa.cpp.msg 是我们签名的消息,而不是原始字符串。当 Java 或 C# 验证我们的 Crypto++ 消息时,它们将验证此文件中的字节,然后重建原始字符串。
消息签名(DER 编码)
如果我们的消息和签名需要 DER 编码以用于 Java 等系统,我们将执行以下操作。下面,在签名过程之后和将签名写入磁盘之前检查该过程。
// Determine size of required buffer
length = DSAConvertSignatureFormat( NULL, 0, DSA_DER,
signature.c_str(),signature.length, DSA_P1363 );
// A buffer for the conversion
byte* buffer = new byte[ length ];
// We are P1363 format. Java desires DER encoding
length = DSAConvertSignatureFormat( buffer, length, DSA_DER,
signature.c_str(), signature.length(), DSA_P1363 );
消息验证
验证过程将放弃标准库的流,转而使用 Crypto++ FileSource
。FileSource
会将文件内容放入 std::string
中。在 Crypto++ 的以下用法中,字符串类似于向量——它是字节的集合。Crypto++ 使用 Unix 管道范式。密钥是源,所以我们需要一个目的地——FileSink
。
// std::string used as a byte array
string message, signature;
FileSource( "dsa.cpp.msg", true, new StringSink( message ) );
FileSource( "dsa.cpp.sig", true, new StringSink( signature ) );
接下来我们验证消息。回想一下 Crypto++ 是字节进、字节出——因此需要 const byte*
类型转换。
bool result = verifier.VerifyMessage(
(const byte*)message.c_str(), message.length(),
(const byte*)signature.c_str(), signature.length() );
最后,转换回宽字符串,结果如图 4 所示。
![]() |
图 4:消息验证和转换
|
消息验证(DER 编码)
回想一下,Java 对 m 上的签名 (r, s) 进行 DER 编码。当我们从 Java 收到 DER 编码的签名时,我们执行以下操作。
FileSource( "dsa.java.msg", true, new StringSink( message ) );
FileSource( "dsa.java.sig", true, new StringSink( signature ) );
// First, a buffer for the conversion
size_t length = verifier.SignatureLength();
byte* buffer = new byte[ length ];
// DER encoded from Java. We desire P1363 format
length = DSAConvertSignatureFormat( buffer, length,
DSA_P1363, signature.c_str(), signature.length(), DSA_DER );
// Reinitialize signature so that it can be used
// in the verifier below with minimal effort
signature = string( (const char*)buffer, length );
delete[] buffer;
// Verify the Signature on the Message
bool result = verifier.VerifyMessage(
(const byte*)message.c_str(), message.length(),
(const byte*)signature.c_str(), signature.length() );
Java
Java 因其更好的文档而更受欢迎,因此以下内容是为了完整性而呈现的。Java 加密扩展 (JCE) 参考指南 [8] 回答了大多数问题。
密钥生成
我们用于在 Java 中创建 DSA 密钥对的代码如下。例程完成后,我们将使用 getBytes
序列化密钥以备将来使用。getBytes
返回对象的默认格式,即 PKCS#8 或 X.509 消息。
KeyPairGenerator kpg = KeyPairGenerator.getInstance("DSA");
kpg.initialize(1024, new SecureRandom());
KeyPair keys = kpg.generateKeyPair();
PrivateKey privateKey = keys.getPrivate();
PublicKey publicKey = keys.getPublic();
消息签名
要使用我们生成的密钥对消息进行签名,我们执行以下操作。
// Retrieve the Private Key
PrivateKey privateKey = LoadPrivateKey("private.dsa.java.key");
// Create the signer object
Signature signer = Signature.getInstance("DSA");
signer.initSign(privateKey, new SecureRandom());
// Prepare the Message
String s = "Crypto Interop: \u9aa8";
// Save the binary of the String which we will sign
byte[] message = s.getBytes("UTF-8");
// Sign the message
signer.update(message);
byte[] signature = signer.sign();
然后我们将字节数组 message
和 signature
保存到磁盘,以供其他库进行验证。
消息验证
验证消息如下。下面,我们验证在 C# 中生成的消息。
// Load the public
PublicKey publicKey = LoadPublicKey("public.dsa.cs.key");
// Load the message from file
byte[] message = LoadMessageFile("dsa.cs.msg");
// Load the signature on the message from file
byte[] signature = LoadSignatureFile("dsa.cs.sig");
// Initialize Signature Object
Signature verifier = Signature.getInstance("DSA");
verifier.initVerify(publicKey);
// Load the message into the verifier
verifier.update(message);
// Verify the Signature on the Message
boolean result = verifier.verify(signature);
与 Crypto++ 和 C# 不同,Java 代码期望消息 m 上的签名 (r, s) 采用 DER 编码格式。尝试验证 P1363 签名会导致编码异常。作为一种解决方法,我们的 Crypto++ 和 C# 源代码将对 Java 的签名进行 DER 编码。
C#
密钥生成
密码学互操作性:密钥 [1] 是对生成、加载和保存密钥的相当全面的处理,因此我们只回顾基础知识。下面我们创建一个密钥对以在 C# 中使用。
CspParameters csp = new CspParameters();
csp.KeyContainerName = "DSA Test (OK to Delete)";
csp.ProviderType = PROV_DSS_DH; // 13
csp.KeyNumber = AT_SIGNATURE; // 2
DSACryptoServiceProvider dsa = new DSACryptoServiceProvider(1024, csp);
// Keys
DSAParameters privateKey = dsa.ExportParameters(true);
DSAParameters publicKey = dsa.ExportParameters(false);
由于我们使用了接受 CSP 和整数位数的 DSACryptoServiceProvider
,因此构造函数为我们创建了一个密钥对。我们还根据 MSDN 指定 PROV_DSS_DH
和 AT_SIGNATURE
(如果愿意,我们实际上可以指定 PROV_DSS
)。最后,我们通过调用 ExportParameters
导出密钥。然后我们使用 AsnKeyBuilder
类将 DSAParameters
转换为 PKCS#8 或 X.509 编码密钥以进行序列化。
消息签名
要对消息进行签名,我们执行以下操作。dsa
是上面的服务提供商。请记住,签名是 P1363 格式的,因此 DSASignatureFormatter
执行 r 和 s(两个 20 字节数组)的连接。
DSASignatureFormatter signer = new DSASignatureFormatter(dsa);
//Set the hash algorithm to SHA1.
signer.SetHashAlgorithm("SHA1");
String m = "Crypto Interop: \u9aa8";
Encoding e = Encoding.GetEncoding("UTF-8");
byte[] message = e.GetBytes(m);
// Hash the Message
SHA1 sha = new SHA1CryptoServiceProvider();
byte[] hash = sha.ComputeHash(message);
// Create the Signature for h(m)
byte[] signature = signer.CreateSignature(hash);
然后我们将序列化消息 m 和消息 m 上的签名 (r, s)。
消息验证
有关加载 DSA 密钥的详细信息,请参阅密码互操作性:密钥 [1]。我们使用 AsnKeyParser
的 LoadPublicKey
和 LoadPrivateKey
如下重建公钥或私钥。AsnKeyParser
处理解析使用 ASN.1 语法描述的密钥的繁重工作。如果密钥格式错误,请准备好捕获 BerDecodeError
。
AsnKeyParser keyParser = new AsnKeyParser("public.dsa.cs.key");
DSAParameters publicKey = keyParser.ParseDSAPublicKey();
接下来我们打开容器。在这种情况下,DSACryptoServiceProvider
使用只接受 CSP 的构造函数(而不是 CSP 和整数位数)。这向提供程序指示我们不希望生成密钥对。请**注意**,我们使用 PROV_DSS
而不是 PROV_DSS_DH
,因为我们不再有 J 和种子等参数。
CspParameters csp = new CspParameters();
csp.KeyContainerName = "DSA Test (OK to Delete)";
csp.ProviderType = PROV_DSS; // 3
csp.KeyNumber = AT_SIGNATURE; // 2
// Load key into provider
DSACryptoServiceProvider dsa = new DSACryptoServiceProvider(csp);
dsa.ImportParameters(publicKey);
一旦提供程序在调用 ImportParameters
时接受我们的参数,该练习就变得学术化了。图 5 显示了一个使用 AsnKeyParser
重建的私钥。注意缺少 seed
和参数 J(群因子)。这是由于 PKCS#8 和 X.509——没有序列化参数的规范。
![]() |
图 5:PKCS#8 私钥参数
|
下面,我们读取构成消息和签名的 byte[] 数组。
byte[] message = LoadMessage();
byte[] signature = LoadSignature();
最后,是验证签名的代码。有趣的是,DSASignatureDeformatter
不接受哈希对象。我们必须提供一个字符串来描述我们对 SetHashAlgorithm
的选择。
SHA1 sha = new SHA1CryptoServiceProvider();
byte[] hash = sha.ComputeHash(message);
// Verifier
DSASignatureDeformatter verifier = new DSASignatureDeformatter(dsa);
verifier.SetHashAlgorithm("SHA1");
bool result = verifier.VerifySignature(hash, signature);
请注意,C# 可能会抛出 CryptographicException
,指出“DSA 签名的长度不是 40 字节”。我们预计 Java 会出现这种情况,因为 Java 使用 DER 编码,而 C# 使用 P1363 格式。
![]() |
图 6:DER 编码签名异常
|
在 C# 中,我们需要将格式从 DER 转换为 P1363。要为 C# 创建一个 DSASignatureConverter
类,请查看 AsnKeyParser
的代码。调用 NextSequence
以删除外部序列,然后返回两个解析的整数 r 和 s 的连接。在返回 r || s 之前,验证每个的长度是否为 20 字节(或相应调整)。请参阅下面的讨论 CryptoInteropSign.aspx?msg=3240277#xx3240277xx。
如果我们退出 Main 时收到密码异常,声明“密钥集不存在”,我们应该显式处置容器。示例中的多个方法打开了一个名为“DSA Test (OK to Delete)”的容器,每个方法都设置了 PersistKeyInCsp = false
。当发生垃圾回收时,每个托管对象都会尝试删除共享的本机资源。为了避免这种情况,我们必须通过在打开资源的方法中调用 Dispose
、Close
或 Clear
来完成对象,这通常不建议这样做。
![]() |
图 7:密钥集不存在异常
|
字符串和流
尽管字符串和流非常方便,但它们在签名和验证消息时给我们带来了最多的问题。这是因为字符串只是最终成为字节数组的编码,然后对其应用加密转换。当我们从字符串转换为字节数组时,通常会在两个地方之一引入不一致。
第一个问题可能是在允许流为字符串转换选择编码时引入的。第二个问题是当程序员(一个实现签名者,另一个实现验证者)为同一字符串选择不同的编码转换时引入的。请注意,当我们显式使用字节数组时,这种情况不会发生。
由于我们有时会使用字符串,我们需要决定使用哪种类型的编码。为此,Unicode 联盟建议将 UTF-8 用于数据交换 [3]。由于几乎所有主要库都支持 UTF-8,我们将始终使用它以获得一致的结果。我们选择 UTF-8 是在互操作性、压缩和信道效率之间的一种折衷。我们还应该意识到,还有其他 Unicode 字符集(例如 SCSU 和 BOCU-1)对于存储和数据交换更有效 [4,5]。
该联盟还定义了 UTF-7 和 UTF-8 等字符集之间如何进行转换。当使用代码页 CP_UTF8
时,转换算法在 Windows 函数 WideCharToMultiByte
和 MultiByteToWideChar
中实现 [6]。
Crypto++
Crypto++ 对于字符串和流是不可知的。对于 Crypto++,它是字节进,字节出。与 Java 和 C# 不同,没有接受高级字符串的方法。但是,C++ 标准库在使用流时确实会影响字符串。
我们已经知道 Visual Studio 使用 UTF-16 编码。在下面,我们将探讨流对 Visual C++ 中字符串的影响。对于我们的第一个示例,请考虑下面列出的程序。
wstring ws = L"crypto";
wofstream ofs;
ofs.open("out.cpp.bin", ios_base::binary);
ofs << ws;
ofs.close();
当我们在图 8 中检查其文件输出时,我们看到尽管我们使用的是标准库的宽版本并指定了二进制模式,但仍然发生了转换。
![]() |
图 8:宽流二进制输出
|
为了进一步调查,我们指定了汉字“骨”(U+9AA8)。我们可以使用一个欧洲代码点,但我们不妨深入探讨这个主题。不幸的是,标准 C++ 库在这种情况下完全失败了。在下面的图 9 中,Visual Studio IntelliSense 正确显示了字符,而标准库生成了一个空文件。事实上,删除二进制模式仍然会产生这个结果。这是 Microsoft 流类的一个已知问题。
![]() |
图 9:宽流二进制输出失败
|
为了解决这个问题,我们有两种选择。第一个解决方法涉及迭代宽字符串的字符,同时将它们单独写入流中,如下所示。最终效果是我们正在写入一个 UTF-16 流。根据我们选择输出字节的方式,我们实现了一个大端(UTF-16BE)或小端(UTF-16LE)流。单个亚洲字符的结果如图 10 所示。
wstring::const_iterator it = ws.begin();
for( ; it != ws.end; it++ )
{
// Little Endian
ofs.put( (*it & 0x00FF) );
ofs.put( (*it & 0xFF00)>>8 )
}
![]() |
图 10:UTF-16LE 输出
|
在第二种方法中,我们使用 WideCharToMultiByte
[7] 和多字节流。最终效果是我们正在写入一个 UTF-8 流。更正后的程序如下所示。图 11 显示了输出,即 UTF-8 编码的宽字符串 - 0xE9 0xAA 0xA8。
wstring ws = L"\u9aa8";
char* utf = NULL;
// UTF-8 Encode
int nChars = WideCharToMultiByte( CP_UTF8, 0, ws.c_str(), -1, NULL, 0, NULL, FALSE );
utf = new char[ nChars ];
WideCharToMultiByte( CP_UTF8, 0, ws.c_str(), -1, utf, nChars, NULL, FALSE );
ofstream ofs;
ofs << utf;
...
![]() |
图 11:UTF-8 输出
|
Java
现在我们将探讨 Java 中发生的情况。我们有两种情况需要检查:使用流的 writeUTF
方法写入字符串;以及在字符串上调用 getBytes
的效果。首先我们检查下面的 writeUTF
。
DataOutputStream dos = new DataOutputStream( new FileOutpuStream("out.java.bin"));
String s = "crypto";
dos.writeUTF(s);
在图 12 中,我们看到 DataOutputStream
的方法以 16 位长度作为编码前缀。对于互操作性而言,这可能是一个糟糕的选择。
![]() |
图 12:Java DataOutputStream 输出
|
接下来,我们修改 Java 程序以探索从 getBytes
返回的各种字节数组。
DataOutputStream dos = new DataOutputStream( new FileOutputStream("out.java.bin"));
String s = "crypto";
byte[] b = s.getBytes();
dos.write(b, 0, b.length);
使用 getBytes
,我们的输出与图 13 相同——字符串已使用平台的默认字符集进行编码。接下来我们运行程序,请求 UTF-16 编码
byte[] b = s.getBytes("UTF16");
在图 13 中,我们看到一个带有字节顺序标记的大端数组。同样,对于互操作性而言,这可能是一个糟糕的选择。
![]() |
图 13:Java getBytes("UTF16") 输出
|
当我们使用 getBytes("UTF8")
和 getBytes("UTF32")
运行 Java 程序时,我们发现没有字节顺序标记写入流。在 UTF-32 的情况下,数组再次是大端。我们观察到使用 UTF-8 的预期(和期望的)结果。
C#
我们的第一个 C# 示例检查了使用 StreamWriter
写入字符串时使用默认编码的结果。程序如下所示,其输出如图 14 所示。
using (TextWriter writer = new StreamWriter("out.cs.bin"))
{
String s = "crypto";
writer.Write(s);
}
![]() |
图 14:C# StreamWriter 输出
|
与 Java 一样,我们观察到流使用默认编码 UTF-8。接下来我们修改程序以使用 UTF-16 编码。
using (BinaryWriter writer = new BinaryWriter(
new FileStream("out.cs.bin", FileMode.Create, FileAccess.ReadWrite)))
{
String s = "crypto";
Encoding e = Encoding.GetEncoding("UTF-16");
byte[] b = e.GetBytes(s);
writer.Write(b);
}
结果如图 15 所示。我们观察到写入了一个小端流。此外,与 Java 不同,没有字节顺序标记。
![]() |
图 15:C# BinaryWriter 输出
|
与 Java 一样,我们必须在使用 UTF-16 时处理字节顺序。这再次引导我们使用 UTF-8,它没有字节顺序和字节顺序标记问题。
致谢
- Wei Dai for Crypto++ 及其在 Crypto++ 邮件列表上的宝贵帮助
- A. Brooke Stephens 博士,他为我打下了密码学基础
校验和
- CryptoPPInteropSign.zip
- MD5: 2647DE3E5E06A07F8CD05F911D75DC3B
- SHA-1: 74DBED5386D64C9041EE66CFCF884C79F8961B0C
- JavaInteropSign.zip
- MD5: BA74E602379395177681172EFC73591E
- SHA-1: B38D7755F6D6D9D4C6ADBE698EF77B771236AB4C
- CSInteropSign.zip
- MD5: 20A78F6E7817F523923CE2E0B21E95E9
- SHA-1: 2D69E88935B549993D974C8A41D0091BE3547C5A
参考文献
[1] J. Walton,《密码互操作性:密钥》,2008 年 4 月,CryptoInteropKeys.aspx。
[2] FIPS 186-3 草案,《数字签名标准》,http://csrc.nist.gov/publications/drafts/fips_186-3/Draft-FIPS-186-3%20_March2006.pdf。
[3] Unicode 联盟,《用于处理的 UTF-16》,Unicode 技术说明 #12,http://unicode.org/notes/tn12/。
[4] Unicode 联盟,《Unicode 压缩概览》,Unicode 技术说明 #14,http://unicode.org/notes/tn14/。
[5] Unicode 联盟,《Unicode 文本快速压缩算法》,Unicode 技术说明 #31,http://unicode.org/notes/tn31/。
[6] D. Schmitt,《Microsoft Windows 国际化编程》,Microsoft Press,ISBN 1-5723-1956-9。
[7] MSDN,《WideCharToMultiByte》,http://msdn2.microsoft.com/en-us/library/ms776420(VS.85).aspx。
[8] 《Java 密码学体系结构 (JCA) 参考指南》,http://java.sun.com/javase/6/docs/technotes/guides/security/crypto/CryptoSpec.html。
[9] IEEE P1363,《公钥密码学标准规范》。
[10] RFC 2440,《OpenPGP 消息格式》,1998 年 11 月,http://www.ietf.org/rfc/rfc2440.txt。
[11] FIPS 186-2,《数字签名标准》,2007 年 1 月,http://csrc.nist.gov/publications/fips/fips186-2/fips186-2-change1.pdf。
[12] A. Menenzes 等人,《应用密码学手册》,CRC Press,ISBN 0-8493-8523-7,第 451-2 页。
[13] RFC 3275,《XML-Signature 语法和处理》,2002 年 3 月,http://www.ietf.org/rfc/rfc3275.txt。
[14] RFC 3279,《互联网 X.509 公钥基础设施的算法和标识符》,2002 年 4 月,http://www.ietf.org/rfc/rfc3279.txt。