Skip to main content

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}