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, default_streaming_hmac_provider, hkdf_expand, hkdf_extract,
13    hmac_output_len, BlockCipherProvider, HmacProvider, HmacState, Pbkdf2Provider, SecureRandom,
14    StreamingHmacProvider,
15};
16
17// ── URL-safe base64 helpers (used by Fernet) ─────────────────────────────────
18
19/// Encode `data` as URL-safe base64 (RFC 4648 §5: `-` for `+`, `_` for `/`).
20fn urlsafe_b64encode(data: &[u8]) -> String {
21    synta_certificate::encode_base64(data)
22        .replace('+', "-")
23        .replace('/', "_")
24}
25
26/// Decode URL-safe base64 bytes.  Translates `-`/`_` back to `+`/`/` before
27/// decoding.
28fn urlsafe_b64decode(data: &[u8]) -> PyResult<Vec<u8>> {
29    let s = std::str::from_utf8(data)
30        .map_err(|_| PyValueError::new_err("Fernet key/token must be valid ASCII"))?;
31    let standard = s.replace('-', "+").replace('_', "/");
32    synta_certificate::decode_base64(standard.as_bytes())
33        .ok_or_else(|| PyValueError::new_err("base64 decode error"))
34}
35
36// ── HMAC ─────────────────────────────────────────────────────────────────────
37
38/// Compute an HMAC digest and return the raw MAC bytes.
39///
40/// ``algorithm`` must be one of ``"sha1"``, ``"sha224"``, ``"sha256"``,
41/// ``"sha384"``, ``"sha512"``, or ``"md5"``.
42///
43/// ```python,ignore
44/// import synta
45///
46/// key  = b"secret"
47/// data = b"hello world"
48/// mac  = synta.hmac_digest("sha256", key, data)
49/// print(mac.hex())
50/// ```
51#[pyfunction]
52pub fn hmac_digest<'py>(
53    py: Python<'py>,
54    algorithm: &str,
55    key: &[u8],
56    data: &[u8],
57) -> PyResult<Bound<'py, PyBytes>> {
58    let mac = default_hmac_provider()
59        .hmac_compute(algorithm, key, data)
60        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
61    Ok(PyBytes::new(py, &mac))
62}
63
64/// Verify an HMAC digest in constant time.
65///
66/// Raises :exc:`ValueError` if the computed HMAC does not match ``expected``.
67/// Uses constant-time comparison to prevent timing side-channels.
68///
69/// ```python,ignore
70/// import synta
71///
72/// synta.hmac_verify("sha256", key, data, expected_mac)
73/// # raises ValueError on mismatch
74/// ```
75#[pyfunction]
76pub fn hmac_verify(algorithm: &str, key: &[u8], data: &[u8], expected: &[u8]) -> PyResult<()> {
77    default_hmac_provider()
78        .hmac_verify(algorithm, key, data, expected)
79        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
80    Ok(())
81}
82
83// ── PBKDF2 ───────────────────────────────────────────────────────────────────
84
85/// Derive a key using PBKDF2-HMAC.
86///
87/// Returns ``length`` raw key bytes derived from ``password`` and ``salt``
88/// using the given HMAC ``algorithm`` and the specified ``iterations`` count.
89///
90/// ```python,ignore
91/// import synta
92///
93/// key = synta.pbkdf2_hmac("sha256", b"password", b"salt", 100_000, 32)
94/// ```
95#[pyfunction]
96pub fn pbkdf2_hmac<'py>(
97    py: Python<'py>,
98    algorithm: &str,
99    password: &[u8],
100    salt: &[u8],
101    iterations: u32,
102    length: usize,
103) -> PyResult<Bound<'py, PyBytes>> {
104    let out = default_pbkdf2_provider()
105        .pbkdf2_hmac(algorithm, password, salt, iterations as usize, length)
106        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
107    Ok(PyBytes::new(py, &out))
108}
109
110// ── AES-CBC ──────────────────────────────────────────────────────────────────
111
112/// Encrypt data using AES-CBC.
113///
114/// Key length determines the AES variant: 16 → AES-128, 24 → AES-192,
115/// 32 → AES-256.  ``iv`` must be 16 bytes.
116///
117/// When ``pad=True`` (default) PKCS#7 padding is applied automatically.
118/// When ``pad=False`` the caller is responsible for correct block alignment.
119///
120/// ```python,ignore
121/// import synta
122///
123/// ct = synta.aes_cbc_encrypt(key_32, iv_16, plaintext)
124/// ct_no_pad = synta.aes_cbc_encrypt(key_16, iv_16, aligned_data, pad=False)
125/// ```
126#[pyfunction]
127#[pyo3(signature = (key, iv, plaintext, pad = true))]
128pub fn aes_cbc_encrypt<'py>(
129    py: Python<'py>,
130    key: &[u8],
131    iv: &[u8],
132    plaintext: &[u8],
133    pad: bool,
134) -> PyResult<Bound<'py, PyBytes>> {
135    let ct = default_block_cipher_provider()
136        .aes_cbc_encrypt(key, iv, plaintext, pad)
137        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
138    Ok(PyBytes::new(py, &ct))
139}
140
141/// Decrypt data using AES-CBC.
142///
143/// Key length determines the AES variant: 16 → AES-128, 24 → AES-192,
144/// 32 → AES-256.  ``iv`` must be 16 bytes.
145///
146/// When ``unpad=True`` (default) PKCS#7 padding is stripped automatically.
147/// When ``unpad=False`` the raw decrypted bytes are returned.
148///
149/// ```python,ignore
150/// import synta
151///
152/// pt = synta.aes_cbc_decrypt(key_32, iv_16, ciphertext)
153/// pt_raw = synta.aes_cbc_decrypt(key_16, iv_16, ciphertext, unpad=False)
154/// ```
155#[pyfunction]
156#[pyo3(signature = (key, iv, ciphertext, unpad = true))]
157pub fn aes_cbc_decrypt<'py>(
158    py: Python<'py>,
159    key: &[u8],
160    iv: &[u8],
161    ciphertext: &[u8],
162    unpad: bool,
163) -> PyResult<Bound<'py, PyBytes>> {
164    let pt = default_block_cipher_provider()
165        .aes_cbc_decrypt(key, iv, ciphertext, unpad)
166        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
167    Ok(PyBytes::new(py, &pt))
168}
169
170// ── 3DES-EDE-CBC ─────────────────────────────────────────────────────────────
171
172/// Encrypt data using 3DES-EDE-CBC.
173///
174/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
175/// ``iv`` must be 8 bytes.
176///
177/// When ``pad=True`` (default) PKCS#7 padding is applied automatically.
178/// When ``pad=False`` the caller is responsible for correct block alignment.
179///
180/// ```python,ignore
181/// import synta
182///
183/// ct = synta.des3_cbc_encrypt(key_24, iv_8, plaintext)
184/// ```
185#[pyfunction]
186#[pyo3(signature = (key, iv, plaintext, pad = true))]
187pub fn des3_cbc_encrypt<'py>(
188    py: Python<'py>,
189    key: &[u8],
190    iv: &[u8],
191    plaintext: &[u8],
192    pad: bool,
193) -> PyResult<Bound<'py, PyBytes>> {
194    let ct = default_block_cipher_provider()
195        .des3_cbc_encrypt(key, iv, plaintext, pad)
196        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
197    Ok(PyBytes::new(py, &ct))
198}
199
200/// Decrypt data using 3DES-EDE-CBC.
201///
202/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
203/// ``iv`` must be 8 bytes.
204///
205/// When ``unpad=True`` (default) PKCS#7 padding is stripped automatically.
206/// When ``unpad=False`` raw decrypted bytes are returned.
207///
208/// ```python,ignore
209/// import synta
210///
211/// pt = synta.des3_cbc_decrypt(key_24, iv_8, ciphertext)
212/// ```
213#[pyfunction]
214#[pyo3(signature = (key, iv, ciphertext, unpad = true))]
215pub fn des3_cbc_decrypt<'py>(
216    py: Python<'py>,
217    key: &[u8],
218    iv: &[u8],
219    ciphertext: &[u8],
220    unpad: bool,
221) -> PyResult<Bound<'py, PyBytes>> {
222    let pt = default_block_cipher_provider()
223        .des3_cbc_decrypt(key, iv, ciphertext, unpad)
224        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
225    Ok(PyBytes::new(py, &pt))
226}
227
228// ── PKCS#7 padding ───────────────────────────────────────────────────────────
229
230/// Apply PKCS#7 padding to ``data``.
231///
232/// ``block_size`` is the cipher block size in bytes (16 for AES, 8 for 3DES).
233/// Raises :exc:`ValueError` if ``block_size`` is 0 or greater than 255.
234///
235/// ```python,ignore
236/// import synta
237///
238/// padded = synta.pkcs7_pad(b"hello", 8)   # → b"hello\x03\x03\x03"
239/// ```
240#[pyfunction]
241pub fn pkcs7_pad<'py>(
242    py: Python<'py>,
243    data: &[u8],
244    block_size: usize,
245) -> PyResult<Bound<'py, PyBytes>> {
246    if block_size == 0 || block_size > 255 {
247        return Err(PyValueError::new_err(
248            "block_size must be between 1 and 255",
249        ));
250    }
251    let pad_len = block_size - (data.len() % block_size);
252    let mut padded = Vec::with_capacity(data.len() + pad_len);
253    padded.extend_from_slice(data);
254    padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
255    Ok(PyBytes::new(py, &padded))
256}
257
258/// Remove PKCS#7 padding from ``data``.
259///
260/// Raises :exc:`ValueError` if the padding is missing or inconsistent.
261///
262/// ```python,ignore
263/// import synta
264///
265/// unpadded = synta.pkcs7_unpad(b"hello\x03\x03\x03", 8)   # → b"hello"
266/// ```
267#[pyfunction]
268pub fn pkcs7_unpad<'py>(
269    py: Python<'py>,
270    data: &[u8],
271    block_size: usize,
272) -> PyResult<Bound<'py, PyBytes>> {
273    if data.is_empty() {
274        return Err(PyValueError::new_err("data is empty"));
275    }
276    let pad_byte = *data.last().unwrap() as usize;
277    if pad_byte == 0 || pad_byte > block_size || pad_byte > data.len() {
278        return Err(PyValueError::new_err("invalid PKCS#7 padding"));
279    }
280    let pad_start = data.len() - pad_byte;
281    if data[pad_start..].iter().any(|&b| b as usize != pad_byte) {
282        return Err(PyValueError::new_err("inconsistent PKCS#7 padding bytes"));
283    }
284    Ok(PyBytes::new(py, &data[..pad_start]))
285}
286
287// ── Fernet ───────────────────────────────────────────────────────────────────
288
289/// Fernet symmetric authenticated encryption.
290///
291/// Implements the `Fernet specification <https://github.com/fernet/spec/blob/master/Spec.md>`_
292/// using AES-128-CBC encryption and HMAC-SHA256 authentication.  Tokens
293/// produced by this class are byte-for-byte compatible with those produced
294/// by ``cryptography.fernet.Fernet``.
295///
296/// **Key format:** a URL-safe base64-encoded string of 32 raw bytes, where
297/// the first 16 bytes are the signing key and the last 16 bytes are the
298/// encryption key.
299///
300/// **Token format (before base64url encoding):**
301///
302/// * 1 byte: version (``0x80``)
303/// * 8 bytes: timestamp (big-endian unsigned 64-bit Unix seconds)
304/// * 16 bytes: AES-CBC initialization vector
305/// * N bytes: AES-128-CBC ciphertext (PKCS#7-padded plaintext)
306/// * 32 bytes: HMAC-SHA256 of all preceding bytes
307///
308/// ```python,ignore
309/// import synta
310///
311/// key   = synta.Fernet.generate_key()
312/// fern  = synta.Fernet(key)
313/// token = fern.encrypt(b"secret data")
314/// plain = fern.decrypt(token)
315/// assert plain == b"secret data"
316///
317/// # TTL check (seconds):
318/// plain = fern.decrypt(token, ttl=300)
319/// ```
320#[pyclass(frozen, name = "Fernet")]
321pub struct PyFernet {
322    signing_key: [u8; 16],
323    encryption_key: [u8; 16],
324}
325
326#[pymethods]
327impl PyFernet {
328    /// Create a :class:`Fernet` instance from a URL-safe base64-encoded 32-byte key.
329    ///
330    /// ```python,ignore
331    /// key  = synta.Fernet.generate_key()
332    /// fern = synta.Fernet(key)
333    /// ```
334    #[new]
335    fn new(key: &[u8]) -> PyResult<Self> {
336        let raw = urlsafe_b64decode(key)?;
337        if raw.len() != 32 {
338            return Err(PyValueError::new_err(
339                "Fernet key must decode to exactly 32 bytes",
340            ));
341        }
342        let mut signing_key = [0u8; 16];
343        let mut encryption_key = [0u8; 16];
344        signing_key.copy_from_slice(&raw[0..16]);
345        encryption_key.copy_from_slice(&raw[16..32]);
346        Ok(Self {
347            signing_key,
348            encryption_key,
349        })
350    }
351
352    /// Generate a fresh random Fernet key.
353    ///
354    /// Returns 32 cryptographically random bytes encoded as URL-safe base64.
355    ///
356    /// ```python,ignore
357    /// key = synta.Fernet.generate_key()
358    /// fern = synta.Fernet(key)
359    /// ```
360    #[staticmethod]
361    fn generate_key<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
362        let mut raw = [0u8; 32];
363        default_secure_random()
364            .rand_bytes(&mut raw)
365            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
366        Ok(PyBytes::new(py, urlsafe_b64encode(&raw).as_bytes()))
367    }
368
369    /// Encrypt ``data`` and return a Fernet token as URL-safe base64 bytes.
370    ///
371    /// The token includes a timestamp set to the current UTC time; use
372    /// :meth:`decrypt` with a ``ttl`` argument to enforce expiry.
373    ///
374    /// ```python,ignore
375    /// token = fern.encrypt(b"hello")
376    /// ```
377    fn encrypt<'py>(&self, py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyBytes>> {
378        let rng = default_secure_random();
379        let cipher = default_block_cipher_provider();
380        let hmac = default_hmac_provider();
381
382        // Generate a random 16-byte IV.
383        let mut iv = [0u8; 16];
384        rng.rand_bytes(&mut iv)
385            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
386
387        // Current Unix timestamp (seconds).
388        let ts = std::time::SystemTime::now()
389            .duration_since(std::time::UNIX_EPOCH)
390            .map_err(|_| PyValueError::new_err("system clock error"))?
391            .as_secs();
392
393        // AES-128-CBC encrypt with PKCS#7 padding.
394        let ciphertext = cipher
395            .aes_cbc_encrypt(&self.encryption_key, &iv, data, true)
396            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
397
398        // Assemble the pre-MAC bytes: version(1) || ts_be(8) || iv(16) || ciphertext.
399        let mut pre_mac = Vec::with_capacity(1 + 8 + 16 + ciphertext.len());
400        pre_mac.push(0x80u8);
401        pre_mac.extend_from_slice(&ts.to_be_bytes());
402        pre_mac.extend_from_slice(&iv);
403        pre_mac.extend_from_slice(&ciphertext);
404
405        // HMAC-SHA256 over the pre-MAC bytes.
406        let mac = hmac
407            .hmac_compute("sha256", &self.signing_key, &pre_mac)
408            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
409
410        // Token = pre_mac || mac, base64url-encoded.
411        let mut token_bytes = pre_mac;
412        token_bytes.extend_from_slice(&mac);
413        Ok(PyBytes::new(py, urlsafe_b64encode(&token_bytes).as_bytes()))
414    }
415
416    /// Decrypt a Fernet ``token``, optionally enforcing a TTL.
417    ///
418    /// ``token`` must be URL-safe base64-encoded bytes as returned by
419    /// :meth:`encrypt`.
420    ///
421    /// If ``ttl`` is given (seconds), raises :exc:`ValueError` when the token
422    /// is older than ``ttl`` seconds relative to the current time.
423    ///
424    /// Raises :exc:`ValueError` on malformed tokens, unknown version bytes,
425    /// HMAC verification failure, or TTL expiry.
426    ///
427    /// ```python,ignore
428    /// plain = fern.decrypt(token)
429    /// plain = fern.decrypt(token, ttl=300)
430    /// ```
431    #[pyo3(signature = (token, ttl = None))]
432    fn decrypt<'py>(
433        &self,
434        py: Python<'py>,
435        token: &[u8],
436        ttl: Option<u64>,
437    ) -> PyResult<Bound<'py, PyBytes>> {
438        let hmac = default_hmac_provider();
439        let cipher = default_block_cipher_provider();
440
441        // Decode URL-safe base64.
442        let raw = urlsafe_b64decode(token)?;
443
444        // Minimum token length: version(1) + ts(8) + iv(16) + one AES block(16) + hmac(32) = 73.
445        if raw.len() < 1 + 8 + 16 + 16 + 32 {
446            return Err(PyValueError::new_err("invalid Fernet token: too short"));
447        }
448
449        // Check the version byte.
450        if raw[0] != 0x80 {
451            return Err(PyValueError::new_err(
452                "invalid Fernet token: unknown version byte",
453            ));
454        }
455
456        // Extract fields.
457        let ts = u64::from_be_bytes(
458            raw[1..9]
459                .try_into()
460                .map_err(|_| PyValueError::new_err("internal error: Fernet timestamp slice"))?,
461        );
462        let iv = &raw[9..25];
463        let ciphertext = &raw[25..raw.len() - 32];
464        let token_mac = &raw[raw.len() - 32..];
465
466        // Verify HMAC-SHA256 in constant time.
467        let pre_mac = &raw[..raw.len() - 32];
468        let computed_mac = hmac
469            .hmac_compute("sha256", &self.signing_key, pre_mac)
470            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
471
472        if !synta_certificate::constant_time_eq(&computed_mac, token_mac) {
473            return Err(PyValueError::new_err(
474                "invalid Fernet token: HMAC verification failed",
475            ));
476        }
477
478        // TTL check (done after HMAC so we don't leak timing info about bad tokens).
479        if let Some(ttl_secs) = ttl {
480            let now = std::time::SystemTime::now()
481                .duration_since(std::time::UNIX_EPOCH)
482                .map_err(|_| PyValueError::new_err("system clock error"))?
483                .as_secs();
484            // Reject tokens issued in the future (60-second clock-skew tolerance).
485            const MAX_CLOCK_SKEW: u64 = 60;
486            if ts > now.saturating_add(MAX_CLOCK_SKEW) {
487                return Err(PyValueError::new_err(
488                    "Fernet token timestamp is in the future",
489                ));
490            }
491            if now.saturating_sub(ts) > ttl_secs {
492                return Err(PyValueError::new_err("Fernet token has expired"));
493            }
494        }
495
496        // AES-128-CBC decrypt with PKCS#7 unpadding.
497        let plaintext = cipher
498            .aes_cbc_decrypt(&self.encryption_key, iv, ciphertext, true)
499            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
500
501        Ok(PyBytes::new(py, &plaintext))
502    }
503}
504
505// ── HKDF ─────────────────────────────────────────────────────────────────────
506
507/// HKDF-Extract (RFC 5869 §2.2).
508///
509/// Returns the pseudorandom key ``PRK = HMAC-Hash(salt, ikm)``.
510///
511/// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
512/// When ``salt`` is ``None`` or omitted, the default salt is a string of
513/// ``HashLen`` zero bytes as specified by RFC 5869.
514///
515/// ```python,ignore
516/// import synta
517///
518/// prk = synta.hkdf_extract("sha256", b"salt", b"input keying material")
519/// # or with default salt:
520/// prk = synta.hkdf_extract("sha256", None, b"ikm")
521/// ```
522#[pyfunction]
523#[pyo3(signature = (algorithm, salt, ikm))]
524pub fn hkdf_extract_py<'py>(
525    py: Python<'py>,
526    algorithm: &str,
527    salt: Option<&[u8]>,
528    ikm: &[u8],
529) -> PyResult<Bound<'py, PyBytes>> {
530    let provider = default_hmac_provider();
531    let prk = hkdf_extract(&provider, algorithm, salt, ikm)
532        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
533    Ok(PyBytes::new(py, &prk))
534}
535
536/// HKDF-Expand (RFC 5869 §2.3).
537///
538/// Expands a pseudorandom key ``prk`` (e.g. from :func:`hkdf_extract`) into
539/// ``length`` bytes of output keying material using ``info`` as a context label.
540///
541/// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
542/// ``length`` must be ≤ 255 × HashLen.
543///
544/// ```python,ignore
545/// import synta
546///
547/// prk = synta.hkdf_extract("sha256", b"salt", b"ikm")
548/// okm = synta.hkdf_expand("sha256", prk, b"application label", 32)
549/// ```
550#[pyfunction]
551pub fn hkdf_expand_py<'py>(
552    py: Python<'py>,
553    algorithm: &str,
554    prk: &[u8],
555    info: &[u8],
556    length: usize,
557) -> PyResult<Bound<'py, PyBytes>> {
558    if length == 0 {
559        return Ok(PyBytes::new(py, b""));
560    }
561    let hash_len = hmac_output_len(algorithm).ok_or_else(|| {
562        PyValueError::new_err(format!(
563            "unknown HKDF algorithm: {algorithm:?} \
564             (accepted: md5, sha1, sha224, sha256, sha384, sha512)"
565        ))
566    })?;
567    if length > 255 * hash_len {
568        return Err(PyValueError::new_err(format!(
569            "HKDF-Expand length {length} exceeds maximum {} for {algorithm}",
570            255 * hash_len
571        )));
572    }
573    let provider = default_hmac_provider();
574    let okm = hkdf_expand(&provider, algorithm, prk, info, length)
575        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
576    Ok(PyBytes::new(py, &okm))
577}
578
579// ── Streaming HMAC ────────────────────────────────────────────────────────────
580
581/// Incremental HMAC computation.
582///
583/// Feed data in chunks with :meth:`update`, then call :meth:`finalize` to
584/// obtain the MAC bytes.  The object is consumed by :meth:`finalize` and
585/// cannot be reused.
586///
587/// ```python,ignore
588/// import synta
589///
590/// h = synta.HmacDigest("sha256", b"secret-key")
591/// h.update(b"chunk 1")
592/// h.update(b"chunk 2")
593/// mac = h.finalize()
594/// ```
595#[pyclass(name = "HmacDigest")]
596pub struct PyHmacDigest {
597    // Mutex makes Box<dyn HmacState> (which is Send but not Sync) meet
598    // PyO3's Send + Sync requirement for #[pyclass].
599    state: std::sync::Mutex<Option<Box<dyn HmacState>>>,
600    algorithm: String,
601}
602
603#[pymethods]
604impl PyHmacDigest {
605    /// Create a new streaming HMAC computation.
606    ///
607    /// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
608    #[new]
609    fn new(algorithm: &str, key: &[u8]) -> PyResult<Self> {
610        let provider = default_streaming_hmac_provider();
611        let state = provider
612            .new_hmac(algorithm, key)
613            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
614        Ok(PyHmacDigest {
615            state: std::sync::Mutex::new(Some(state)),
616            algorithm: algorithm.to_string(),
617        })
618    }
619
620    /// Feed ``data`` into the running HMAC computation.
621    ///
622    /// Raises :exc:`ValueError` if called after :meth:`finalize`.
623    fn update(&self, data: &[u8]) -> PyResult<()> {
624        let mut guard = self.state.lock().unwrap();
625        match guard.as_mut() {
626            Some(s) => {
627                s.update(data);
628                Ok(())
629            }
630            None => Err(PyValueError::new_err(
631                "HmacDigest.update() called after finalize()",
632            )),
633        }
634    }
635
636    /// Consume the state and return the raw MAC bytes.
637    ///
638    /// Raises :exc:`ValueError` if called more than once.
639    fn finalize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
640        let mut guard = self.state.lock().unwrap();
641        match guard.take() {
642            Some(s) => Ok(PyBytes::new(py, &s.finalize_boxed())),
643            None => Err(PyValueError::new_err(
644                "HmacDigest.finalize() called more than once",
645            )),
646        }
647    }
648
649    fn __repr__(&self) -> String {
650        let finalized = self.state.lock().unwrap().is_none();
651        format!(
652            "HmacDigest(algorithm={:?}, finalized={finalized})",
653            self.algorithm,
654        )
655    }
656}
657
658// ── Submodule registration ────────────────────────────────────────────────────
659
660/// Register the ``synta.crypto`` submodule.
661///
662/// Exposes all symmetric-crypto primitives under ``synta.crypto``:
663///
664/// * :class:`Fernet` — authenticated encryption (AES-128-CBC + HMAC-SHA256)
665/// * :func:`hmac_digest` / :func:`hmac_verify` — HMAC generation and verification
666/// * :func:`pbkdf2_hmac` — PBKDF2 key derivation
667/// * :func:`aes_cbc_encrypt` / :func:`aes_cbc_decrypt` — AES-CBC
668/// * :func:`des3_cbc_encrypt` / :func:`des3_cbc_decrypt` — 3DES-EDE-CBC
669/// * :func:`pkcs7_pad` / :func:`pkcs7_unpad` — PKCS#7 block padding helpers
670pub fn register_crypto_module(parent: &Bound<'_, PyModule>) -> PyResult<()> {
671    use pyo3::prelude::*;
672    let py = parent.py();
673    let m = PyModule::new(py, "crypto")?;
674
675    m.add_class::<PyFernet>()?;
676    m.add_class::<PyHmacDigest>()?;
677    m.add_function(wrap_pyfunction!(hmac_digest, &m)?)?;
678    m.add_function(wrap_pyfunction!(hmac_verify, &m)?)?;
679    m.add_function(wrap_pyfunction!(hkdf_extract_py, &m)?)?;
680    m.add_function(wrap_pyfunction!(hkdf_expand_py, &m)?)?;
681    m.add_function(wrap_pyfunction!(pbkdf2_hmac, &m)?)?;
682    m.add_function(wrap_pyfunction!(aes_cbc_encrypt, &m)?)?;
683    m.add_function(wrap_pyfunction!(aes_cbc_decrypt, &m)?)?;
684    m.add_function(wrap_pyfunction!(des3_cbc_encrypt, &m)?)?;
685    m.add_function(wrap_pyfunction!(des3_cbc_decrypt, &m)?)?;
686    m.add_function(wrap_pyfunction!(pkcs7_pad, &m)?)?;
687    m.add_function(wrap_pyfunction!(pkcs7_unpad, &m)?)?;
688    m.add_class::<crate::otp::PyHOTP>()?;
689    m.add_class::<crate::otp::PyTOTP>()?;
690
691    crate::install_submodule(
692        parent,
693        &m,
694        "synta.crypto",
695        Some(concat!(
696            "Symmetric cryptography primitives: HMAC, PBKDF2, AES-CBC, 3DES-CBC, ",
697            "PKCS#7 padding, and Fernet authenticated encryption (RFC-compatible ",
698            "with the Python ``cryptography`` library).",
699        )),
700    )
701}