_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}