Skip to main content

gmcrypto_core/asn1/
ciphertext.rs

1//! GM/T 0009-2012 §6 SM2 ciphertext DER encoding.
2//!
3//! Structure:
4//!
5//! ```text
6//! SM2Cipher ::= SEQUENCE {
7//!     XCoordinate INTEGER,         -- C1.x  (positive, ≤ 256 bits)
8//!     YCoordinate INTEGER,         -- C1.y  (positive, ≤ 256 bits)
9//!     HASH        OCTET STRING,    -- C3, exactly 32 bytes (SM3 digest)
10//!     CipherText  OCTET STRING     -- C2, variable length
11//! }
12//! ```
13//!
14//! v0.2 scope: this module ships **DER only**. Raw byte concatenation
15//! formats (`C1 || C3 || C2` modern, `C1 || C2 || C3` legacy gmssl) are
16//! out of scope until v0.3 — see `SECURITY.md` and `CLAUDE.md`.
17//!
18//! INTEGER decoding follows strict X.690 canonical-encoding rules,
19//! adapted for the field-element range that C1 coordinates inhabit:
20//! the leading `0x00` pad is allowed only when needed for sign
21//! disambiguation; sign-bit-set first bytes (would be negative in
22//! two's complement) are rejected; empty INTEGER content is rejected;
23//! the canonical single-byte encoding of zero (`02 01 00`) is
24//! accepted; and 32-byte coordinates `≥ p` are rejected so that
25//! `Fp::new` cannot silently reduce a non-canonical encoding modulo
26//! the field prime. The two SM2-specific deltas vs. the
27//! [`crate::asn1::sig::decode_sig`] rules (which target `r, s ∈
28//! [1, n-1]`) are documented inline in [`read_integer`]. Accepting
29//! non-canonical encodings would create ciphertext malleability —
30//! multiple distinct DER blobs mapping to the same `(C1, C3, C2)`.
31//!
32//! OCTET STRING decoding accepts any tag-length-value with a
33//! non-indefinite length, since OCTET STRING values have no canonical-
34//! form constraint analogous to INTEGER's leading-zero rule.
35//!
36//! No reusable `asn1::reader` / `asn1::writer` infrastructure here —
37//! the v0.1 `asn1::sig` module-doc explicitly defers full ASN.1 to
38//! v0.3, and `ciphertext.rs` mirrors `sig.rs`'s ad-hoc structure.
39
40use alloc::vec::Vec;
41use crypto_bigint::U256;
42use subtle::ConstantTimeLess;
43
44use crate::sm2::curve::Fp;
45
46/// SM3 digest size — fixed at 32 bytes; the spec mandates it.
47const HASH_LEN: usize = 32;
48
49/// Parsed SM2 ciphertext components.
50///
51/// `x` and `y` are the affine coordinates of `C1 = kG`; `hash` is `C3`,
52/// the SM3 digest computed during encryption; `ciphertext` is `C2`, the
53/// KDF-XOR'd plaintext.
54#[derive(Clone, Debug)]
55pub struct Sm2Ciphertext {
56    /// `C1.x`.
57    pub x: U256,
58    /// `C1.y`.
59    pub y: U256,
60    /// `C3 = SM3(x2 || M || y2)`. Always 32 bytes.
61    pub hash: [u8; HASH_LEN],
62    /// `C2 = M XOR KDF(x2 || y2, |M|)`. Length matches plaintext length.
63    pub ciphertext: Vec<u8>,
64}
65
66/// Encode an [`Sm2Ciphertext`] as a GM/T 0009 SEQUENCE.
67#[must_use]
68pub fn encode(ct: &Sm2Ciphertext) -> Vec<u8> {
69    let x_der = encode_integer(&ct.x.to_be_bytes());
70    let y_der = encode_integer(&ct.y.to_be_bytes());
71    let hash_der = encode_octet_string(&ct.hash);
72    let ciphertext_der = encode_octet_string(&ct.ciphertext);
73    let body_len = x_der.len() + y_der.len() + hash_der.len() + ciphertext_der.len();
74    let mut out = Vec::with_capacity(body_len + 8);
75    out.push(0x30); // SEQUENCE tag
76    push_length(&mut out, body_len);
77    out.extend_from_slice(&x_der);
78    out.extend_from_slice(&y_der);
79    out.extend_from_slice(&hash_der);
80    out.extend_from_slice(&ciphertext_der);
81    out
82}
83
84/// Decode a GM/T 0009 SEQUENCE into [`Sm2Ciphertext`]. Returns `None`
85/// for any malformed input. **No distinguishing failure modes** —
86/// malleability defense per the project's failure-mode invariant.
87#[must_use]
88pub fn decode(input: &[u8]) -> Option<Sm2Ciphertext> {
89    let (tag, rest) = input.split_first()?;
90    if *tag != 0x30 {
91        return None;
92    }
93    let (body_len, rest) = read_length(rest)?;
94    if rest.len() != body_len {
95        return None;
96    }
97    let (x, rest) = read_integer(rest)?;
98    let (y, rest) = read_integer(rest)?;
99    let (hash_bytes, rest) = read_octet_string(rest)?;
100    let (ciphertext, rest) = read_octet_string(rest)?;
101    if !rest.is_empty() {
102        return None;
103    }
104    if hash_bytes.len() != HASH_LEN {
105        return None;
106    }
107    let mut hash = [0u8; HASH_LEN];
108    hash.copy_from_slice(hash_bytes);
109    Some(Sm2Ciphertext {
110        x,
111        y,
112        hash,
113        ciphertext: ciphertext.to_vec(),
114    })
115}
116
117// ---------------------------------------------------------------------
118// DER primitive helpers
119//
120// These mirror the corresponding helpers in `asn1::sig` to keep both
121// shapes ad-hoc and self-contained until v0.3 ships a reusable subset.
122// Keep the strict canonical-INTEGER rules in lockstep with sig.rs.
123// ---------------------------------------------------------------------
124
125fn encode_integer(value_be: &[u8]) -> Vec<u8> {
126    // Strip leading zeros, then re-add one if the high bit is set
127    // (positive integers need a leading 0x00 to disambiguate from
128    // negative two's-complement).
129    let mut start = 0;
130    while start < value_be.len() - 1 && value_be[start] == 0 {
131        start += 1;
132    }
133    let trimmed = &value_be[start..];
134    let needs_pad = (trimmed[0] & 0x80) != 0;
135    let int_len = trimmed.len() + usize::from(needs_pad);
136    let mut out = Vec::with_capacity(int_len + 4);
137    out.push(0x02); // INTEGER tag
138    push_length(&mut out, int_len);
139    if needs_pad {
140        out.push(0x00);
141    }
142    out.extend_from_slice(trimmed);
143    out
144}
145
146fn read_integer(input: &[u8]) -> Option<(U256, &[u8])> {
147    let (tag, rest) = input.split_first()?;
148    if *tag != 0x02 {
149        return None;
150    }
151    let (int_len, rest) = read_length(rest)?;
152    if rest.len() < int_len {
153        return None;
154    }
155    let (int_bytes, rest_after) = rest.split_at(int_len);
156
157    // Strict X.690 canonical rules adapted for ciphertext coordinates.
158    // The shape is similar to `asn1::sig::read_integer` but **differs in
159    // two places** because C1 coordinates inhabit `[0, p-1]` (a field
160    // element range) while signature `r`/`s` inhabit `[1, n-1]`:
161    //
162    // - Length ≥ 1 (an INTEGER cannot be empty).
163    // - For positive integers, the high bit of the first content byte
164    //   must be clear; otherwise a leading 0x00 is required to
165    //   disambiguate from a two's-complement negative.
166    // - That leading-0x00 padding is allowed only when needed.
167    // - **Zero is admissible.** The canonical DER encoding of zero is
168    //   `02 01 00` (a single content byte 0x00), and a `(0, y)` point
169    //   on the SM2 curve is a perfectly valid C1 — the signature path
170    //   does not see this case because zero is excluded from `[1, n-1]`.
171    // - **Coordinates ≥ p are rejected.** Without this bound, a
172    //   32-byte INTEGER above `p` passes the canonical-encoding check,
173    //   then `Fp::new` silently reduces it modulo `p`, admitting a
174    //   second wire encoding for the same field element — a malleability
175    //   primitive on the ciphertext path.
176    if int_bytes.is_empty() {
177        return None;
178    }
179    if int_bytes[0] & 0x80 != 0 {
180        return None;
181    }
182    let bytes = if int_bytes[0] == 0x00 {
183        if int_bytes.len() == 1 {
184            // Canonical encoding of zero: `02 01 00`.
185            int_bytes
186        } else if int_bytes[1] & 0x80 == 0 {
187            // Leading 0x00 followed by a high-bit-clear byte is
188            // redundant padding (BER, not DER).
189            return None;
190        } else {
191            &int_bytes[1..]
192        }
193    } else {
194        int_bytes
195    };
196    if bytes.len() > 32 {
197        return None;
198    }
199    let mut padded = [0u8; 32];
200    padded[32 - bytes.len()..].copy_from_slice(bytes);
201    let value = U256::from_be_slice(&padded);
202    // Reject coordinates ≥ p. C1 coordinates are public, so a
203    // non-constant-time comparison is acceptable here; using
204    // `ConstantTimeLess` matches the rest of the crate's idiom.
205    let in_field: bool = value.ct_lt(Fp::MODULUS.as_ref()).into();
206    if !in_field {
207        return None;
208    }
209    Some((value, rest_after))
210}
211
212fn encode_octet_string(value: &[u8]) -> Vec<u8> {
213    let mut out = Vec::with_capacity(value.len() + 4);
214    out.push(0x04); // OCTET STRING tag
215    push_length(&mut out, value.len());
216    out.extend_from_slice(value);
217    out
218}
219
220fn read_octet_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
221    let (tag, rest) = input.split_first()?;
222    if *tag != 0x04 {
223        return None;
224    }
225    let (len, rest) = read_length(rest)?;
226    if rest.len() < len {
227        return None;
228    }
229    Some(rest.split_at(len))
230}
231
232fn push_length(out: &mut Vec<u8>, len: usize) {
233    if len < 128 {
234        #[allow(clippy::cast_possible_truncation)]
235        out.push(len as u8);
236    } else if len < 256 {
237        out.push(0x81);
238        #[allow(clippy::cast_possible_truncation)]
239        out.push(len as u8);
240    } else if len < 65_536 {
241        #[allow(clippy::cast_possible_truncation)]
242        {
243            out.push(0x82);
244            out.push((len >> 8) as u8);
245            out.push(len as u8);
246        }
247    } else if len < 16_777_216 {
248        #[allow(clippy::cast_possible_truncation)]
249        {
250            out.push(0x83);
251            out.push((len >> 16) as u8);
252            out.push((len >> 8) as u8);
253            out.push(len as u8);
254        }
255    } else {
256        // Ciphertexts up to ~16 MB are supported. Anything larger is
257        // an API misuse — SM2 envelope encryption is not designed for
258        // bulk data; v0.2 callers should chunk via SM4-CBC + an outer
259        // SM2 wrap.
260        panic!("ciphertext DER length overflow (> 16 MB)");
261    }
262}
263
264fn read_length(input: &[u8]) -> Option<(usize, &[u8])> {
265    let (first, rest) = input.split_first()?;
266    if *first < 0x80 {
267        Some((*first as usize, rest))
268    } else if *first == 0x81 {
269        let (b, rest) = rest.split_first()?;
270        if *b < 0x80 {
271            return None; // not minimal
272        }
273        Some((*b as usize, rest))
274    } else if *first == 0x82 {
275        let (hi, rest) = rest.split_first()?;
276        let (lo, rest) = rest.split_first()?;
277        let len = ((*hi as usize) << 8) | (*lo as usize);
278        if len < 256 {
279            return None; // not minimal
280        }
281        Some((len, rest))
282    } else if *first == 0x83 {
283        let (b2, rest) = rest.split_first()?;
284        let (b1, rest) = rest.split_first()?;
285        let (b0, rest) = rest.split_first()?;
286        let len = ((*b2 as usize) << 16) | ((*b1 as usize) << 8) | (*b0 as usize);
287        if len < 65_536 {
288            return None; // not minimal
289        }
290        Some((len, rest))
291    } else {
292        None // 4-byte+ lengths not supported in v0.2
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn make_ct(ciphertext: Vec<u8>) -> Sm2Ciphertext {
301        Sm2Ciphertext {
302            x: U256::from_be_hex(
303                "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF",
304            ),
305            y: U256::from_be_hex(
306                "FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321",
307            ),
308            hash: [0xa5u8; 32],
309            ciphertext,
310        }
311    }
312
313    /// Standard round-trip: encode → decode → equal.
314    #[test]
315    fn round_trip_short() {
316        let ct = make_ct(b"hello world".to_vec());
317        let der = encode(&ct);
318        let decoded = decode(&der).expect("decode round-trip");
319        assert_eq!(decoded.x, ct.x);
320        assert_eq!(decoded.y, ct.y);
321        assert_eq!(decoded.hash, ct.hash);
322        assert_eq!(decoded.ciphertext, ct.ciphertext);
323    }
324
325    /// Round-trip with a high-bit-set top byte on `x` — exercises the
326    /// `encode_integer` 0x00-pad path.
327    #[test]
328    fn round_trip_x_high_bit_set() {
329        let mut ct = make_ct(b"x".to_vec());
330        ct.x =
331            U256::from_be_hex("FFEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA987654321");
332        let der = encode(&ct);
333        let decoded = decode(&der).expect("decode high-bit round-trip");
334        assert_eq!(decoded.x, ct.x);
335    }
336
337    /// Round-trip with a ciphertext spanning the 256-byte length boundary
338    /// (exercises the 0x82 length encoding in `push_length`).
339    #[test]
340    fn round_trip_medium_ciphertext_300_bytes() {
341        let mut payload = alloc::vec![0u8; 300];
342        for (i, b) in payload.iter_mut().enumerate() {
343            #[allow(clippy::cast_possible_truncation)]
344            {
345                *b = (i as u8).wrapping_mul(13);
346            }
347        }
348        let ct = make_ct(payload.clone());
349        let der = encode(&ct);
350        let decoded = decode(&der).expect("decode 300-byte round-trip");
351        assert_eq!(decoded.ciphertext, payload);
352    }
353
354    /// Round-trip with empty ciphertext — RFC 5652 §6 doesn't forbid
355    /// zero-length OCTET STRING content; our DER must accept it.
356    #[test]
357    fn round_trip_empty_ciphertext() {
358        let ct = make_ct(Vec::new());
359        let der = encode(&ct);
360        let decoded = decode(&der).expect("decode empty-ciphertext round-trip");
361        assert!(decoded.ciphertext.is_empty());
362    }
363
364    /// Decode rejects garbage / truncated / empty input.
365    #[test]
366    fn rejects_malformed() {
367        assert!(decode(&[]).is_none(), "empty input");
368        assert!(decode(&[0x30]).is_none(), "truncated SEQUENCE header");
369        assert!(decode(&[0x31, 0x00]).is_none(), "wrong outer tag");
370        // SEQUENCE with declared body shorter than declared length
371        assert!(decode(&[0x30, 0x05, 0x02, 0x01, 0x01]).is_none());
372    }
373
374    /// Decode rejects a hash field whose length is anything other than
375    /// 32 bytes. SM3 always produces 32 bytes; smaller or larger is
376    /// malformed.
377    #[test]
378    fn rejects_wrong_hash_length() {
379        // Build a SEQUENCE where HASH OCTET STRING has 31 bytes instead of 32.
380        let bad_hash = [0x55u8; 31];
381        let ciphertext = b"x";
382        let mut body = Vec::new();
383        body.extend_from_slice(&encode_integer(&[0x01]));
384        body.extend_from_slice(&encode_integer(&[0x02]));
385        body.extend_from_slice(&encode_octet_string(&bad_hash));
386        body.extend_from_slice(&encode_octet_string(ciphertext));
387        let mut der = Vec::new();
388        der.push(0x30);
389        push_length(&mut der, body.len());
390        der.extend_from_slice(&body);
391        assert!(
392            decode(&der).is_none(),
393            "31-byte HASH must be rejected; SM3 always produces 32 bytes"
394        );
395    }
396
397    /// Strict canonical INTEGER: redundant `00`-pad on `x` rejected
398    /// (the same rule `asn1::sig::read_integer` enforces). Prevents
399    /// ciphertext malleability across multiple DER encodings of the
400    /// same `(x, y, hash, ct)` tuple.
401    #[test]
402    fn rejects_non_canonical_x_leading_zero() {
403        // Build SEQUENCE with x = INTEGER 0x00 0x01 (BER-style, non-canonical).
404        let mut body = Vec::new();
405        body.extend_from_slice(&[0x02, 0x02, 0x00, 0x01]); // x: bad
406        body.extend_from_slice(&encode_integer(&[0x02])); // y: ok
407        body.extend_from_slice(&encode_octet_string(&[0u8; 32]));
408        body.extend_from_slice(&encode_octet_string(b""));
409        let mut der = Vec::new();
410        der.push(0x30);
411        push_length(&mut der, body.len());
412        der.extend_from_slice(&body);
413        assert!(
414            decode(&der).is_none(),
415            "non-canonical 00-pad on x must be rejected"
416        );
417    }
418
419    /// Strict canonical INTEGER: high-bit-set first byte (would be
420    /// negative in two's complement) rejected on `y`.
421    #[test]
422    fn rejects_negative_y_encoding() {
423        let mut body = Vec::new();
424        body.extend_from_slice(&encode_integer(&[0x01]));
425        body.extend_from_slice(&[0x02, 0x01, 0x80]); // y = INTEGER 0x80 (sign-bit set, no pad)
426        body.extend_from_slice(&encode_octet_string(&[0u8; 32]));
427        body.extend_from_slice(&encode_octet_string(b""));
428        let mut der = Vec::new();
429        der.push(0x30);
430        push_length(&mut der, body.len());
431        der.extend_from_slice(&body);
432        assert!(decode(&der).is_none());
433    }
434
435    /// Trailing garbage after the ciphertext OCTET STRING is rejected
436    /// — strict DER parsing.
437    #[test]
438    fn rejects_trailing_bytes() {
439        let ct = make_ct(b"hi".to_vec());
440        let mut der = encode(&ct);
441        der.push(0xff); // trailing garbage
442        assert!(decode(&der).is_none());
443    }
444
445    /// Canonical DER encoding of zero (`02 01 00`) on `x` round-trips.
446    /// `(0, y)` is a valid affine C1 if it lies on the curve; the wire
447    /// format must accept the field element 0. Regression test for the
448    /// previous decoder copying the signature INTEGER rule that
449    /// rejected single-byte zero content.
450    #[test]
451    fn round_trip_x_zero() {
452        let mut ct = make_ct(b"z".to_vec());
453        ct.x = U256::ZERO;
454        let der = encode(&ct);
455        let decoded = decode(&der).expect("decode round-trip with x = 0");
456        assert_eq!(decoded.x, U256::ZERO);
457        assert_eq!(decoded.y, ct.y);
458    }
459
460    /// Strict canonical INTEGER: a 32-byte coordinate ≥ p is rejected.
461    /// Without this bound, `Fp::new` silently reduces the value modulo
462    /// `p`, admitting a second DER blob for the same field element —
463    /// ciphertext malleability. Regression test for the v0.2 review
464    /// finding from the codex pre-publish review.
465    #[test]
466    fn rejects_x_at_or_above_p() {
467        // Build a SEQUENCE with x = p (the SM2 prime). After Fp::new
468        // reduction this would be 0; without the field-bound check the
469        // wire-format admits this.
470        let p = *Fp::MODULUS.as_ref();
471        let p_bytes = p.to_be_bytes();
472        let mut body = Vec::new();
473        body.extend_from_slice(&encode_integer(&p_bytes));
474        body.extend_from_slice(&encode_integer(&[0x01]));
475        body.extend_from_slice(&encode_octet_string(&[0u8; 32]));
476        body.extend_from_slice(&encode_octet_string(b""));
477        let mut der = Vec::new();
478        der.push(0x30);
479        push_length(&mut der, body.len());
480        der.extend_from_slice(&body);
481        assert!(
482            decode(&der).is_none(),
483            "x = p is not a field element and must be rejected"
484        );
485
486        // Also verify `2^256 - 1` is rejected (well above p).
487        let max_bytes = [0xffu8; 32];
488        let mut body = Vec::new();
489        body.extend_from_slice(&encode_integer(&max_bytes));
490        body.extend_from_slice(&encode_integer(&[0x01]));
491        body.extend_from_slice(&encode_octet_string(&[0u8; 32]));
492        body.extend_from_slice(&encode_octet_string(b""));
493        let mut der = Vec::new();
494        der.push(0x30);
495        push_length(&mut der, body.len());
496        der.extend_from_slice(&body);
497        assert!(decode(&der).is_none(), "x = 2^256 - 1 must be rejected");
498    }
499
500    /// Companion check: `p - 1` is the largest valid coordinate and
501    /// must round-trip cleanly.
502    #[test]
503    fn round_trip_x_p_minus_one() {
504        let p_minus_one = Fp::MODULUS.as_ref().wrapping_sub(&U256::ONE);
505        let mut ct = make_ct(b"q".to_vec());
506        ct.x = p_minus_one;
507        let der = encode(&ct);
508        let decoded = decode(&der).expect("decode round-trip with x = p - 1");
509        assert_eq!(decoded.x, p_minus_one);
510    }
511
512    /// The 0x83 length encoding boundary: a ciphertext payload exactly
513    /// 65,536 bytes long forces 3-byte length.
514    #[test]
515    fn round_trip_65536_byte_ciphertext_uses_3byte_length() {
516        let payload = alloc::vec![0xa5u8; 65_536];
517        let ct = make_ct(payload.clone());
518        let der = encode(&ct);
519        // Sanity-check that the encoder used 0x83 somewhere (the
520        // ciphertext OCTET STRING's length is 65_536, which needs 0x83).
521        // Don't assert the exact byte position — just round-trip.
522        let decoded = decode(&der).expect("decode 65,536-byte round-trip");
523        assert_eq!(decoded.ciphertext, payload);
524    }
525}