Skip to main content

cryptography/public_key/
schmidt_samoa.rs

1//! Schmidt-Samoa public-key primitive (Katja Schmidt-Samoa, 2005).
2//!
3//! This keeps the Schmidt-Samoa arithmetic map explicit: prime inputs, public
4//! modulus `n = p^2 q`, and private decryption exponent modulo `gamma = p q`.
5//! On top of that arithmetic core, the byte helpers serialize ciphertexts as
6//! single-field DER `INTEGER` payloads so the scheme can be used directly on
7//! byte strings.
8
9use core::fmt;
10
11use crate::public_key::bigint::{BigUint, MontgomeryCtx};
12use crate::public_key::io::{
13    decode_biguints, encode_biguints, pem_unwrap, pem_wrap, xml_unwrap, xml_wrap,
14};
15use crate::public_key::primes::{
16    is_probable_prime, lcm, mod_inverse, mod_pow, random_probable_prime,
17};
18use crate::Csprng;
19
20const SCHMIDT_SAMOA_PUBLIC_LABEL: &str = "CRYPTOGRAPHY SCHMIDT-SAMOA PUBLIC KEY";
21const SCHMIDT_SAMOA_PRIVATE_LABEL: &str = "CRYPTOGRAPHY SCHMIDT-SAMOA PRIVATE KEY";
22
23/// Public key for the Schmidt-Samoa primitive.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct SchmidtSamoaPublicKey {
26    n: BigUint,
27    n_ctx: Option<MontgomeryCtx>,
28}
29
30/// Private key for the Schmidt-Samoa primitive.
31#[derive(Clone, Eq, PartialEq)]
32pub struct SchmidtSamoaPrivateKey {
33    d: BigUint,
34    gamma: BigUint,
35    gamma_ctx: Option<MontgomeryCtx>,
36}
37
38/// Namespace wrapper for the Schmidt-Samoa construction.
39pub struct SchmidtSamoa;
40
41impl SchmidtSamoaPublicKey {
42    /// Return the public modulus `n = p^2 q`.
43    #[must_use]
44    pub fn modulus(&self) -> &BigUint {
45        &self.n
46    }
47
48    /// Return a conservative public upper bound for byte-oriented plaintexts.
49    ///
50    /// For `n = p^2 q`, the private reduction modulus `gamma = p q` always
51    /// satisfies `gamma > floor(sqrt(n))`, so any message below this bound is
52    /// guaranteed to round-trip through the private map.
53    #[must_use]
54    pub fn max_plaintext_exclusive(&self) -> BigUint {
55        self.n.sqrt_floor()
56    }
57
58    /// Apply the raw public map `m^n mod n`.
59    ///
60    /// Unlike textbook RSA, the public exponent is the modulus `n` itself.
61    /// The inverse map recovers the original message only for values
62    /// interpreted in the range `[0, gamma)`, where `gamma = p q`.
63    #[must_use]
64    pub fn encrypt_raw(&self, message: &BigUint) -> BigUint {
65        if let Some(ctx) = &self.n_ctx {
66            ctx.pow(message, &self.n)
67        } else {
68            mod_pow(message, &self.n, &self.n)
69        }
70    }
71
72    /// Encrypt a byte string using the conservative public plaintext bound.
73    #[must_use]
74    pub fn encrypt(&self, message: &[u8]) -> Option<BigUint> {
75        let message_int = BigUint::from_be_bytes(message);
76        if message_int >= self.max_plaintext_exclusive() {
77            return None;
78        }
79        Some(self.encrypt_raw(&message_int))
80    }
81
82    /// Encrypt a byte string and return the serialized ciphertext bytes.
83    #[must_use]
84    pub fn encrypt_bytes(&self, message: &[u8]) -> Option<Vec<u8>> {
85        let ciphertext = self.encrypt(message)?;
86        Some(encode_biguints(&[&ciphertext]))
87    }
88
89    /// Encode the public key in the crate-defined binary format.
90    #[must_use]
91    pub fn to_key_blob(&self) -> Vec<u8> {
92        encode_biguints(&[&self.n])
93    }
94
95    /// Decode the public key from the crate-defined binary format.
96    #[must_use]
97    pub fn from_key_blob(blob: &[u8]) -> Option<Self> {
98        let mut fields = decode_biguints(blob)?.into_iter();
99        let n = fields.next()?;
100        if fields.next().is_some() || n <= BigUint::one() {
101            return None;
102        }
103        let n_ctx = MontgomeryCtx::new(&n);
104        Some(Self { n, n_ctx })
105    }
106
107    /// Encode the public key in PEM using the crate-defined label.
108    #[must_use]
109    pub fn to_pem(&self) -> String {
110        pem_wrap(SCHMIDT_SAMOA_PUBLIC_LABEL, &self.to_key_blob())
111    }
112
113    /// Encode the public key as the crate's flat XML form.
114    #[must_use]
115    pub fn to_xml(&self) -> String {
116        xml_wrap("SchmidtSamoaPublicKey", &[("n", &self.n)])
117    }
118
119    /// Decode the public key from the crate-defined PEM label.
120    #[must_use]
121    pub fn from_pem(pem: &str) -> Option<Self> {
122        let blob = pem_unwrap(SCHMIDT_SAMOA_PUBLIC_LABEL, pem)?;
123        Self::from_key_blob(&blob)
124    }
125
126    /// Decode the public key from the crate's flat XML form.
127    #[must_use]
128    pub fn from_xml(xml: &str) -> Option<Self> {
129        let mut fields = xml_unwrap("SchmidtSamoaPublicKey", &["n"], xml)?.into_iter();
130        let n = fields.next()?;
131        if fields.next().is_some() || n <= BigUint::one() {
132            return None;
133        }
134        let n_ctx = MontgomeryCtx::new(&n);
135        Some(Self { n, n_ctx })
136    }
137}
138
139impl SchmidtSamoaPrivateKey {
140    /// Return the private exponent.
141    #[must_use]
142    pub fn exponent(&self) -> &BigUint {
143        &self.d
144    }
145
146    /// Return `gamma = p q`.
147    #[must_use]
148    pub fn gamma(&self) -> &BigUint {
149        &self.gamma
150    }
151
152    /// Apply the raw private map `c^d mod gamma`.
153    ///
154    /// This recovers the original message only for plaintexts represented in
155    /// the range `[0, gamma)`.
156    #[must_use]
157    pub fn decrypt_raw(&self, ciphertext: &BigUint) -> BigUint {
158        if let Some(ctx) = &self.gamma_ctx {
159            ctx.pow(ciphertext, &self.d)
160        } else {
161            mod_pow(ciphertext, &self.d, &self.gamma)
162        }
163    }
164
165    /// Decrypt a ciphertext back into the big-endian byte string that was
166    /// interpreted as the plaintext integer.
167    #[must_use]
168    pub fn decrypt(&self, ciphertext: &BigUint) -> Vec<u8> {
169        self.decrypt_raw(ciphertext).to_be_bytes()
170    }
171
172    /// Decrypt a byte-encoded ciphertext produced by [`SchmidtSamoaPublicKey::encrypt_bytes`].
173    #[must_use]
174    pub fn decrypt_bytes(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
175        let mut fields = decode_biguints(ciphertext)?.into_iter();
176        let value = fields.next()?;
177        if fields.next().is_some() {
178            return None;
179        }
180        Some(self.decrypt(&value))
181    }
182
183    /// Encode the private key in the crate-defined binary format.
184    #[must_use]
185    pub fn to_key_blob(&self) -> Vec<u8> {
186        encode_biguints(&[&self.d, &self.gamma])
187    }
188
189    /// Decode the private key from the crate-defined binary format.
190    #[must_use]
191    pub fn from_key_blob(blob: &[u8]) -> Option<Self> {
192        let mut fields = decode_biguints(blob)?.into_iter();
193        let d = fields.next()?;
194        let gamma = fields.next()?;
195        if fields.next().is_some() || d.is_zero() || gamma <= BigUint::one() {
196            return None;
197        }
198        let gamma_ctx = MontgomeryCtx::new(&gamma);
199        Some(Self {
200            d,
201            gamma,
202            gamma_ctx,
203        })
204    }
205
206    /// Encode the private key in PEM using the crate-defined label.
207    #[must_use]
208    pub fn to_pem(&self) -> String {
209        pem_wrap(SCHMIDT_SAMOA_PRIVATE_LABEL, &self.to_key_blob())
210    }
211
212    /// Encode the private key as the crate's flat XML form.
213    #[must_use]
214    pub fn to_xml(&self) -> String {
215        xml_wrap(
216            "SchmidtSamoaPrivateKey",
217            &[("d", &self.d), ("gamma", &self.gamma)],
218        )
219    }
220
221    /// Decode the private key from the crate-defined PEM label.
222    #[must_use]
223    pub fn from_pem(pem: &str) -> Option<Self> {
224        let blob = pem_unwrap(SCHMIDT_SAMOA_PRIVATE_LABEL, pem)?;
225        Self::from_key_blob(&blob)
226    }
227
228    /// Decode the private key from the crate's flat XML form.
229    #[must_use]
230    pub fn from_xml(xml: &str) -> Option<Self> {
231        let mut fields = xml_unwrap("SchmidtSamoaPrivateKey", &["d", "gamma"], xml)?.into_iter();
232        let d = fields.next()?;
233        let gamma = fields.next()?;
234        if fields.next().is_some() || d.is_zero() || gamma <= BigUint::one() {
235            return None;
236        }
237        let gamma_ctx = MontgomeryCtx::new(&gamma);
238        Some(Self {
239            d,
240            gamma,
241            gamma_ctx,
242        })
243    }
244}
245
246impl fmt::Debug for SchmidtSamoaPrivateKey {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.write_str("SchmidtSamoaPrivateKey(<redacted>)")
249    }
250}
251
252impl SchmidtSamoa {
253    /// Derive a raw Schmidt-Samoa key pair from explicit primes.
254    ///
255    /// Returns `None` if the primes are equal, composite, or violate the
256    /// divisibility checks from the Python reference.
257    #[must_use]
258    pub fn from_primes(
259        p: &BigUint,
260        q: &BigUint,
261    ) -> Option<(SchmidtSamoaPublicKey, SchmidtSamoaPrivateKey)> {
262        if p == q || !is_probable_prime(p) || !is_probable_prime(q) {
263            return None;
264        }
265
266        let p_minus_one = p.sub_ref(&BigUint::one());
267        let q_minus_one = q.sub_ref(&BigUint::one());
268        // This explicit divisibility check is equivalent to the later
269        // `mod_inverse(...)?` failure, but keeping it here makes the Python
270        // parameter restriction visible at the key-derivation boundary.
271        if q_minus_one.modulo(p).is_zero() || p_minus_one.modulo(q).is_zero() {
272            return None;
273        }
274
275        let gamma = p.mul_ref(q);
276        let lambda = lcm(&p_minus_one, &q_minus_one);
277        let p_squared = p.mul_ref(p);
278        let n = p_squared.mul_ref(q);
279        let d = mod_inverse(&n, &lambda)?;
280
281        let n_ctx = MontgomeryCtx::new(&n);
282        let gamma_ctx = MontgomeryCtx::new(&gamma);
283        Some((
284            SchmidtSamoaPublicKey { n, n_ctx },
285            SchmidtSamoaPrivateKey {
286                d,
287                gamma,
288                gamma_ctx,
289            },
290        ))
291    }
292
293    /// Generate a Schmidt-Samoa key pair.
294    #[must_use]
295    pub fn generate<R: Csprng>(
296        rng: &mut R,
297        bits: usize,
298    ) -> Option<(SchmidtSamoaPublicKey, SchmidtSamoaPrivateKey)> {
299        // The split is roughly `bits / 3` for `p`, so tiny bit sizes can
300        // collapse to the same minimal prime and never yield a valid pair.
301        if bits < 8 {
302            return None;
303        }
304
305        let p_bits = bits / 3;
306        let q_bits = bits.saturating_sub(2 * p_bits);
307        let p_bits = p_bits.max(2);
308        let q_bits = q_bits.max(2);
309        loop {
310            let p = random_probable_prime(rng, p_bits)?;
311            let q = random_probable_prime(rng, q_bits)?;
312            if let Some(keypair) = Self::from_primes(&p, &q) {
313                return Some(keypair);
314            }
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::{SchmidtSamoa, SchmidtSamoaPrivateKey, SchmidtSamoaPublicKey};
322    use crate::public_key::bigint::BigUint;
323    use crate::CtrDrbgAes256;
324
325    #[test]
326    fn derive_small_reference_key() {
327        let p = BigUint::from_u64(3);
328        let q = BigUint::from_u64(5);
329        let (public, private) = SchmidtSamoa::from_primes(&p, &q).expect("valid Schmidt-Samoa key");
330        assert_eq!(public.modulus(), &BigUint::from_u64(45));
331        // With p = 3 and q = 5, n = 45 ≡ 1 (mod lcm(2, 4)), so the modular
332        // inverse used for the private exponent collapses to d = 1.
333        assert_eq!(private.exponent(), &BigUint::from_u64(1));
334        assert_eq!(private.gamma(), &BigUint::from_u64(15));
335    }
336
337    #[test]
338    fn roundtrip_small_messages() {
339        let p = BigUint::from_u64(3);
340        let q = BigUint::from_u64(5);
341        let (public, private) = SchmidtSamoa::from_primes(&p, &q).expect("valid Schmidt-Samoa key");
342
343        for msg in [0u64, 1, 2, 7, 14] {
344            let message = BigUint::from_u64(msg);
345            let ciphertext = public.encrypt_raw(&message);
346            let plaintext = private.decrypt_raw(&ciphertext);
347            assert_eq!(plaintext, message);
348        }
349    }
350
351    #[test]
352    fn exact_small_ciphertext_matches_reference() {
353        let p = BigUint::from_u64(3);
354        let q = BigUint::from_u64(5);
355        let (public, private) = SchmidtSamoa::from_primes(&p, &q).expect("valid Schmidt-Samoa key");
356        let message = BigUint::from_u64(7);
357        let ciphertext = public.encrypt_raw(&message);
358        assert_eq!(ciphertext, BigUint::from_u64(37));
359        assert_eq!(private.decrypt_raw(&ciphertext), message);
360    }
361
362    #[test]
363    fn rejects_invalid_parameters() {
364        let p = BigUint::from_u64(3);
365        let q = BigUint::from_u64(7);
366        assert!(SchmidtSamoa::from_primes(&p, &q).is_none());
367
368        let p = BigUint::from_u64(3);
369        let composite = BigUint::from_u64(21);
370        assert!(SchmidtSamoa::from_primes(&p, &composite).is_none());
371
372        let p = BigUint::from_u64(5);
373        assert!(SchmidtSamoa::from_primes(&p, &p).is_none());
374    }
375
376    #[test]
377    fn byte_wrapper_roundtrip() {
378        let p = BigUint::from_u64(3);
379        let q = BigUint::from_u64(5);
380        let (public, private) = SchmidtSamoa::from_primes(&p, &q).expect("valid Schmidt-Samoa key");
381        let ciphertext = public.encrypt(&[0x05]).expect("message fits");
382        assert_eq!(private.decrypt(&ciphertext), vec![0x05]);
383    }
384
385    #[test]
386    fn generate_keypair_roundtrip() {
387        let mut drbg = CtrDrbgAes256::new(&[0x71; 48]);
388        let (public, private) =
389            SchmidtSamoa::generate(&mut drbg, 48).expect("Schmidt-Samoa key generation");
390        let ciphertext = public.encrypt(&[0x2a]).expect("message fits");
391        assert_eq!(private.decrypt(&ciphertext), vec![0x2a]);
392    }
393
394    #[test]
395    fn generate_rejects_too_few_bits() {
396        let mut drbg = CtrDrbgAes256::new(&[0x93; 48]);
397        assert!(SchmidtSamoa::generate(&mut drbg, 7).is_none());
398    }
399
400    #[test]
401    fn key_serialization_roundtrip() {
402        let mut drbg = CtrDrbgAes256::new(&[0xb3; 48]);
403        let (public, private) =
404            SchmidtSamoa::generate(&mut drbg, 48).expect("Schmidt-Samoa key generation");
405
406        let public_blob = public.to_key_blob();
407        let private_blob = private.to_key_blob();
408        assert_eq!(
409            SchmidtSamoaPublicKey::from_key_blob(&public_blob),
410            Some(public.clone())
411        );
412        assert_eq!(
413            SchmidtSamoaPrivateKey::from_key_blob(&private_blob),
414            Some(private.clone())
415        );
416
417        let public_pem = public.to_pem();
418        let private_pem = private.to_pem();
419        let public_xml = public.to_xml();
420        let private_xml = private.to_xml();
421        assert_eq!(
422            SchmidtSamoaPublicKey::from_pem(&public_pem),
423            Some(public.clone())
424        );
425        assert_eq!(
426            SchmidtSamoaPrivateKey::from_pem(&private_pem),
427            Some(private.clone())
428        );
429        assert_eq!(SchmidtSamoaPublicKey::from_xml(&public_xml), Some(public));
430        assert_eq!(
431            SchmidtSamoaPrivateKey::from_xml(&private_xml),
432            Some(private)
433        );
434    }
435
436    #[test]
437    fn byte_ciphertext_roundtrip() {
438        let p = BigUint::from_u64(3);
439        let q = BigUint::from_u64(5);
440        let (public, private) = SchmidtSamoa::from_primes(&p, &q).expect("valid Schmidt-Samoa key");
441        let ciphertext = public.encrypt_bytes(&[0x05]).expect("message fits");
442        assert_eq!(private.decrypt_bytes(&ciphertext), Some(vec![0x05]));
443    }
444}