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