Skip to main content

_synta/
crypto.rs

1//! Symmetric-crypto primitives for Python: HMAC, PBKDF2, AES-CBC, 3DES-CBC,
2//! PKCS#7 padding helpers, and the Fernet authenticated-encryption scheme.
3//!
4//! All operations are delegated to `synta-certificate`'s backend traits so
5//! that no direct `openssl::*` imports are needed here.
6
7use pyo3::exceptions::PyValueError;
8use pyo3::prelude::*;
9use pyo3::types::PyBytes;
10use synta_certificate::{
11    default_block_cipher_provider, default_hmac_provider, default_pbkdf2_provider,
12    default_secure_random, BlockCipherProvider, HmacProvider, Pbkdf2Provider, SecureRandom,
13};
14
15// ── URL-safe base64 helpers (used by Fernet) ─────────────────────────────────
16
17/// Encode `data` as URL-safe base64 (RFC 4648 §5: `-` for `+`, `_` for `/`).
18fn urlsafe_b64encode(data: &[u8]) -> String {
19    synta_certificate::encode_base64(data)
20        .replace('+', "-")
21        .replace('/', "_")
22}
23
24/// Decode URL-safe base64 bytes.  Translates `-`/`_` back to `+`/`/` before
25/// decoding.
26fn urlsafe_b64decode(data: &[u8]) -> PyResult<Vec<u8>> {
27    let s = std::str::from_utf8(data)
28        .map_err(|_| PyValueError::new_err("Fernet key/token must be valid ASCII"))?;
29    let standard = s.replace('-', "+").replace('_', "/");
30    synta_certificate::decode_base64(standard.as_bytes())
31        .ok_or_else(|| PyValueError::new_err("base64 decode error"))
32}
33
34// ── HMAC ─────────────────────────────────────────────────────────────────────
35
36/// Compute an HMAC digest and return the raw MAC bytes.
37///
38/// ``algorithm`` must be one of ``"sha1"``, ``"sha224"``, ``"sha256"``,
39/// ``"sha384"``, ``"sha512"``, or ``"md5"``.
40///
41/// ```python,ignore
42/// import synta
43///
44/// key  = b"secret"
45/// data = b"hello world"
46/// mac  = synta.hmac_digest("sha256", key, data)
47/// print(mac.hex())
48/// ```
49#[pyfunction]
50pub fn hmac_digest<'py>(
51    py: Python<'py>,
52    algorithm: &str,
53    key: &[u8],
54    data: &[u8],
55) -> PyResult<Bound<'py, PyBytes>> {
56    let mac = default_hmac_provider()
57        .hmac_compute(algorithm, key, data)
58        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
59    Ok(PyBytes::new(py, &mac))
60}
61
62/// Verify an HMAC digest in constant time.
63///
64/// Raises :exc:`ValueError` if the computed HMAC does not match ``expected``.
65/// Uses constant-time comparison to prevent timing side-channels.
66///
67/// ```python,ignore
68/// import synta
69///
70/// synta.hmac_verify("sha256", key, data, expected_mac)
71/// # raises ValueError on mismatch
72/// ```
73#[pyfunction]
74pub fn hmac_verify(algorithm: &str, key: &[u8], data: &[u8], expected: &[u8]) -> PyResult<()> {
75    default_hmac_provider()
76        .hmac_verify(algorithm, key, data, expected)
77        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
78    Ok(())
79}
80
81// ── PBKDF2 ───────────────────────────────────────────────────────────────────
82
83/// Derive a key using PBKDF2-HMAC.
84///
85/// Returns ``length`` raw key bytes derived from ``password`` and ``salt``
86/// using the given HMAC ``algorithm`` and the specified ``iterations`` count.
87///
88/// ```python,ignore
89/// import synta
90///
91/// key = synta.pbkdf2_hmac("sha256", b"password", b"salt", 100_000, 32)
92/// ```
93#[pyfunction]
94pub fn pbkdf2_hmac<'py>(
95    py: Python<'py>,
96    algorithm: &str,
97    password: &[u8],
98    salt: &[u8],
99    iterations: u32,
100    length: usize,
101) -> PyResult<Bound<'py, PyBytes>> {
102    let out = default_pbkdf2_provider()
103        .pbkdf2_hmac(algorithm, password, salt, iterations as usize, length)
104        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
105    Ok(PyBytes::new(py, &out))
106}
107
108// ── AES-CBC ──────────────────────────────────────────────────────────────────
109
110/// Encrypt data using AES-CBC.
111///
112/// Key length determines the AES variant: 16 → AES-128, 24 → AES-192,
113/// 32 → AES-256.  ``iv`` must be 16 bytes.
114///
115/// When ``pad=True`` (default) PKCS#7 padding is applied automatically.
116/// When ``pad=False`` the caller is responsible for correct block alignment.
117///
118/// ```python,ignore
119/// import synta
120///
121/// ct = synta.aes_cbc_encrypt(key_32, iv_16, plaintext)
122/// ct_no_pad = synta.aes_cbc_encrypt(key_16, iv_16, aligned_data, pad=False)
123/// ```
124#[pyfunction]
125#[pyo3(signature = (key, iv, plaintext, pad = true))]
126pub fn aes_cbc_encrypt<'py>(
127    py: Python<'py>,
128    key: &[u8],
129    iv: &[u8],
130    plaintext: &[u8],
131    pad: bool,
132) -> PyResult<Bound<'py, PyBytes>> {
133    let ct = default_block_cipher_provider()
134        .aes_cbc_encrypt(key, iv, plaintext, pad)
135        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
136    Ok(PyBytes::new(py, &ct))
137}
138
139/// Decrypt data using AES-CBC.
140///
141/// Key length determines the AES variant: 16 → AES-128, 24 → AES-192,
142/// 32 → AES-256.  ``iv`` must be 16 bytes.
143///
144/// When ``unpad=True`` (default) PKCS#7 padding is stripped automatically.
145/// When ``unpad=False`` the raw decrypted bytes are returned.
146///
147/// ```python,ignore
148/// import synta
149///
150/// pt = synta.aes_cbc_decrypt(key_32, iv_16, ciphertext)
151/// pt_raw = synta.aes_cbc_decrypt(key_16, iv_16, ciphertext, unpad=False)
152/// ```
153#[pyfunction]
154#[pyo3(signature = (key, iv, ciphertext, unpad = true))]
155pub fn aes_cbc_decrypt<'py>(
156    py: Python<'py>,
157    key: &[u8],
158    iv: &[u8],
159    ciphertext: &[u8],
160    unpad: bool,
161) -> PyResult<Bound<'py, PyBytes>> {
162    let pt = default_block_cipher_provider()
163        .aes_cbc_decrypt(key, iv, ciphertext, unpad)
164        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
165    Ok(PyBytes::new(py, &pt))
166}
167
168// ── 3DES-EDE-CBC ─────────────────────────────────────────────────────────────
169
170/// Encrypt data using 3DES-EDE-CBC.
171///
172/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
173/// ``iv`` must be 8 bytes.
174///
175/// When ``pad=True`` (default) PKCS#7 padding is applied automatically.
176/// When ``pad=False`` the caller is responsible for correct block alignment.
177///
178/// ```python,ignore
179/// import synta
180///
181/// ct = synta.des3_cbc_encrypt(key_24, iv_8, plaintext)
182/// ```
183#[pyfunction]
184#[pyo3(signature = (key, iv, plaintext, pad = true))]
185pub fn des3_cbc_encrypt<'py>(
186    py: Python<'py>,
187    key: &[u8],
188    iv: &[u8],
189    plaintext: &[u8],
190    pad: bool,
191) -> PyResult<Bound<'py, PyBytes>> {
192    let ct = default_block_cipher_provider()
193        .des3_cbc_encrypt(key, iv, plaintext, pad)
194        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
195    Ok(PyBytes::new(py, &ct))
196}
197
198/// Decrypt data using 3DES-EDE-CBC.
199///
200/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
201/// ``iv`` must be 8 bytes.
202///
203/// When ``unpad=True`` (default) PKCS#7 padding is stripped automatically.
204/// When ``unpad=False`` raw decrypted bytes are returned.
205///
206/// ```python,ignore
207/// import synta
208///
209/// pt = synta.des3_cbc_decrypt(key_24, iv_8, ciphertext)
210/// ```
211#[pyfunction]
212#[pyo3(signature = (key, iv, ciphertext, unpad = true))]
213pub fn des3_cbc_decrypt<'py>(
214    py: Python<'py>,
215    key: &[u8],
216    iv: &[u8],
217    ciphertext: &[u8],
218    unpad: bool,
219) -> PyResult<Bound<'py, PyBytes>> {
220    let pt = default_block_cipher_provider()
221        .des3_cbc_decrypt(key, iv, ciphertext, unpad)
222        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
223    Ok(PyBytes::new(py, &pt))
224}
225
226// ── PKCS#7 padding ───────────────────────────────────────────────────────────
227
228/// Apply PKCS#7 padding to ``data``.
229///
230/// ``block_size`` is the cipher block size in bytes (16 for AES, 8 for 3DES).
231/// Raises :exc:`ValueError` if ``block_size`` is 0 or greater than 255.
232///
233/// ```python,ignore
234/// import synta
235///
236/// padded = synta.pkcs7_pad(b"hello", 8)   # → b"hello\x03\x03\x03"
237/// ```
238#[pyfunction]
239pub fn pkcs7_pad<'py>(
240    py: Python<'py>,
241    data: &[u8],
242    block_size: usize,
243) -> PyResult<Bound<'py, PyBytes>> {
244    if block_size == 0 || block_size > 255 {
245        return Err(PyValueError::new_err(
246            "block_size must be between 1 and 255",
247        ));
248    }
249    let pad_len = block_size - (data.len() % block_size);
250    let mut padded = Vec::with_capacity(data.len() + pad_len);
251    padded.extend_from_slice(data);
252    padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
253    Ok(PyBytes::new(py, &padded))
254}
255
256/// Remove PKCS#7 padding from ``data``.
257///
258/// Raises :exc:`ValueError` if the padding is missing or inconsistent.
259///
260/// ```python,ignore
261/// import synta
262///
263/// unpadded = synta.pkcs7_unpad(b"hello\x03\x03\x03", 8)   # → b"hello"
264/// ```
265#[pyfunction]
266pub fn pkcs7_unpad<'py>(
267    py: Python<'py>,
268    data: &[u8],
269    block_size: usize,
270) -> PyResult<Bound<'py, PyBytes>> {
271    if data.is_empty() {
272        return Err(PyValueError::new_err("data is empty"));
273    }
274    let pad_byte = *data.last().unwrap() as usize;
275    if pad_byte == 0 || pad_byte > block_size || pad_byte > data.len() {
276        return Err(PyValueError::new_err("invalid PKCS#7 padding"));
277    }
278    let pad_start = data.len() - pad_byte;
279    if data[pad_start..].iter().any(|&b| b as usize != pad_byte) {
280        return Err(PyValueError::new_err("inconsistent PKCS#7 padding bytes"));
281    }
282    Ok(PyBytes::new(py, &data[..pad_start]))
283}
284
285// ── Fernet ───────────────────────────────────────────────────────────────────
286
287/// Fernet symmetric authenticated encryption.
288///
289/// Implements the `Fernet specification <https://github.com/fernet/spec/blob/master/Spec.md>`_
290/// using AES-128-CBC encryption and HMAC-SHA256 authentication.  Tokens
291/// produced by this class are byte-for-byte compatible with those produced
292/// by ``cryptography.fernet.Fernet``.
293///
294/// **Key format:** a URL-safe base64-encoded string of 32 raw bytes, where
295/// the first 16 bytes are the signing key and the last 16 bytes are the
296/// encryption key.
297///
298/// **Token format (before base64url encoding):**
299///
300/// * 1 byte: version (``0x80``)
301/// * 8 bytes: timestamp (big-endian unsigned 64-bit Unix seconds)
302/// * 16 bytes: AES-CBC initialization vector
303/// * N bytes: AES-128-CBC ciphertext (PKCS#7-padded plaintext)
304/// * 32 bytes: HMAC-SHA256 of all preceding bytes
305///
306/// ```python,ignore
307/// import synta
308///
309/// key   = synta.Fernet.generate_key()
310/// fern  = synta.Fernet(key)
311/// token = fern.encrypt(b"secret data")
312/// plain = fern.decrypt(token)
313/// assert plain == b"secret data"
314///
315/// # TTL check (seconds):
316/// plain = fern.decrypt(token, ttl=300)
317/// ```
318#[pyclass(frozen, name = "Fernet")]
319pub struct PyFernet {
320    signing_key: [u8; 16],
321    encryption_key: [u8; 16],
322}
323
324#[pymethods]
325impl PyFernet {
326    /// Create a :class:`Fernet` instance from a URL-safe base64-encoded 32-byte key.
327    ///
328    /// ```python,ignore
329    /// key  = synta.Fernet.generate_key()
330    /// fern = synta.Fernet(key)
331    /// ```
332    #[new]
333    fn new(key: &[u8]) -> PyResult<Self> {
334        let raw = urlsafe_b64decode(key)?;
335        if raw.len() != 32 {
336            return Err(PyValueError::new_err(
337                "Fernet key must decode to exactly 32 bytes",
338            ));
339        }
340        let mut signing_key = [0u8; 16];
341        let mut encryption_key = [0u8; 16];
342        signing_key.copy_from_slice(&raw[0..16]);
343        encryption_key.copy_from_slice(&raw[16..32]);
344        Ok(Self {
345            signing_key,
346            encryption_key,
347        })
348    }
349
350    /// Generate a fresh random Fernet key.
351    ///
352    /// Returns 32 cryptographically random bytes encoded as URL-safe base64.
353    ///
354    /// ```python,ignore
355    /// key = synta.Fernet.generate_key()
356    /// fern = synta.Fernet(key)
357    /// ```
358    #[staticmethod]
359    fn generate_key<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
360        let mut raw = [0u8; 32];
361        default_secure_random()
362            .rand_bytes(&mut raw)
363            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
364        Ok(PyBytes::new(py, urlsafe_b64encode(&raw).as_bytes()))
365    }
366
367    /// Encrypt ``data`` and return a Fernet token as URL-safe base64 bytes.
368    ///
369    /// The token includes a timestamp set to the current UTC time; use
370    /// :meth:`decrypt` with a ``ttl`` argument to enforce expiry.
371    ///
372    /// ```python,ignore
373    /// token = fern.encrypt(b"hello")
374    /// ```
375    fn encrypt<'py>(&self, py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyBytes>> {
376        let rng = default_secure_random();
377        let cipher = default_block_cipher_provider();
378        let hmac = default_hmac_provider();
379
380        // Generate a random 16-byte IV.
381        let mut iv = [0u8; 16];
382        rng.rand_bytes(&mut iv)
383            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
384
385        // Current Unix timestamp (seconds).
386        let ts = std::time::SystemTime::now()
387            .duration_since(std::time::UNIX_EPOCH)
388            .map_err(|_| PyValueError::new_err("system clock error"))?
389            .as_secs();
390
391        // AES-128-CBC encrypt with PKCS#7 padding.
392        let ciphertext = cipher
393            .aes_cbc_encrypt(&self.encryption_key, &iv, data, true)
394            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
395
396        // Assemble the pre-MAC bytes: version(1) || ts_be(8) || iv(16) || ciphertext.
397        let mut pre_mac = Vec::with_capacity(1 + 8 + 16 + ciphertext.len());
398        pre_mac.push(0x80u8);
399        pre_mac.extend_from_slice(&ts.to_be_bytes());
400        pre_mac.extend_from_slice(&iv);
401        pre_mac.extend_from_slice(&ciphertext);
402
403        // HMAC-SHA256 over the pre-MAC bytes.
404        let mac = hmac
405            .hmac_compute("sha256", &self.signing_key, &pre_mac)
406            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
407
408        // Token = pre_mac || mac, base64url-encoded.
409        let mut token_bytes = pre_mac;
410        token_bytes.extend_from_slice(&mac);
411        Ok(PyBytes::new(py, urlsafe_b64encode(&token_bytes).as_bytes()))
412    }
413
414    /// Decrypt a Fernet ``token``, optionally enforcing a TTL.
415    ///
416    /// ``token`` must be URL-safe base64-encoded bytes as returned by
417    /// :meth:`encrypt`.
418    ///
419    /// If ``ttl`` is given (seconds), raises :exc:`ValueError` when the token
420    /// is older than ``ttl`` seconds relative to the current time.
421    ///
422    /// Raises :exc:`ValueError` on malformed tokens, unknown version bytes,
423    /// HMAC verification failure, or TTL expiry.
424    ///
425    /// ```python,ignore
426    /// plain = fern.decrypt(token)
427    /// plain = fern.decrypt(token, ttl=300)
428    /// ```
429    #[pyo3(signature = (token, ttl = None))]
430    fn decrypt<'py>(
431        &self,
432        py: Python<'py>,
433        token: &[u8],
434        ttl: Option<u64>,
435    ) -> PyResult<Bound<'py, PyBytes>> {
436        let hmac = default_hmac_provider();
437        let cipher = default_block_cipher_provider();
438
439        // Decode URL-safe base64.
440        let raw = urlsafe_b64decode(token)?;
441
442        // Minimum token length: version(1) + ts(8) + iv(16) + one AES block(16) + hmac(32) = 73.
443        if raw.len() < 1 + 8 + 16 + 16 + 32 {
444            return Err(PyValueError::new_err("invalid Fernet token: too short"));
445        }
446
447        // Check the version byte.
448        if raw[0] != 0x80 {
449            return Err(PyValueError::new_err(
450                "invalid Fernet token: unknown version byte",
451            ));
452        }
453
454        // Extract fields.
455        let ts = u64::from_be_bytes(
456            raw[1..9]
457                .try_into()
458                .map_err(|_| PyValueError::new_err("internal error: Fernet timestamp slice"))?,
459        );
460        let iv = &raw[9..25];
461        let ciphertext = &raw[25..raw.len() - 32];
462        let token_mac = &raw[raw.len() - 32..];
463
464        // Verify HMAC-SHA256 in constant time.
465        let pre_mac = &raw[..raw.len() - 32];
466        let computed_mac = hmac
467            .hmac_compute("sha256", &self.signing_key, pre_mac)
468            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
469
470        if !synta_certificate::constant_time_eq(&computed_mac, token_mac) {
471            return Err(PyValueError::new_err(
472                "invalid Fernet token: HMAC verification failed",
473            ));
474        }
475
476        // TTL check (done after HMAC so we don't leak timing info about bad tokens).
477        if let Some(ttl_secs) = ttl {
478            let now = std::time::SystemTime::now()
479                .duration_since(std::time::UNIX_EPOCH)
480                .map_err(|_| PyValueError::new_err("system clock error"))?
481                .as_secs();
482            // Reject tokens issued in the future (60-second clock-skew tolerance).
483            const MAX_CLOCK_SKEW: u64 = 60;
484            if ts > now.saturating_add(MAX_CLOCK_SKEW) {
485                return Err(PyValueError::new_err(
486                    "Fernet token timestamp is in the future",
487                ));
488            }
489            if now.saturating_sub(ts) > ttl_secs {
490                return Err(PyValueError::new_err("Fernet token has expired"));
491            }
492        }
493
494        // AES-128-CBC decrypt with PKCS#7 unpadding.
495        let plaintext = cipher
496            .aes_cbc_decrypt(&self.encryption_key, iv, ciphertext, true)
497            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
498
499        Ok(PyBytes::new(py, &plaintext))
500    }
501}
502
503// ── Submodule registration ────────────────────────────────────────────────────
504
505/// Register the ``synta.crypto`` submodule.
506///
507/// Exposes all symmetric-crypto primitives under ``synta.crypto``:
508///
509/// * :class:`Fernet` — authenticated encryption (AES-128-CBC + HMAC-SHA256)
510/// * :func:`hmac_digest` / :func:`hmac_verify` — HMAC generation and verification
511/// * :func:`pbkdf2_hmac` — PBKDF2 key derivation
512/// * :func:`aes_cbc_encrypt` / :func:`aes_cbc_decrypt` — AES-CBC
513/// * :func:`des3_cbc_encrypt` / :func:`des3_cbc_decrypt` — 3DES-EDE-CBC
514/// * :func:`pkcs7_pad` / :func:`pkcs7_unpad` — PKCS#7 block padding helpers
515pub fn register_crypto_module(parent: &Bound<'_, PyModule>) -> PyResult<()> {
516    use pyo3::prelude::*;
517    let py = parent.py();
518    let m = PyModule::new(py, "crypto")?;
519
520    m.add_class::<PyFernet>()?;
521    m.add_function(wrap_pyfunction!(hmac_digest, &m)?)?;
522    m.add_function(wrap_pyfunction!(hmac_verify, &m)?)?;
523    m.add_function(wrap_pyfunction!(pbkdf2_hmac, &m)?)?;
524    m.add_function(wrap_pyfunction!(aes_cbc_encrypt, &m)?)?;
525    m.add_function(wrap_pyfunction!(aes_cbc_decrypt, &m)?)?;
526    m.add_function(wrap_pyfunction!(des3_cbc_encrypt, &m)?)?;
527    m.add_function(wrap_pyfunction!(des3_cbc_decrypt, &m)?)?;
528    m.add_function(wrap_pyfunction!(pkcs7_pad, &m)?)?;
529    m.add_function(wrap_pyfunction!(pkcs7_unpad, &m)?)?;
530    m.add_class::<crate::otp::PyHOTP>()?;
531    m.add_class::<crate::otp::PyTOTP>()?;
532
533    crate::install_submodule(
534        parent,
535        &m,
536        "synta.crypto",
537        Some(concat!(
538            "Symmetric cryptography primitives: HMAC, PBKDF2, AES-CBC, 3DES-CBC, ",
539            "PKCS#7 padding, and Fernet authenticated encryption (RFC-compatible ",
540            "with the Python ``cryptography`` library).",
541        )),
542    )
543}