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