Skip to main content

wx_rust_common/util/crypto/
wx_crypt_util.rs

1//! 微信消息加解密工具。
2//!
3//! 对应 Java `me.chanjar.weixin.common.util.crypto.WxCryptUtil`。
4//!
5//! 实现公众号/企业微信消息体的 AES-CBC 加解密(微信方案):
6//! - 加密:`randomStr + 网络序长度 + 明文 + appid` 拼接 → PKCS7 填充 → AES-CBC(NoPadding) → Base64
7//! - 解密:Base64 解码 → AES-CBC 解密 → 去填充 → 拆解字段 → 校验 appid
8//! - 签名:`SHA1(token, timestamp, nonce, encrypt)` 排序拼接
9
10use aes::Aes256;
11use cbc::cipher::block_padding::NoPadding;
12use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, Iv, Key, KeyIvInit};
13
14use super::byte_group::ByteGroup;
15use super::pkcs7_encoder::Pkcs7Encoder;
16use super::sha1::Sha1;
17
18/// AES-256-CBC 密码(微信使用 NoPadding,填充由 PKCS7Encoder 自定义处理)
19type Aes256Cbc = cbc::Encryptor<Aes256>;
20type Aes256CbcDec = cbc::Decryptor<Aes256>;
21
22/// 加密上下文(`encrypt` 的返回值)。
23#[derive(Debug, Clone)]
24pub struct EncryptContext {
25    /// 加密后的消息密文(Base64)
26    pub encrypted_xml: String,
27    /// 安全签名
28    pub signature: String,
29    /// 时间戳
30    pub timestamp: String,
31    /// 随机串
32    pub nonce: String,
33}
34
35/// 微信消息加解密工具实例。
36#[derive(Debug, Clone)]
37pub struct WxCryptUtil {
38    /// 消息体加密密钥(44 字节 base64 编码的 aesKey,解码后 32 字节)
39    aes_key: Vec<u8>,
40    /// 令牌(消息校验 token)
41    token: String,
42    /// 公众号 appid 或企业微信 corpid
43    appid_or_corpid: String,
44}
45
46impl WxCryptUtil {
47    /// 构建加解密工具。
48    ///
49    /// # 参数
50    /// - `token`:消息校验 token(公众号后台配置)
51    /// - `aes_key`:消息加密密钥(EncodingAESKey,44 字符 base64)
52    /// - `appid_or_corpid`:公众号 appid 或企业微信 corpid
53    ///
54    /// # 返回
55    /// 构建失败(aesKey 解码异常)时返回错误。
56    pub fn new(
57        token: impl Into<String>,
58        aes_key: impl Into<String>,
59        appid_or_corpid: impl Into<String>,
60    ) -> Result<Self, String> {
61        let token = token.into();
62        let aes_key_str = aes_key.into();
63        // Java Base64.decodeBase64(aesKey) —— Commons Codec 宽松模式:
64        // 容忍无 padding、容忍非规范尾字符(如 43 字符的微信 EncodingAESKey)。
65        // 实现:先用 STANDARD/NO_PAD 解码;失败则按 4 字符对齐补 'A'(0 值位)
66        // 使尾字符通过严格校验,再解码(等价 Java 宽松行为)。
67        let aes_key =
68            lenient_base64_decode(&aes_key_str).map_err(|e| format!("aesKey 解码失败: {e}"))?;
69        if aes_key.len() != 32 {
70            return Err(format!("aesKey 解码后长度应为 32,实际 {}", aes_key.len()));
71        }
72        Ok(Self {
73            aes_key,
74            token,
75            appid_or_corpid: appid_or_corpid.into(),
76        })
77    }
78
79    /// 将公众平台回复用户的消息加密打包。
80    ///
81    /// <ol>
82    /// <li>对要发送的消息进行 AES-CBC 加密</li>
83    /// <li>生成安全签名</li>
84    /// <li>将消息密文和安全签名打包成 xml 格式</li>
85    /// </ol>
86    ///
87    /// # 参数
88    /// - `plain_text`:公众平台待回复用户的消息(xml 格式字符串)
89    ///
90    /// # 返回
91    /// 加密后的可直接回复用户的密文(含 msg_signature、timestamp、nonce、encrypt 的 xml)。
92    pub fn encrypt(&self, plain_text: &str) -> Result<String, String> {
93        let ctx = self.encrypt_context(plain_text)?;
94        Ok(Self::generate_xml(
95            &ctx.encrypted_xml,
96            &ctx.signature,
97            &ctx.timestamp,
98            &ctx.nonce,
99        ))
100    }
101
102    /// 将公众平台回复用户的消息加密打包,返回加密所需的值对象。
103    ///
104    /// # 参数
105    /// - `plain_text`:公众平台待回复用户的消息(xml 格式字符串)
106    ///
107    /// # 返回
108    /// 加密消息所需的值对象。
109    pub fn encrypt_context(&self, plain_text: &str) -> Result<EncryptContext, String> {
110        // 加密
111        let random_str = Self::gen_random_str();
112        let encrypted_xml = self.encrypt_with_random(&random_str, plain_text)?;
113
114        // 生成安全签名
115        let timestamp = (std::time::SystemTime::now()
116            .duration_since(std::time::UNIX_EPOCH)
117            .map_err(|e| e.to_string())?
118            .as_secs())
119        .to_string();
120        let nonce = Self::gen_random_str();
121
122        let signature = Sha1::digest_with_amp(&[&self.token, &timestamp, &nonce, &encrypted_xml])?;
123        Ok(EncryptContext {
124            encrypted_xml,
125            signature,
126            timestamp,
127            nonce,
128        })
129    }
130
131    /// 对明文进行加密(不打包 xml)。
132    ///
133    /// # 参数
134    /// - `random_str`:随机字符串
135    /// - `plain_text`:需要加密的明文
136    ///
137    /// # 返回
138    /// 加密后 base64 编码的字符串。
139    pub fn encrypt_with_random(
140        &self,
141        random_str: &str,
142        plain_text: &str,
143    ) -> Result<String, String> {
144        let mut collector = ByteGroup::new();
145        let random_bytes = random_str.as_bytes();
146        let plain_bytes = plain_text.as_bytes();
147        let size_bytes = Self::number_2_bytes_in_network_order(plain_bytes.len() as i32);
148        let appid_bytes = self.appid_or_corpid.as_bytes();
149
150        // randomStr + networkBytesOrder + text + appid
151        collector.add_bytes(random_bytes);
152        collector.add_bytes(&size_bytes);
153        collector.add_bytes(plain_bytes);
154        collector.add_bytes(appid_bytes);
155
156        // ... + pad: 使用自定义的填充方式对明文进行补位填充
157        let pad_bytes = Pkcs7Encoder::encode(collector.size());
158        collector.add_bytes(&pad_bytes);
159
160        // 获得最终的字节流(未加密)
161        let unencrypted = collector.to_bytes();
162
163        // AES-256-CBC,Key=IV=aesKey 前 16 字节
164        let mut key = Key::<Aes256Cbc>::default();
165        key.clone_from_slice(&self.aes_key);
166        let mut iv = Iv::<Aes256Cbc>::default();
167        iv.clone_from_slice(&self.aes_key[..16]);
168        let cipher = Aes256Cbc::new(&key, &iv);
169
170        // CBC 加密(微信长度是 32 的倍数,NoPadding 不额外填充)
171        let mut buf = vec![0u8; unencrypted.len()];
172        cipher
173            .encrypt_padded_b2b::<NoPadding>(&unencrypted, &mut buf)
174            .map_err(|e| format!("加密失败: {e}"))?;
175
176        Ok(base64::Engine::encode(
177            &base64::engine::general_purpose::STANDARD,
178            buf,
179        ))
180    }
181
182    /// 检验消息的真实性,并且获取解密后的明文。
183    ///
184    /// <ol>
185    /// <li>利用收到的密文生成安全签名,进行签名验证</li>
186    /// <li>若验证通过,则提取 xml 中的加密消息</li>
187    /// <li>对消息进行解密</li>
188    /// </ol>
189    ///
190    /// # 参数
191    /// - `msg_signature`:签名串,对应 URL 参数的 msg_signature
192    /// - `timestamp`:时间戳,对应 URL 参数的 timestamp
193    /// - `nonce`:随机串,对应 URL 参数的 nonce
194    /// - `encrypted_xml`:包含 Encrypt 密文的 xml(POST 请求数据)
195    ///
196    /// # 返回
197    /// 解密后的原文。
198    pub fn decrypt_xml(
199        &self,
200        msg_signature: &str,
201        timestamp: &str,
202        nonce: &str,
203        encrypted_xml: &str,
204    ) -> Result<String, String> {
205        // 提取密文
206        let cipher_text = Self::extract_encrypt_part(encrypted_xml)?;
207        self.decrypt_content(msg_signature, timestamp, nonce, &cipher_text)
208    }
209
210    /// 验证签名后解密内容。
211    pub fn decrypt_content(
212        &self,
213        msg_signature: &str,
214        timestamp: &str,
215        nonce: &str,
216        cipher_text: &str,
217    ) -> Result<String, String> {
218        // 验证安全签名
219        let signature = Sha1::digest_with_amp(&[&self.token, timestamp, nonce, cipher_text])?;
220        if signature != msg_signature {
221            return Err("签名验证错误".to_string());
222        }
223
224        // 解密
225        let encrypted =
226            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, cipher_text)
227                .map_err(|e| format!("base64 解码失败: {e}"))?;
228        let mut key = Key::<Aes256CbcDec>::default();
229        key.clone_from_slice(&self.aes_key);
230        let mut iv = Iv::<Aes256CbcDec>::default();
231        iv.clone_from_slice(&self.aes_key[..16]);
232        let cipher = Aes256CbcDec::new(&key, &iv);
233
234        // CBC 解密
235        let mut decrypted_buf = vec![0u8; encrypted.len()];
236        let decrypted_all = cipher
237            .decrypt_padded_b2b::<NoPadding>(&encrypted, &mut decrypted_buf)
238            .map_err(|e| format!("解密失败: {e}"))?
239            .to_vec();
240
241        // 去除补位字符
242        let decrypted = Pkcs7Encoder::decode(&decrypted_all);
243
244        // 拆分:16 字节随机串 + 4 字节网络序长度 + 明文 + appid
245        if decrypted.len() < 20 {
246            return Err("解密后数据长度非法".to_string());
247        }
248        let len_bytes: [u8; 4] = decrypted[16..20].try_into().unwrap();
249        let xml_len = Self::bytes_network_order_2_number(&len_bytes) as usize;
250        if 20 + xml_len > decrypted.len() {
251            return Err("解密后 xml 长度非法".to_string());
252        }
253        let xml = String::from_utf8_lossy(&decrypted[20..20 + xml_len]).into_owned();
254        let from_appid = String::from_utf8_lossy(&decrypted[20 + xml_len..]).into_owned();
255
256        // appid 校验
257        if from_appid != self.appid_or_corpid {
258            return Err(format!(
259                "appid 校验失败:报文 appid={from_appid},本地 appid={}",
260                self.appid_or_corpid
261            ));
262        }
263        Ok(xml)
264    }
265
266    /// 对密文进行解密(不校验签名),返回明文。
267    ///
268    /// 对应 Java `WxCryptUtil.decrypt(String cipherText)`。
269    ///
270    /// # 参数
271    /// - `cipher_text`:Base64 编码的密文
272    ///
273    /// # 返回
274    /// 解密后的明文(含随机串前缀与 appid 校验)。
275    pub fn decrypt(&self, cipher_text: &str) -> Result<String, String> {
276        let encrypted =
277            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, cipher_text)
278                .map_err(|e| format!("base64 解码失败: {e}"))?;
279        let key = Key::<Aes256CbcDec>::default();
280        let mut key_copy = key;
281        key_copy.clone_from_slice(&self.aes_key);
282        let mut iv = Iv::<Aes256CbcDec>::default();
283        iv.clone_from_slice(&self.aes_key[..16]);
284        let cipher = Aes256CbcDec::new(&key_copy, &iv);
285
286        let mut decrypted_buf = vec![0u8; encrypted.len()];
287        let decrypted_all = cipher
288            .decrypt_padded_b2b::<NoPadding>(&encrypted, &mut decrypted_buf)
289            .map_err(|e| format!("解密失败: {e}"))?
290            .to_vec();
291
292        // 去除补位字符
293        let decrypted = Pkcs7Encoder::decode(&decrypted_all);
294
295        // 拆分:16 字节随机串 + 4 字节网络序长度 + 明文 + appid
296        if decrypted.len() < 20 {
297            return Err("解密后数据长度非法".to_string());
298        }
299        let len_bytes: [u8; 4] = decrypted[16..20].try_into().unwrap();
300        let xml_len = Self::bytes_network_order_2_number(&len_bytes) as usize;
301        if 20 + xml_len > decrypted.len() {
302            return Err("解密后 xml 长度非法".to_string());
303        }
304        let xml = String::from_utf8_lossy(&decrypted[20..20 + xml_len]).into_owned();
305        let from_appid = String::from_utf8_lossy(&decrypted[20 + xml_len..]).into_owned();
306
307        // appid 校验
308        if from_appid != self.appid_or_corpid {
309            return Err(format!(
310                "appid 校验失败:报文 appid={from_appid},本地 appid={}",
311                self.appid_or_corpid
312            ));
313        }
314        Ok(xml)
315    }
316
317    /// 从 xml 中提取 Encrypt 节点的密文。
318    fn extract_encrypt_part(xml: &str) -> Result<String, String> {
319        // 微信消息 xml 中 Encrypt 节点为 CDATA 包裹;此处用轻量提取(不引入完整 XML 解析)
320        let start = xml.find("<Encrypt>").ok_or("xml 中未找到 <Encrypt>")? + "<Encrypt>".len();
321        let end = xml[start..]
322            .find("</Encrypt>")
323            .ok_or("xml 中未找到 </Encrypt>")?;
324        let content = &xml[start..start + end];
325        // 去除可能的 CDATA 包裹
326        let content = content.trim();
327        if let Some(rest) = content.strip_prefix("<![CDATA[") {
328            if let Some(v) = rest.strip_suffix("]]>") {
329                return Ok(v.to_string());
330            }
331        }
332        Ok(content.to_string())
333    }
334
335    /// 生成加密响应 xml。
336    fn generate_xml(encrypt: &str, signature: &str, timestamp: &str, nonce: &str) -> String {
337        format!(
338            "<xml>\n<Encrypt><![CDATA[{encrypt}]]></Encrypt>\n<MsgSignature><![CDATA[{signature}]]></MsgSignature>\n<TimeStamp>{timestamp}</TimeStamp>\n<Nonce><![CDATA[{nonce}]]></Nonce>\n</xml>"
339        )
340    }
341
342    /// int 转 4 字节网络序(大端)。
343    fn number_2_bytes_in_network_order(number: i32) -> [u8; 4] {
344        (number as u32).to_be_bytes()
345    }
346
347    /// 4 字节网络序转 int。
348    fn bytes_network_order_2_number(bytes: &[u8]) -> i32 {
349        let mut arr = [0u8; 4];
350        arr.copy_from_slice(&bytes[..4]);
351        i32::from_be_bytes(arr)
352    }
353
354    /// 生成 16 位随机字符串(对应 Java `genRandomStr`,基于 UUID)。
355    pub fn gen_random_str() -> String {
356        const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
357        (0..16)
358            .map(|_| {
359                let idx = rand::random_range(0..CHARS.len());
360                CHARS[idx] as char
361            })
362            .collect()
363    }
364}
365
366/// 宽松 Base64 解码(对齐 Java Commons Codec 语义)。
367///
368/// 先尝试严格/NO_PAD 引擎;若仅因尾字符非规范填充位失败,
369/// 则将最后字符的额外位清零后重试(等价 Java 宽松行为)。
370fn lenient_base64_decode(input: &str) -> Result<Vec<u8>, String> {
371    let s = input.trim();
372    // 尝试严格引擎(带 padding)
373    if let Ok(v) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s) {
374        return Ok(v);
375    }
376    // 尝试 NO_PAD(无 padding)
377    if let Ok(v) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, s) {
378        return Ok(v);
379    }
380    // 宽松模式:长度非 4 倍数时,把尾字符的额外位清零(按 4 对齐到合法输入)
381    let rem = s.len() % 4;
382    if rem == 1 {
383        // 1 个尾字符无法构成字节,Java 亦会丢弃
384        let s2 = &s[..s.len() - 1];
385        return base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, s2)
386            .map_err(|e| e.to_string());
387    }
388    if rem == 2 || rem == 3 {
389        // 尾字符只保留 2 位(rem=2)或 4 位(rem=3)有效数据:
390        // 将尾字符替换为合法编码(低 2 位清零)
391        if let Some(&last) = s.as_bytes().last() {
392            if let Some(idx) = base64_charset_index(last) {
393                let masked = idx & !0b11;
394                if let Some(ch) = base64_charset_char(masked) {
395                    let mut s2 = s[..s.len() - 1].to_string();
396                    s2.push(ch as char);
397                    return base64::Engine::decode(
398                        &base64::engine::general_purpose::STANDARD_NO_PAD,
399                        &s2,
400                    )
401                    .map_err(|e| e.to_string());
402                }
403            }
404        }
405    }
406    Err("无法解码".to_string())
407}
408
409/// 返回 base64 字符在标准字符集中的索引。
410fn base64_charset_index(c: u8) -> Option<u8> {
411    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
412    CHARS.iter().position(|&x| x == c).map(|i| i as u8)
413}
414
415/// 返回 base64 索引对应的字符。
416fn base64_charset_char(idx: u8) -> Option<u8> {
417    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
418    CHARS.get(idx as usize).copied()
419}