gmcrypto_core/sm2/encrypt.rs
1//! SM2 public-key encryption (GB/T 32918.4-2017 §6).
2//!
3//! # Algorithm
4//!
5//! ```text
6//! Input: recipient public key P_B, plaintext M
7//! Output: ciphertext (C1 = kG, C3 = SM3(x2 || M || y2), C2 = M XOR KDF(x2 || y2, |M|))
8//!
9//! 1. Pick random k in [1, n-1]
10//! 2. C1 = kG = (x1, y1)
11//! 3. (x2, y2) = k * P_B
12//! 4. t = KDF(x2 || y2, |M| in bits)
13//! 5. If t is all zeros, retry from step 1 (negligible probability for non-empty M)
14//! 6. C2 = M XOR t
15//! 7. C3 = SM3(x2 || M || y2)
16//! 8. Output GM/T 0009 DER encoding of (x1, y1, C3, C2)
17//! ```
18//!
19//! # KDF (GB/T 32918.4 §5.4.3)
20//!
21//! SM3-based counter-mode key-derivation:
22//!
23//! ```text
24//! KDF(Z, klen):
25//! ct = 1
26//! while output length < klen:
27//! output ||= SM3(Z || ct.to_be_bytes())
28//! ct += 1
29//! return output truncated to klen bits
30//! ```
31//!
32//! v0.2 places this KDF inside `sm2::encrypt` rather than the top-level
33//! `gmcrypto_core::kdf` module. `kdf.rs` is reserved for PBKDF2.
34//!
35//! # Failure-mode invariant
36//!
37//! [`encrypt`] returns `Result<Vec<u8>, EncryptError>` with a single
38//! `Failed` variant — collapses every retry-budget-exhausted, identity-
39//! point, or KDF-zero outcome to one uninformative shape. With a
40//! [`CryptoRng`], the cumulative-failure probability is `≤ 2^-512` per
41//! call across all plaintext lengths (1-byte through arbitrary), per
42//! the [`ENCRYPT_RETRY_BUDGET`] table — i.e. never observed in
43//! practice.
44//!
45//! # Constant-time stance
46//!
47//! Encrypt operates on the recipient's **public key** and a freshly
48//! sampled `k`; no caller-controlled secret is touched. The only
49//! secret-derived intermediates are `(x2, y2) = kP_B` and the KDF
50//! output, both of which are wiped before return. v0.2's dudect
51//! harness covers the secret-touching path on the **decrypt** side
52//! (`ct_sm2_decrypt`); a `ct_sm2_encrypt` target is optional and
53//! deferred until v0.3.
54
55use crate::asn1::ciphertext::{Sm2Ciphertext, encode};
56use crate::sm2::curve::{Fn, Fp, b};
57use crate::sm2::point::ProjectivePoint;
58use crate::sm2::public_key::Sm2PublicKey;
59use crate::sm2::scalar_mul::{mul_g, mul_var};
60use crate::sm2::sign::sample_nonzero_scalar;
61use crate::sm3::{DIGEST_SIZE, Sm3};
62use alloc::vec::Vec;
63use crypto_bigint::U256;
64use rand_core::{CryptoRng, Rng};
65use subtle::ConstantTimeEq;
66use zeroize::Zeroize;
67
68/// Retry budget for the KDF-zero rejection step.
69///
70/// **Per-iteration KDF-zero probability is length-dependent**, not the
71/// asymptotic `2^-256` figure that v0.2's first cut assumed. For a
72/// plaintext of `L` bytes the KDF output is `L` bytes long and
73/// `P(all-zero) = 2^(-8·L)`. For very short plaintexts the per-call
74/// probability is non-negligible:
75///
76/// | `|M|` (bytes) | per-iteration P(zero) | budget=4 P(fail) | budget=64 P(fail) |
77/// |---:|---:|---:|---:|
78/// | 1 | `2^-8` | `2^-32` | `2^-512` |
79/// | 2 | `2^-16` | `2^-64` | `2^-1024` |
80/// | 4 | `2^-32` | `2^-128` | `2^-2048` |
81/// | 32 | `2^-256` | `2^-1024`| `2^-16384`|
82///
83/// A budget of 64 makes the cumulative failure probability negligible
84/// at any plaintext length while keeping the loop bounded for liveness
85/// under degenerate RNGs. GB/T 32918.4 specifies the retry as
86/// indefinite; the 64-step bound is a defense-in-depth ceiling, never
87/// reached in practice with a uniform CSPRNG.
88const ENCRYPT_RETRY_BUDGET: usize = 64;
89
90/// Encrypt failure — single uninformative variant per the project's
91/// failure-mode invariant.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum EncryptError {
94 /// The retry budget was exhausted, or the recipient public key is
95 /// the identity point. Effectively unreachable with a uniform
96 /// CSPRNG and a real public key.
97 Failed,
98}
99
100/// Encrypt `plaintext` to recipient `public`, returning a GM/T 0009
101/// DER-encoded ciphertext.
102///
103/// `rng` must be a [`CryptoRng`]. With a CSPRNG, encrypt failure
104/// probability is `≤ 2^-512` for any plaintext length — see the
105/// [`ENCRYPT_RETRY_BUDGET`] table for the per-length math.
106///
107/// # Errors
108///
109/// Returns [`EncryptError::Failed`] if the recipient public key is the
110/// identity point (a malicious caller could construct one via
111/// [`Sm2PublicKey::from_point`]) or if every retry produced an
112/// all-zeros KDF output.
113pub fn encrypt<R: CryptoRng + Rng>(
114 public: &Sm2PublicKey,
115 plaintext: &[u8],
116 rng: &mut R,
117) -> Result<Vec<u8>, EncryptError> {
118 if bool::from(public.point().is_identity()) {
119 return Err(EncryptError::Failed);
120 }
121 for _ in 0..ENCRYPT_RETRY_BUDGET {
122 let k = sample_nonzero_scalar(rng);
123 if let Some(ct) = try_encrypt_once(public, plaintext, &k) {
124 return Ok(encode(&ct));
125 }
126 }
127 Err(EncryptError::Failed)
128}
129
130/// Single encrypt attempt. Returns `None` when the KDF output is
131/// all-zeros (caller retries with a fresh `k`).
132fn try_encrypt_once(public: &Sm2PublicKey, plaintext: &[u8], k: &Fn) -> Option<Sm2Ciphertext> {
133 // C1 = kG; (x1, y1) = affine(C1)
134 let c1 = mul_g(k);
135 let (x1, y1) = c1.to_affine()?;
136
137 // (x2, y2) = k * P_B; affine
138 let kp = mul_var(k, &public.point());
139 let (x2, y2) = kp.to_affine()?;
140
141 // Z = x2 || y2 (64 bytes), the KDF input.
142 let mut z = [0u8; 64];
143 z[..32].copy_from_slice(&x2.retrieve().to_be_bytes());
144 z[32..].copy_from_slice(&y2.retrieve().to_be_bytes());
145
146 // t = KDF(Z, |plaintext|)
147 let mut t = alloc::vec![0u8; plaintext.len()];
148 kdf(&z, &mut t);
149
150 // KDF-zero rejection: spec requires retry on all-zeros KDF output.
151 // Vacuously satisfied for empty plaintext (no output bytes to check).
152 if !plaintext.is_empty() && all_zero_ct(&t) {
153 // Wipe the all-zero buffer (defensive; it carries no secret
154 // since it's all zeros, but the KDF input Z is secret-derived).
155 z.zeroize();
156 t.zeroize();
157 return None;
158 }
159
160 // C2 = M XOR t (in place, reusing the t buffer).
161 for (i, byte) in plaintext.iter().enumerate() {
162 t[i] ^= byte;
163 }
164 let c2 = t; // rename: it now holds C2.
165
166 // C3 = SM3(x2 || M || y2)
167 let mut h = Sm3::new();
168 h.update(&z[..32]);
169 h.update(plaintext);
170 h.update(&z[32..]);
171 let c3 = h.finalize();
172
173 // Wipe the secret-derived (x2 || y2) buffer.
174 z.zeroize();
175
176 Some(Sm2Ciphertext {
177 x: x1.retrieve(),
178 y: y1.retrieve(),
179 hash: c3,
180 ciphertext: c2,
181 })
182}
183
184/// SM3 counter-mode KDF per GB/T 32918.4 §5.4.3.
185///
186/// Writes `output.len()` bytes of derived material into `output` from
187/// the input `z`. `output` may be any length, including empty (in
188/// which case the function is a no-op).
189///
190/// Visible to `sm2::decrypt` via `pub(super)`; not part of the public
191/// API and not SemVer-stable.
192pub(super) fn kdf(z: &[u8], output: &mut [u8]) {
193 let mut counter: u32 = 1;
194 let mut written = 0;
195 while written < output.len() {
196 let mut h = Sm3::new();
197 h.update(z);
198 h.update(&counter.to_be_bytes());
199 let digest = h.finalize();
200 let block_remaining = output.len() - written;
201 let copy_len = block_remaining.min(DIGEST_SIZE);
202 output[written..written + copy_len].copy_from_slice(&digest[..copy_len]);
203 written += copy_len;
204 counter += 1;
205 }
206}
207
208/// Constant-time all-zero test: `acc |= byte` over the whole buffer,
209/// then check `acc == 0`. The final equality is on a non-secret
210/// summary value (the OR of all bytes), so the bool result is the
211/// only timing signal — and that signal is the "is the KDF output
212/// all-zero?" question, which is itself an explicit spec-mandated
213/// branch (the retry).
214fn all_zero_ct(buf: &[u8]) -> bool {
215 let mut acc: u8 = 0;
216 for b in buf {
217 acc |= b;
218 }
219 bool::from(acc.ct_eq(&0u8))
220}
221
222/// Validate that `(x, y)` lies on the SM2 curve `y² ≡ x³ - 3x + b
223/// (mod p)`. Defense against invalid-curve attacks on `decrypt` —
224/// without this check, an attacker submitting `C1` on a different
225/// curve could leak bits of the recipient's private key via
226/// `d_B * C1`.
227///
228/// Visible to the rest of the crate (W2's `spki` / `sec1` reuse it
229/// at the import boundary). Not part of the public API.
230pub(crate) fn point_on_curve(x: &Fp, y: &Fp) -> bool {
231 let three = Fp::new(&U256::from_u64(3));
232 let lhs = *y * *y;
233 let rhs = (*x) * (*x) * (*x) - three * (*x) + b();
234 bool::from(lhs.retrieve().ct_eq(&rhs.retrieve()))
235}
236
237/// Construct a [`ProjectivePoint`] from validated affine `(x, y)`
238/// coordinates. Visible to the rest of the crate (W2's `spki` / `sec1`
239/// reuse it after `point_on_curve`); not part of the public API.
240pub(crate) const fn projective_from_affine(x: Fp, y: Fp) -> ProjectivePoint {
241 ProjectivePoint {
242 x,
243 y,
244 z: Fp::new(&U256::ONE),
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::sm2::private_key::Sm2PrivateKey;
252 use core::convert::Infallible;
253 use rand_core::{TryCryptoRng, TryRng};
254
255 /// Test-only RNG that emits a fixed 32-byte value on every
256 /// `fill_bytes` call. Used to drive `encrypt` with a known `k` for
257 /// KAT-style tests.
258 struct FixedScalarRng {
259 bytes: [u8; 32],
260 }
261
262 impl FixedScalarRng {
263 const fn new(bytes: [u8; 32]) -> Self {
264 Self { bytes }
265 }
266 }
267
268 impl TryRng for FixedScalarRng {
269 type Error = Infallible;
270
271 fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
272 Ok(0)
273 }
274 fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
275 Ok(0)
276 }
277 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
278 assert_eq!(dst.len(), 32);
279 dst.copy_from_slice(&self.bytes);
280 Ok(())
281 }
282 }
283
284 impl TryCryptoRng for FixedScalarRng {}
285
286 /// Build a deterministic 64-byte test `Z` for the KDF cross-checks.
287 /// Content doesn't matter — the goal is exact-length, reproducible bytes.
288 fn synthetic_z() -> [u8; 64] {
289 let mut z = [0u8; 64];
290 for (i, b) in z.iter_mut().enumerate() {
291 #[allow(clippy::cast_possible_truncation)]
292 {
293 *b = (i as u8).wrapping_mul(7);
294 }
295 }
296 z
297 }
298
299 /// Single-block KDF cross-check: 32-byte output equals
300 /// `SM3(z || 0x00000001)`.
301 #[test]
302 fn kdf_single_block_matches_manual_sm3() {
303 let z = synthetic_z();
304 let mut out = [0u8; 32];
305 kdf(&z, &mut out);
306
307 let mut h = Sm3::new();
308 h.update(&z);
309 h.update(&1u32.to_be_bytes());
310 let expected = h.finalize();
311 assert_eq!(out, expected);
312 }
313
314 /// Two-block KDF cross-check: 40 bytes spans two SM3 invocations
315 /// with `ct = 1` then `ct = 2`.
316 #[test]
317 fn kdf_two_block_matches_manual_sm3() {
318 let z = synthetic_z();
319 let mut out = [0u8; 40];
320 kdf(&z, &mut out);
321
322 let mut h1 = Sm3::new();
323 h1.update(&z);
324 h1.update(&1u32.to_be_bytes());
325 let block1 = h1.finalize();
326 let mut h2 = Sm3::new();
327 h2.update(&z);
328 h2.update(&2u32.to_be_bytes());
329 let block2 = h2.finalize();
330
331 assert_eq!(&out[..32], &block1);
332 assert_eq!(&out[32..40], &block2[..8]);
333 }
334
335 /// Empty-output KDF is a no-op.
336 #[test]
337 fn kdf_empty_output_is_noop() {
338 let z = b"whatever";
339 let mut out: [u8; 0] = [];
340 kdf(z, &mut out);
341 // (no assertion needed — just verifying it doesn't panic and
342 // returns a 0-length output)
343 }
344
345 /// `point_on_curve` accepts the SM2 generator `G`.
346 #[test]
347 fn point_on_curve_accepts_generator() {
348 let g = ProjectivePoint::generator();
349 let (gx, gy) = g.to_affine().expect("G is finite");
350 assert!(point_on_curve(&gx, &gy));
351 }
352
353 /// `point_on_curve` rejects an arbitrary off-curve point.
354 #[test]
355 fn point_on_curve_rejects_off_curve() {
356 // `(1, 1)` is almost certainly not on SM2 (overwhelmingly
357 // likely false; cross-checking the on-curve guard is the
358 // point of the test).
359 let x = Fp::new(&U256::ONE);
360 let y = Fp::new(&U256::ONE);
361 assert!(!point_on_curve(&x, &y));
362 }
363
364 /// Encrypt rejects an identity-point public key. (Same-style
365 /// hardening as `verify_with_id`'s identity-rejection from v0.1.)
366 #[test]
367 fn encrypt_rejects_identity_pubkey() {
368 let pk = Sm2PublicKey::from_point(ProjectivePoint::identity());
369 let mut rng = rand_core::UnwrapErr(getrandom::SysRng);
370 assert_eq!(
371 encrypt(&pk, b"any plaintext", &mut rng),
372 Err(EncryptError::Failed)
373 );
374 }
375
376 /// Fixed-`k` smoke test: encrypt with a deterministic RNG produces
377 /// a deterministic ciphertext (round-trip is in `sm2::decrypt`'s
378 /// tests).
379 #[test]
380 fn encrypt_with_fixed_k_is_deterministic() {
381 let d =
382 U256::from_be_hex("1649AB77A00637BD5E2EFE283FBF353534AA7F7CB89463F208DDBC2920BB0DA0");
383 let key = Sm2PrivateKey::new(d).expect("valid d");
384 let pk = Sm2PublicKey::from_point(key.public_key());
385 let k_bytes =
386 U256::from_be_hex("4C62EEFD6ECFC2B95B92FD6C3D9575148AFA17425546D49018E5388D49DD7B4F")
387 .to_be_bytes();
388 let mut bytes = [0u8; 32];
389 bytes.copy_from_slice(&k_bytes);
390 let mut rng_a = rand_core::UnwrapErr(FixedScalarRng::new(bytes));
391 let mut rng_b = rand_core::UnwrapErr(FixedScalarRng::new(bytes));
392 let der_a = encrypt(&pk, b"encryption standard", &mut rng_a).expect("encrypt a");
393 let der_b = encrypt(&pk, b"encryption standard", &mut rng_b).expect("encrypt b");
394 assert_eq!(der_a, der_b, "fixed-k encrypt must be deterministic");
395 }
396}