gmcrypto_core/sm2/decrypt.rs
1//! SM2 public-key decryption (GB/T 32918.4-2017 §7).
2//!
3//! # Algorithm
4//!
5//! ```text
6//! Input: recipient private key d_B, GM/T 0009 DER ciphertext blob
7//! Output: plaintext M
8//!
9//! 1. Decode DER → (x1, y1, C3, C2)
10//! 2. Construct C1 = (x1, y1); reject if not on the SM2 curve
11//! 3. (x2, y2) = d_B * C1
12//! 4. t = KDF(x2 || y2, |C2|)
13//! 5. If t is all zeros, abort
14//! 6. M = C2 XOR t
15//! 7. u = SM3(x2 || M || y2)
16//! 8. If u != C3 (constant-time compare), abort
17//! 9. Output M
18//! ```
19//!
20//! # Failure-mode invariant
21//!
22//! Every failure mode collapses to a single
23//! [`crate::Error::Failed`] return — malformed DER, off-curve `C1`,
24//! identity `C1`, all-zero KDF, MAC mismatch. No distinguishing
25//! variants per the project's failure-mode invariant. SECURITY.md
26//! has the full rationale.
27//!
28//! # Constant-time stance
29//!
30//! Decrypt operates on the recipient's secret `d_B`. The
31//! constant-time-relevant work happens via:
32//!
33//! - `mul_var(d_B, C1)`: covered by v0.1's `ct_mul_var` harness target
34//! plus the W0 direct-invert diagnostics.
35//! - `to_affine` after `mul_var`: covered by W0's `ct_fp_invert`.
36//! - KDF (counter-mode SM3): SM3 itself is data-independent in timing.
37//! - `M = C2 XOR t`: byte-wise XOR loop, branchless.
38//! - MAC compare: `subtle::ConstantTimeEq` on the 32-byte digest.
39//! - **All-zero KDF detection: non-branching.** A naïve early-return
40//! on KDF-zero would gift a chosen-ciphertext attacker a timing
41//! oracle for short C2: P(KDF zero) ≈ 2^(-8·|C2|), so a 1-byte C2
42//! trips the branch ~1/256 of the time, and the early-return path
43//! skips the XOR/SM3/MAC work — observably faster than a
44//! normal MAC failure. The implementation folds the all-zero
45//! detection into a `subtle::Choice` and combines it with the
46//! `mac_ok` result via `&` so both classes of failure collapse to
47//! identical control flow.
48//!
49//! v0.2 adds [`crate::sm4::Sm4Cipher`] for envelope encryption (use
50//! SM2 to wrap an SM4 key, then SM4-CBC with HMAC-SM3 for bulk data
51//! and integrity). v0.2's dudect harness adds `ct_sm2_decrypt` (W2
52//! chunk 3) — class-split by `d_B`, fixed ciphertext.
53//!
54//! # Invalid-curve attack
55//!
56//! Without the on-curve check on `C1`, an attacker could submit a
57//! point on a different curve sharing the same `x` coordinate as a
58//! point on SM2; multiplying by the secret `d_B` then leaks bits of
59//! `d_B` via the small-order subgroup of the rogue curve. The
60//! [`crate::sm2::encrypt::point_on_curve`] check is the standard
61//! defense.
62
63use crate::asn1::ciphertext::decode;
64use crate::sm2::curve::Fp;
65use crate::sm2::encrypt::{kdf, point_on_curve, projective_from_affine};
66use crate::sm2::private_key::Sm2PrivateKey;
67use crate::sm2::scalar_mul::mul_var;
68use crate::sm3::Sm3;
69use alloc::vec::Vec;
70use subtle::{Choice, ConstantTimeEq};
71use zeroize::Zeroize;
72
73/// Decrypt a GM/T 0009 DER-encoded ciphertext under recipient private
74/// key `private`.
75///
76/// Returns `Ok(plaintext)` on success, [`crate::Error::Failed`] on any
77/// failure.
78///
79/// # Errors
80///
81/// See module-doc — every failure mode collapses to one variant.
82pub fn decrypt(private: &Sm2PrivateKey, ciphertext_der: &[u8]) -> Result<Vec<u8>, crate::Error> {
83 // 1. DER decode. (Returns None on malformed input — single failure
84 // bucket here; we collapse to Failed.)
85 let parsed = decode(ciphertext_der).ok_or(crate::Error::Failed)?;
86
87 // 2. Construct C1 from (x1, y1). Reject off-curve.
88 let x1 = Fp::new(&parsed.x);
89 let y1 = Fp::new(&parsed.y);
90 if !point_on_curve(&x1, &y1) {
91 return Err(crate::Error::Failed);
92 }
93 let c1 = projective_from_affine(x1, y1);
94 if bool::from(c1.is_identity()) {
95 return Err(crate::Error::Failed);
96 }
97
98 // 3. (x2, y2) = d_B * C1
99 let kp = mul_var(private.scalar(), &c1);
100 let (x2, y2) = kp.to_affine().ok_or(crate::Error::Failed)?;
101
102 // 4. KDF(x2 || y2, |C2|)
103 let mut z = [0u8; 64];
104 z[..32].copy_from_slice(&x2.retrieve().to_be_bytes());
105 z[32..].copy_from_slice(&y2.retrieve().to_be_bytes());
106
107 let mut t = alloc::vec![0u8; parsed.ciphertext.len()];
108 kdf(&z, &mut t);
109
110 // 5. KDF-zero detection — *non-branching*. We MUST NOT early-return
111 // here: a chosen-ciphertext attacker who can submit a short C2
112 // (e.g. 1 byte) hits an all-zero KDF output with probability
113 // `≈ 2^(-8 * |C2|)`, and an early-return that skips the
114 // XOR/SM3/MAC work would distinguish the secret-derived
115 // predicate "d_B*C1 produced all-zero KDF" from an ordinary
116 // MAC failure. Both outcomes must collapse to identical control
117 // flow per the failure-mode invariant. The empty-C2 case is
118 // explicitly excluded (vacuous all-zero on an empty buffer).
119 let nonempty: Choice = u8::from(!parsed.ciphertext.is_empty()).into();
120 let kdf_zero = nonempty & ct_all_zero(&t);
121
122 // 6. M = C2 XOR t (in place — t becomes M).
123 for (i, byte) in parsed.ciphertext.iter().enumerate() {
124 t[i] ^= byte;
125 }
126 // Rename for clarity: the buffer now holds (would-be) plaintext.
127 let mut plaintext = t;
128
129 // 7. u = SM3(x2 || M || y2) — computed unconditionally regardless
130 // of `kdf_zero` so timing is identical on both branches.
131 let mut h = Sm3::new();
132 h.update(&z[..32]);
133 h.update(&plaintext);
134 h.update(&z[32..]);
135 let u = h.finalize();
136
137 // 8. Combine the constant-time KDF-zero detection with the MAC
138 // compare into a single `Choice`. Using `&` on `Choice` (defined
139 // via `BitAnd<Choice>`) preserves the constant-time contract.
140 let mac_ok = u.ct_eq(&parsed.hash);
141 let valid = mac_ok & !kdf_zero;
142
143 // Wipe the secret-derived (x2 || y2) buffer regardless of outcome.
144 z.zeroize();
145
146 if !bool::from(valid) {
147 // Wipe the would-be plaintext — the caller never sees it.
148 plaintext.zeroize();
149 return Err(crate::Error::Failed);
150 }
151
152 Ok(plaintext)
153}
154
155/// Constant-time all-zero scan returning a [`Choice`]. The bitwise OR
156/// fold gives a single 8-bit summary value that reveals only whether
157/// the buffer is all-zero — itself the mandated KDF-zero predicate
158/// — and never short-circuits.
159fn ct_all_zero(buf: &[u8]) -> Choice {
160 let mut acc: u8 = 0;
161 for b in buf {
162 acc |= b;
163 }
164 acc.ct_eq(&0u8)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::asn1::ciphertext::{Sm2Ciphertext, encode};
171 use crate::sm2::encrypt::encrypt;
172 use crate::sm2::private_key::Sm2PrivateKey;
173 use crate::sm2::public_key::Sm2PublicKey;
174 use crypto_bigint::U256;
175 use getrandom::SysRng;
176 use rand_core::UnwrapErr;
177
178 /// End-to-end round-trip with a random nonce: encrypt → decrypt
179 /// → recover plaintext.
180 #[test]
181 fn round_trip_random_nonce() {
182 let d =
183 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
184 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
185 let pk = Sm2PublicKey::from_point(key.public_key());
186 let plaintext = b"encryption standard";
187 let mut rng = UnwrapErr(SysRng);
188 let der = encrypt(&pk, plaintext, &mut rng).expect("encrypt");
189 let recovered = decrypt(&key, &der).expect("decrypt");
190 assert_eq!(recovered.as_slice(), plaintext);
191 }
192
193 /// Boundary-length round-trip across empty / 1 / 31 / 32 / 33 /
194 /// 64 / 65 byte plaintexts. Empty exercises the vacuous
195 /// KDF-zero check; 32 sits exactly on a KDF-block boundary; 33
196 /// crosses into the second KDF block.
197 #[test]
198 fn round_trip_boundary_lengths() {
199 let d =
200 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
201 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
202 let pk = Sm2PublicKey::from_point(key.public_key());
203 let mut rng = UnwrapErr(SysRng);
204
205 for len in [0usize, 1, 31, 32, 33, 64, 65, 128] {
206 let plaintext: Vec<u8> = (0..len)
207 .map(|i| {
208 #[allow(clippy::cast_possible_truncation)]
209 {
210 (i as u8).wrapping_mul(7)
211 }
212 })
213 .collect();
214 let der = encrypt(&pk, &plaintext, &mut rng).expect("encrypt");
215 let recovered = decrypt(&key, &der).expect("decrypt");
216 assert_eq!(recovered, plaintext, "round-trip mismatch at len={len}");
217 }
218 }
219
220 /// Decrypt rejects garbage / malformed DER.
221 #[test]
222 fn rejects_malformed_der() {
223 let d =
224 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
225 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
226 assert_eq!(decrypt(&key, &[]), Err(crate::Error::Failed));
227 assert_eq!(decrypt(&key, b"not DER"), Err(crate::Error::Failed));
228 assert_eq!(
229 decrypt(&key, &[0x30, 0x05, 0xff, 0xff, 0xff]),
230 Err(crate::Error::Failed)
231 );
232 }
233
234 /// Decrypt rejects ciphertext with `C1` not on the SM2 curve.
235 /// (Constructed by hand-building `Sm2Ciphertext` with arbitrary
236 /// off-curve `(x, y)`.)
237 #[test]
238 fn rejects_off_curve_c1() {
239 let d =
240 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
241 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
242 let off_curve = Sm2Ciphertext {
243 x: U256::from_u64(1),
244 y: U256::from_u64(1), // (1, 1) is not on SM2
245 hash: [0u8; 32],
246 ciphertext: alloc::vec![0u8; 16],
247 };
248 let der = encode(&off_curve);
249 assert_eq!(decrypt(&key, &der), Err(crate::Error::Failed));
250 }
251
252 /// Decrypt rejects ciphertext where `C3` (the MAC) doesn't match
253 /// the recomputed hash. Mutate one byte of `C3` after a valid
254 /// encrypt and verify decrypt fails.
255 #[test]
256 fn rejects_mac_mismatch() {
257 let d =
258 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
259 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
260 let pk = Sm2PublicKey::from_point(key.public_key());
261 let mut rng = UnwrapErr(SysRng);
262 let der = encrypt(&pk, b"encryption standard", &mut rng).expect("encrypt");
263
264 // Decode → mutate hash → re-encode → decrypt should fail.
265 let mut parsed = decode(&der).expect("decode our own DER");
266 parsed.hash[0] ^= 0x01;
267 let tampered = encode(&parsed);
268 assert_eq!(decrypt(&key, &tampered), Err(crate::Error::Failed));
269 }
270
271 /// Decrypt under the WRONG private key fails (MAC won't match).
272 #[test]
273 fn rejects_wrong_private_key() {
274 let d_a =
275 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
276 let d_b =
277 U256::from_be_hex("3945208F7B2144B13F36E38AC6D39F95889393692860B51A42FB81EF4DF7C5B8");
278 let key_a = Sm2PrivateKey::from_scalar_inner(d_a).expect("valid d_a");
279 let key_b = Sm2PrivateKey::from_scalar_inner(d_b).expect("valid d_b");
280 let pk_a = Sm2PublicKey::from_point(key_a.public_key());
281 let mut rng = UnwrapErr(SysRng);
282 let der = encrypt(&pk_a, b"top secret", &mut rng).expect("encrypt to A");
283 // Decrypt with B's key — must fail.
284 assert_eq!(decrypt(&key_b, &der), Err(crate::Error::Failed));
285 }
286
287 /// Decrypt rejects ciphertext where `C2` has been mutated (one
288 /// byte XOR'd) — both the resulting plaintext bit AND the MAC
289 /// will be inconsistent.
290 #[test]
291 fn rejects_tampered_c2() {
292 let d =
293 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
294 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
295 let pk = Sm2PublicKey::from_point(key.public_key());
296 let mut rng = UnwrapErr(SysRng);
297 let der = encrypt(&pk, b"some plaintext data", &mut rng).expect("encrypt");
298
299 let mut parsed = decode(&der).expect("decode our own DER");
300 parsed.ciphertext[0] ^= 0xff;
301 let tampered = encode(&parsed);
302 assert_eq!(decrypt(&key, &tampered), Err(crate::Error::Failed));
303 }
304
305 /// Functional regression test for the constant-time KDF-zero
306 /// handling. Forge a ciphertext where `C2` is `[0x00; n]` for
307 /// small `n`; on decryption the random-looking `KDF(d_B*C1, n)`
308 /// won't be all-zero (the attacker can't choose `KDF` output
309 /// without knowing `d_B*C1`), so the path must collapse to
310 /// `Failed` via the MAC-mismatch arm rather than via an
311 /// early-return KDF-zero branch. The pre-fix decoder would have
312 /// taken the early return whenever the KDF *did* hit all-zero
313 /// (~1/256 of attempts for a 1-byte C2), exposing a chosen-
314 /// ciphertext timing oracle. We can't reliably hit the
315 /// all-zero KDF output here without grinding `C1`, but this
316 /// test does verify the rewrite still rejects forged short
317 /// ciphertexts cleanly across many attempts and that no panic
318 /// or `Ok` result slips through.
319 ///
320 /// Companion: see `crates/gmcrypto-core/src/sm2/decrypt.rs`
321 /// step 5 comment for the timing-oracle rationale.
322 #[test]
323 fn rejects_forged_short_ciphertext() {
324 let d =
325 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
326 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
327 let pk = Sm2PublicKey::from_point(key.public_key());
328 let mut rng = UnwrapErr(SysRng);
329
330 // Encrypt many distinct 1-byte messages so we exercise lots
331 // of `(C1, KDF)` pairs, then for each tamper `C3` to force
332 // the path through the new branchless KDF-zero detection +
333 // MAC compare. None should panic or return `Ok`.
334 for round in 0..32u8 {
335 let plaintext = [round];
336 let der = encrypt(&pk, &plaintext, &mut rng).expect("encrypt 1-byte");
337 let mut parsed = decode(&der).expect("decode our own DER");
338 parsed.hash[0] ^= 0x01;
339 let tampered = encode(&parsed);
340 assert_eq!(
341 decrypt(&key, &tampered),
342 Err(crate::Error::Failed),
343 "forged 1-byte ciphertext on round {round} must fail"
344 );
345 }
346 }
347
348 /// Empty plaintext round-trip is supported: `KDF(_, 0)` writes
349 /// zero bytes, the all-zero check is vacuously suppressed via
350 /// the `nonempty` Choice mask, and `SM3(x2 || empty || y2)`
351 /// is the MAC. Companion to `round_trip_boundary_lengths` —
352 /// kept independent so a regression on the empty-suppression
353 /// behavior surfaces here distinctly.
354 #[test]
355 fn round_trip_empty_plaintext() {
356 let d =
357 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
358 let key = Sm2PrivateKey::from_scalar_inner(d).expect("valid d");
359 let pk = Sm2PublicKey::from_point(key.public_key());
360 let mut rng = UnwrapErr(SysRng);
361 let der = encrypt(&pk, b"", &mut rng).expect("encrypt empty");
362 let recovered = decrypt(&key, &der).expect("decrypt empty");
363 assert!(recovered.is_empty(), "empty plaintext round-trip");
364 }
365}