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