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// ── AES-GCM ──────────────────────────────────────────────────────────────────
171
172/// Encrypt data using AES-GCM (authenticated encryption with associated data).
173///
174/// Key length determines the AES variant: 16 → AES-128, 24 → AES-192,
175/// 32 → AES-256.  ``nonce`` must be exactly 12 bytes.
176///
177/// Returns ``ciphertext ‖ tag`` where the authentication tag is 16 bytes.
178/// ``aad`` (additional authenticated data) is authenticated but not encrypted;
179/// pass an empty :class:`bytes` object when no AAD is needed.
180///
181/// The output format is identical to
182/// ``cryptography.hazmat.primitives.ciphers.aead.AESGCM(key).encrypt(nonce, plaintext, aad)``.
183///
184/// ```python,ignore
185/// import synta.crypto as sc, os
186///
187/// key   = os.urandom(32)
188/// nonce = os.urandom(12)
189/// ct    = sc.aes_gcm_encrypt(key, nonce, b"hello", b"extra-data")
190/// ```
191#[pyfunction]
192#[pyo3(signature = (key, nonce, plaintext, aad = None))]
193pub fn aes_gcm_encrypt<'py>(
194    py: Python<'py>,
195    key: &[u8],
196    nonce: &[u8],
197    plaintext: &[u8],
198    aad: Option<&[u8]>,
199) -> PyResult<Bound<'py, PyBytes>> {
200    let ct = default_block_cipher_provider()
201        .aes_gcm_encrypt(key, nonce, plaintext, aad.unwrap_or(&[]))
202        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
203    Ok(PyBytes::new(py, &ct))
204}
205
206/// Decrypt and verify data using AES-GCM.
207///
208/// ``ciphertext_with_tag`` must be the output of :func:`aes_gcm_encrypt`:
209/// ciphertext followed by a 16-byte authentication tag.  ``nonce`` and
210/// ``aad`` must match those used during encryption.
211///
212/// Raises :exc:`ValueError` if the authentication tag does not verify.
213///
214/// ```python,ignore
215/// import synta.crypto as sc
216///
217/// pt = sc.aes_gcm_decrypt(key, nonce, ciphertext_with_tag, b"extra-data")
218/// ```
219#[pyfunction]
220#[pyo3(signature = (key, nonce, ciphertext_with_tag, aad = None))]
221pub fn aes_gcm_decrypt<'py>(
222    py: Python<'py>,
223    key: &[u8],
224    nonce: &[u8],
225    ciphertext_with_tag: &[u8],
226    aad: Option<&[u8]>,
227) -> PyResult<Bound<'py, PyBytes>> {
228    let pt = default_block_cipher_provider()
229        .aes_gcm_decrypt(key, nonce, ciphertext_with_tag, aad.unwrap_or(&[]))
230        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
231    Ok(PyBytes::new(py, &pt))
232}
233
234// ── 3DES-EDE-CBC ─────────────────────────────────────────────────────────────
235
236/// Encrypt data using 3DES-EDE-CBC.
237///
238/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
239/// ``iv`` must be 8 bytes.
240///
241/// When ``pad=True`` (default) PKCS#7 padding is applied automatically.
242/// When ``pad=False`` the caller is responsible for correct block alignment.
243///
244/// ```python,ignore
245/// import synta
246///
247/// ct = synta.des3_cbc_encrypt(key_24, iv_8, plaintext)
248/// ```
249#[pyfunction]
250#[pyo3(signature = (key, iv, plaintext, pad = true))]
251pub fn des3_cbc_encrypt<'py>(
252    py: Python<'py>,
253    key: &[u8],
254    iv: &[u8],
255    plaintext: &[u8],
256    pad: bool,
257) -> PyResult<Bound<'py, PyBytes>> {
258    let ct = default_block_cipher_provider()
259        .des3_cbc_encrypt(key, iv, plaintext, pad)
260        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
261    Ok(PyBytes::new(py, &ct))
262}
263
264/// Decrypt data using 3DES-EDE-CBC.
265///
266/// ``key`` must be 16 bytes (two-key 3DES) or 24 bytes (three-key 3DES).
267/// ``iv`` must be 8 bytes.
268///
269/// When ``unpad=True`` (default) PKCS#7 padding is stripped automatically.
270/// When ``unpad=False`` raw decrypted bytes are returned.
271///
272/// ```python,ignore
273/// import synta
274///
275/// pt = synta.des3_cbc_decrypt(key_24, iv_8, ciphertext)
276/// ```
277#[pyfunction]
278#[pyo3(signature = (key, iv, ciphertext, unpad = true))]
279pub fn des3_cbc_decrypt<'py>(
280    py: Python<'py>,
281    key: &[u8],
282    iv: &[u8],
283    ciphertext: &[u8],
284    unpad: bool,
285) -> PyResult<Bound<'py, PyBytes>> {
286    let pt = default_block_cipher_provider()
287        .des3_cbc_decrypt(key, iv, ciphertext, unpad)
288        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
289    Ok(PyBytes::new(py, &pt))
290}
291
292// ── PKCS#7 padding ───────────────────────────────────────────────────────────
293
294/// Apply PKCS#7 padding to ``data``.
295///
296/// ``block_size`` is the cipher block size in bytes (16 for AES, 8 for 3DES).
297/// Raises :exc:`ValueError` if ``block_size`` is 0 or greater than 255.
298///
299/// ```python,ignore
300/// import synta
301///
302/// padded = synta.pkcs7_pad(b"hello", 8)   # → b"hello\x03\x03\x03"
303/// ```
304#[pyfunction]
305pub fn pkcs7_pad<'py>(
306    py: Python<'py>,
307    data: &[u8],
308    block_size: usize,
309) -> PyResult<Bound<'py, PyBytes>> {
310    if block_size == 0 || block_size > 255 {
311        return Err(PyValueError::new_err(
312            "block_size must be between 1 and 255",
313        ));
314    }
315    let pad_len = block_size - (data.len() % block_size);
316    let mut padded = Vec::with_capacity(data.len() + pad_len);
317    padded.extend_from_slice(data);
318    padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
319    Ok(PyBytes::new(py, &padded))
320}
321
322/// Remove PKCS#7 padding from ``data``.
323///
324/// Raises :exc:`ValueError` if the padding is missing or inconsistent.
325///
326/// ```python,ignore
327/// import synta
328///
329/// unpadded = synta.pkcs7_unpad(b"hello\x03\x03\x03", 8)   # → b"hello"
330/// ```
331#[pyfunction]
332pub fn pkcs7_unpad<'py>(
333    py: Python<'py>,
334    data: &[u8],
335    block_size: usize,
336) -> PyResult<Bound<'py, PyBytes>> {
337    if data.is_empty() {
338        return Err(PyValueError::new_err("data is empty"));
339    }
340    let pad_byte = *data.last().unwrap() as usize;
341    if pad_byte == 0 || pad_byte > block_size || pad_byte > data.len() {
342        return Err(PyValueError::new_err("invalid PKCS#7 padding"));
343    }
344    let pad_start = data.len() - pad_byte;
345    if data[pad_start..].iter().any(|&b| b as usize != pad_byte) {
346        return Err(PyValueError::new_err("inconsistent PKCS#7 padding bytes"));
347    }
348    Ok(PyBytes::new(py, &data[..pad_start]))
349}
350
351// ── Fernet ───────────────────────────────────────────────────────────────────
352
353/// Fernet symmetric authenticated encryption.
354///
355/// Implements the `Fernet specification <https://github.com/fernet/spec/blob/master/Spec.md>`_
356/// using AES-128-CBC encryption and HMAC-SHA256 authentication.  Tokens
357/// produced by this class are byte-for-byte compatible with those produced
358/// by ``cryptography.fernet.Fernet``.
359///
360/// **Key format:** a URL-safe base64-encoded string of 32 raw bytes, where
361/// the first 16 bytes are the signing key and the last 16 bytes are the
362/// encryption key.
363///
364/// **Token format (before base64url encoding):**
365///
366/// * 1 byte: version (``0x80``)
367/// * 8 bytes: timestamp (big-endian unsigned 64-bit Unix seconds)
368/// * 16 bytes: AES-CBC initialization vector
369/// * N bytes: AES-128-CBC ciphertext (PKCS#7-padded plaintext)
370/// * 32 bytes: HMAC-SHA256 of all preceding bytes
371///
372/// ```python,ignore
373/// import synta
374///
375/// key   = synta.Fernet.generate_key()
376/// fern  = synta.Fernet(key)
377/// token = fern.encrypt(b"secret data")
378/// plain = fern.decrypt(token)
379/// assert plain == b"secret data"
380///
381/// # TTL check (seconds):
382/// plain = fern.decrypt(token, ttl=300)
383/// ```
384#[pyclass(frozen, name = "Fernet")]
385pub struct PyFernet {
386    signing_key: [u8; 16],
387    encryption_key: [u8; 16],
388}
389
390#[pymethods]
391impl PyFernet {
392    /// Create a :class:`Fernet` instance from a URL-safe base64-encoded 32-byte key.
393    ///
394    /// ```python,ignore
395    /// key  = synta.Fernet.generate_key()
396    /// fern = synta.Fernet(key)
397    /// ```
398    #[new]
399    fn new(key: &[u8]) -> PyResult<Self> {
400        let raw = urlsafe_b64decode(key)?;
401        if raw.len() != 32 {
402            return Err(PyValueError::new_err(
403                "Fernet key must decode to exactly 32 bytes",
404            ));
405        }
406        let mut signing_key = [0u8; 16];
407        let mut encryption_key = [0u8; 16];
408        signing_key.copy_from_slice(&raw[0..16]);
409        encryption_key.copy_from_slice(&raw[16..32]);
410        Ok(Self {
411            signing_key,
412            encryption_key,
413        })
414    }
415
416    /// Generate a fresh random Fernet key.
417    ///
418    /// Returns 32 cryptographically random bytes encoded as URL-safe base64.
419    ///
420    /// ```python,ignore
421    /// key = synta.Fernet.generate_key()
422    /// fern = synta.Fernet(key)
423    /// ```
424    #[staticmethod]
425    fn generate_key<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
426        let mut raw = [0u8; 32];
427        default_secure_random()
428            .rand_bytes(&mut raw)
429            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
430        Ok(PyBytes::new(py, urlsafe_b64encode(&raw).as_bytes()))
431    }
432
433    /// Encrypt ``data`` and return a Fernet token as URL-safe base64 bytes.
434    ///
435    /// The token includes a timestamp set to the current UTC time; use
436    /// :meth:`decrypt` with a ``ttl`` argument to enforce expiry.
437    ///
438    /// ```python,ignore
439    /// token = fern.encrypt(b"hello")
440    /// ```
441    fn encrypt<'py>(&self, py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyBytes>> {
442        let rng = default_secure_random();
443        let cipher = default_block_cipher_provider();
444        let hmac = default_hmac_provider();
445
446        // Generate a random 16-byte IV.
447        let mut iv = [0u8; 16];
448        rng.rand_bytes(&mut iv)
449            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
450
451        // Current Unix timestamp (seconds).
452        let ts = std::time::SystemTime::now()
453            .duration_since(std::time::UNIX_EPOCH)
454            .map_err(|_| PyValueError::new_err("system clock error"))?
455            .as_secs();
456
457        // AES-128-CBC encrypt with PKCS#7 padding.
458        let ciphertext = cipher
459            .aes_cbc_encrypt(&self.encryption_key, &iv, data, true)
460            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
461
462        // Assemble the pre-MAC bytes: version(1) || ts_be(8) || iv(16) || ciphertext.
463        let mut pre_mac = Vec::with_capacity(1 + 8 + 16 + ciphertext.len());
464        pre_mac.push(0x80u8);
465        pre_mac.extend_from_slice(&ts.to_be_bytes());
466        pre_mac.extend_from_slice(&iv);
467        pre_mac.extend_from_slice(&ciphertext);
468
469        // HMAC-SHA256 over the pre-MAC bytes.
470        let mac = hmac
471            .hmac_compute("sha256", &self.signing_key, &pre_mac)
472            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
473
474        // Token = pre_mac || mac, base64url-encoded.
475        let mut token_bytes = pre_mac;
476        token_bytes.extend_from_slice(&mac);
477        Ok(PyBytes::new(py, urlsafe_b64encode(&token_bytes).as_bytes()))
478    }
479
480    /// Decrypt a Fernet ``token``, optionally enforcing a TTL.
481    ///
482    /// ``token`` must be URL-safe base64-encoded bytes as returned by
483    /// :meth:`encrypt`.
484    ///
485    /// If ``ttl`` is given (seconds), raises :exc:`ValueError` when the token
486    /// is older than ``ttl`` seconds relative to the current time.
487    ///
488    /// Raises :exc:`ValueError` on malformed tokens, unknown version bytes,
489    /// HMAC verification failure, or TTL expiry.
490    ///
491    /// ```python,ignore
492    /// plain = fern.decrypt(token)
493    /// plain = fern.decrypt(token, ttl=300)
494    /// ```
495    #[pyo3(signature = (token, ttl = None))]
496    fn decrypt<'py>(
497        &self,
498        py: Python<'py>,
499        token: &[u8],
500        ttl: Option<u64>,
501    ) -> PyResult<Bound<'py, PyBytes>> {
502        let hmac = default_hmac_provider();
503        let cipher = default_block_cipher_provider();
504
505        // Decode URL-safe base64.
506        let raw = urlsafe_b64decode(token)?;
507
508        // Minimum token length: version(1) + ts(8) + iv(16) + one AES block(16) + hmac(32) = 73.
509        if raw.len() < 1 + 8 + 16 + 16 + 32 {
510            return Err(PyValueError::new_err("invalid Fernet token: too short"));
511        }
512
513        // Check the version byte.
514        if raw[0] != 0x80 {
515            return Err(PyValueError::new_err(
516                "invalid Fernet token: unknown version byte",
517            ));
518        }
519
520        // Extract fields.
521        let ts = u64::from_be_bytes(
522            raw[1..9]
523                .try_into()
524                .map_err(|_| PyValueError::new_err("internal error: Fernet timestamp slice"))?,
525        );
526        let iv = &raw[9..25];
527        let ciphertext = &raw[25..raw.len() - 32];
528        let token_mac = &raw[raw.len() - 32..];
529
530        // Verify HMAC-SHA256 in constant time.
531        let pre_mac = &raw[..raw.len() - 32];
532        let computed_mac = hmac
533            .hmac_compute("sha256", &self.signing_key, pre_mac)
534            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
535
536        if !synta_certificate::constant_time_eq(&computed_mac, token_mac) {
537            return Err(PyValueError::new_err(
538                "invalid Fernet token: HMAC verification failed",
539            ));
540        }
541
542        // TTL check (done after HMAC so we don't leak timing info about bad tokens).
543        if let Some(ttl_secs) = ttl {
544            let now = std::time::SystemTime::now()
545                .duration_since(std::time::UNIX_EPOCH)
546                .map_err(|_| PyValueError::new_err("system clock error"))?
547                .as_secs();
548            // Reject tokens issued in the future (60-second clock-skew tolerance).
549            const MAX_CLOCK_SKEW: u64 = 60;
550            if ts > now.saturating_add(MAX_CLOCK_SKEW) {
551                return Err(PyValueError::new_err(
552                    "Fernet token timestamp is in the future",
553                ));
554            }
555            if now.saturating_sub(ts) > ttl_secs {
556                return Err(PyValueError::new_err("Fernet token has expired"));
557            }
558        }
559
560        // AES-128-CBC decrypt with PKCS#7 unpadding.
561        let plaintext = cipher
562            .aes_cbc_decrypt(&self.encryption_key, iv, ciphertext, true)
563            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
564
565        Ok(PyBytes::new(py, &plaintext))
566    }
567}
568
569// ── HKDF ─────────────────────────────────────────────────────────────────────
570
571/// HKDF-Extract (RFC 5869 §2.2).
572///
573/// Returns the pseudorandom key ``PRK = HMAC-Hash(salt, ikm)``.
574///
575/// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
576/// When ``salt`` is ``None`` or omitted, the default salt is a string of
577/// ``HashLen`` zero bytes as specified by RFC 5869.
578///
579/// ```python,ignore
580/// import synta
581///
582/// prk = synta.hkdf_extract("sha256", b"salt", b"input keying material")
583/// # or with default salt:
584/// prk = synta.hkdf_extract("sha256", None, b"ikm")
585/// ```
586#[pyfunction]
587#[pyo3(signature = (algorithm, salt, ikm))]
588pub fn hkdf_extract_py<'py>(
589    py: Python<'py>,
590    algorithm: &str,
591    salt: Option<&[u8]>,
592    ikm: &[u8],
593) -> PyResult<Bound<'py, PyBytes>> {
594    let provider = default_hmac_provider();
595    let prk = hkdf_extract(&provider, algorithm, salt, ikm)
596        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
597    Ok(PyBytes::new(py, &prk))
598}
599
600/// HKDF-Expand (RFC 5869 §2.3).
601///
602/// Expands a pseudorandom key ``prk`` (e.g. from :func:`hkdf_extract`) into
603/// ``length`` bytes of output keying material using ``info`` as a context label.
604///
605/// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
606/// ``length`` must be ≤ 255 × HashLen.
607///
608/// ```python,ignore
609/// import synta
610///
611/// prk = synta.hkdf_extract("sha256", b"salt", b"ikm")
612/// okm = synta.hkdf_expand("sha256", prk, b"application label", 32)
613/// ```
614#[pyfunction]
615pub fn hkdf_expand_py<'py>(
616    py: Python<'py>,
617    algorithm: &str,
618    prk: &[u8],
619    info: &[u8],
620    length: usize,
621) -> PyResult<Bound<'py, PyBytes>> {
622    if length == 0 {
623        return Ok(PyBytes::new(py, b""));
624    }
625    let hash_len = hmac_output_len(algorithm).ok_or_else(|| {
626        PyValueError::new_err(format!(
627            "unknown HKDF algorithm: {algorithm:?} \
628             (accepted: md5, sha1, sha224, sha256, sha384, sha512)"
629        ))
630    })?;
631    if length > 255 * hash_len {
632        return Err(PyValueError::new_err(format!(
633            "HKDF-Expand length {length} exceeds maximum {} for {algorithm}",
634            255 * hash_len
635        )));
636    }
637    let provider = default_hmac_provider();
638    let okm = hkdf_expand(&provider, algorithm, prk, info, length)
639        .map_err(|e| PyValueError::new_err(format!("{e}")))?;
640    Ok(PyBytes::new(py, &okm))
641}
642
643// ── Streaming HMAC ────────────────────────────────────────────────────────────
644
645/// Incremental HMAC computation.
646///
647/// Feed data in chunks with :meth:`update`, then call :meth:`finalize` to
648/// obtain the MAC bytes.  The object is consumed by :meth:`finalize` and
649/// cannot be reused.
650///
651/// ```python,ignore
652/// import synta
653///
654/// h = synta.HmacDigest("sha256", b"secret-key")
655/// h.update(b"chunk 1")
656/// h.update(b"chunk 2")
657/// mac = h.finalize()
658/// ```
659#[pyclass(name = "HmacDigest")]
660pub struct PyHmacDigest {
661    // Mutex makes Box<dyn HmacState> (which is Send but not Sync) meet
662    // PyO3's Send + Sync requirement for #[pyclass].
663    state: std::sync::Mutex<Option<Box<dyn HmacState>>>,
664    algorithm: String,
665}
666
667#[pymethods]
668impl PyHmacDigest {
669    /// Create a new streaming HMAC computation.
670    ///
671    /// ``algorithm`` must be one of ``"sha256"``, ``"sha384"``, ``"sha512"``, etc.
672    #[new]
673    fn new(algorithm: &str, key: &[u8]) -> PyResult<Self> {
674        let provider = default_streaming_hmac_provider();
675        let state = provider
676            .new_hmac(algorithm, key)
677            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
678        Ok(PyHmacDigest {
679            state: std::sync::Mutex::new(Some(state)),
680            algorithm: algorithm.to_string(),
681        })
682    }
683
684    /// Feed ``data`` into the running HMAC computation.
685    ///
686    /// Raises :exc:`ValueError` if called after :meth:`finalize`.
687    fn update(&self, data: &[u8]) -> PyResult<()> {
688        let mut guard = self.state.lock().unwrap();
689        match guard.as_mut() {
690            Some(s) => {
691                s.update(data);
692                Ok(())
693            }
694            None => Err(PyValueError::new_err(
695                "HmacDigest.update() called after finalize()",
696            )),
697        }
698    }
699
700    /// Consume the state and return the raw MAC bytes.
701    ///
702    /// Raises :exc:`ValueError` if called more than once.
703    fn finalize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
704        let mut guard = self.state.lock().unwrap();
705        match guard.take() {
706            Some(s) => Ok(PyBytes::new(py, &s.finalize_boxed())),
707            None => Err(PyValueError::new_err(
708                "HmacDigest.finalize() called more than once",
709            )),
710        }
711    }
712
713    fn __repr__(&self) -> String {
714        let finalized = self.state.lock().unwrap().is_none();
715        format!(
716            "HmacDigest(algorithm={:?}, finalized={finalized})",
717            self.algorithm,
718        )
719    }
720}
721
722// ── Submodule registration ────────────────────────────────────────────────────
723
724/// Register the ``synta.crypto`` submodule.
725///
726/// Exposes all symmetric-crypto primitives under ``synta.crypto``:
727///
728/// * :class:`Fernet` — authenticated encryption (AES-128-CBC + HMAC-SHA256)
729/// * :func:`hmac_digest` / :func:`hmac_verify` — HMAC generation and verification
730/// * :func:`pbkdf2_hmac` — PBKDF2 key derivation
731/// * :func:`aes_cbc_encrypt` / :func:`aes_cbc_decrypt` — AES-CBC
732/// * :func:`aes_gcm_encrypt` / :func:`aes_gcm_decrypt` — AES-GCM (AEAD)
733/// * :func:`des3_cbc_encrypt` / :func:`des3_cbc_decrypt` — 3DES-EDE-CBC
734/// * :func:`pkcs7_pad` / :func:`pkcs7_unpad` — PKCS#7 block padding helpers
735pub fn register_crypto_module(parent: &Bound<'_, PyModule>) -> PyResult<()> {
736    use pyo3::prelude::*;
737    let py = parent.py();
738    let m = PyModule::new(py, "crypto")?;
739
740    m.add_class::<PyFernet>()?;
741    m.add_class::<PyHmacDigest>()?;
742    m.add_function(wrap_pyfunction!(hmac_digest, &m)?)?;
743    m.add_function(wrap_pyfunction!(hmac_verify, &m)?)?;
744    m.add_function(wrap_pyfunction!(hkdf_extract_py, &m)?)?;
745    m.add_function(wrap_pyfunction!(hkdf_expand_py, &m)?)?;
746    m.add_function(wrap_pyfunction!(pbkdf2_hmac, &m)?)?;
747    m.add_function(wrap_pyfunction!(aes_cbc_encrypt, &m)?)?;
748    m.add_function(wrap_pyfunction!(aes_cbc_decrypt, &m)?)?;
749    m.add_function(wrap_pyfunction!(aes_gcm_encrypt, &m)?)?;
750    m.add_function(wrap_pyfunction!(aes_gcm_decrypt, &m)?)?;
751    m.add_function(wrap_pyfunction!(des3_cbc_encrypt, &m)?)?;
752    m.add_function(wrap_pyfunction!(des3_cbc_decrypt, &m)?)?;
753    m.add_function(wrap_pyfunction!(pkcs7_pad, &m)?)?;
754    m.add_function(wrap_pyfunction!(pkcs7_unpad, &m)?)?;
755    m.add_class::<crate::otp::PyHOTP>()?;
756    m.add_class::<crate::otp::PyTOTP>()?;
757
758    crate::install_submodule(
759        parent,
760        &m,
761        "synta.crypto",
762        Some(concat!(
763            "Symmetric cryptography primitives: HMAC, PBKDF2, AES-CBC, AES-GCM, ",
764            "3DES-CBC, PKCS#7 padding, and Fernet authenticated encryption ",
765            "(RFC-compatible with the Python ``cryptography`` library).",
766        )),
767    )
768}