From c9a5682a26bb40ec8cedc6c01bc6b186e3918d98 Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Sat, 1 Feb 2025 10:12:29 -0800 Subject: [PATCH] Minimal PoC for putting pyOpenSSL functionality in Rust --- .../hazmat/bindings/_rust/pyopenssl.pyi | 23 ++++++ src/rust/src/lib.rs | 38 ++++++++++ src/rust/src/pyopenssl/error.rs | 68 +++++++++++++++++ src/rust/src/pyopenssl/mod.rs | 6 ++ src/rust/src/pyopenssl/ssl.rs | 75 +++++++++++++++++++ src/rust/src/types.rs | 3 + tests/pyopenssl/__init__.py | 3 + tests/pyopenssl/test_ssl.py | 37 +++++++++ 8 files changed, 253 insertions(+) create mode 100644 src/cryptography/hazmat/bindings/_rust/pyopenssl.pyi create mode 100644 src/rust/src/pyopenssl/error.rs create mode 100644 src/rust/src/pyopenssl/mod.rs create mode 100644 src/rust/src/pyopenssl/ssl.rs create mode 100644 tests/pyopenssl/__init__.py create mode 100644 tests/pyopenssl/test_ssl.py diff --git a/src/cryptography/hazmat/bindings/_rust/pyopenssl.pyi b/src/cryptography/hazmat/bindings/_rust/pyopenssl.pyi new file mode 100644 index 000000000000..cd8b1fac45bf --- /dev/null +++ b/src/cryptography/hazmat/bindings/_rust/pyopenssl.pyi @@ -0,0 +1,23 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import typing + +SSLv23_METHOD: int +TLSv1_METHOD: int +TLSv1_1_METHOD: int +TLSv1_2_METHOD: int +TLS_METHOD: int +TLS_SERVER_METHOD: int +TLS_CLIENT_METHOD: int +DTLS_METHOD: int +DTLS_SERVER_METHOD: int +DTLS_CLIENT_METHOD: int + +class Context: + def __new__(cls, method: int) -> Context: ... + @property + def _context(self) -> typing.Any: ... diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index cd05334bfc3f..1abf71f927fc 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -23,6 +23,7 @@ pub(crate) mod oid; mod padding; mod pkcs12; mod pkcs7; +mod pyopenssl; mod test_support; pub(crate) mod types; pub(crate) mod utils; @@ -255,6 +256,43 @@ mod _rust { } } + #[pyo3::pymodule] + mod pyopenssl { + use pyo3::prelude::PyModuleMethods; + + #[pymodule_export] + use crate::pyopenssl::error::Error; + #[pymodule_export] + use crate::pyopenssl::ssl::Context; + + #[pymodule_init] + fn init(pyopenssl_mod: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + use crate::pyopenssl::ssl::{ + DTLS_CLIENT_METHOD, DTLS_METHOD, DTLS_SERVER_METHOD, SSLV23_METHOD, TLSV1_1_METHOD, + TLSV1_2_METHOD, TLSV1_METHOD, TLS_CLIENT_METHOD, TLS_METHOD, TLS_SERVER_METHOD, + }; + + macro_rules! add_const { + ($name:ident) => { + pyopenssl_mod.add(stringify!($name), $name)?; + }; + } + + pyopenssl_mod.add("SSLv23_METHOD", SSLV23_METHOD)?; + pyopenssl_mod.add("TLSv1_METHOD", TLSV1_METHOD)?; + pyopenssl_mod.add("TLSv1_1_METHOD", TLSV1_1_METHOD)?; + pyopenssl_mod.add("TLSv1_2_METHOD", TLSV1_2_METHOD)?; + add_const!(TLS_METHOD); + add_const!(TLS_SERVER_METHOD); + add_const!(TLS_CLIENT_METHOD); + add_const!(DTLS_METHOD); + add_const!(DTLS_SERVER_METHOD); + add_const!(DTLS_CLIENT_METHOD); + + Ok(()) + } + } + #[pymodule_init] fn init(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { m.add_submodule(&cryptography_cffi::create_module(m.py())?)?; diff --git a/src/rust/src/pyopenssl/error.rs b/src/rust/src/pyopenssl/error.rs new file mode 100644 index 000000000000..a1bf79fdf3a4 --- /dev/null +++ b/src/rust/src/pyopenssl/error.rs @@ -0,0 +1,68 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +use pyo3::types::PyListMethods; + +pyo3::create_exception!( + OpenSSL.SSL, + Error, + pyo3::exceptions::PyException, + "An error occurred in an `OpenSSL.SSL` API." +); + +pub(crate) enum PyOpenSslError { + Py(pyo3::PyErr), + OpenSSL(openssl::error::ErrorStack), +} + +impl From for PyOpenSslError { + fn from(e: pyo3::PyErr) -> PyOpenSslError { + PyOpenSslError::Py(e) + } +} + +impl From for PyOpenSslError { + fn from(e: openssl::error::ErrorStack) -> PyOpenSslError { + PyOpenSslError::OpenSSL(e) + } +} + +impl From for pyo3::PyErr { + fn from(e: PyOpenSslError) -> pyo3::PyErr { + match e { + PyOpenSslError::Py(e) => e, + PyOpenSslError::OpenSSL(e) => pyo3::Python::with_gil(|py| { + let errs = pyo3::types::PyList::empty(py); + for err in e.errors() { + errs.append(( + err.library().unwrap_or(""), + err.function().unwrap_or(""), + err.reason().unwrap_or(""), + ))?; + } + Ok(Error::new_err(errs.unbind())) + }) + .unwrap_or_else(|e| e), + } + } +} + +pub(crate) type PyOpenSslResult = Result; + +#[cfg(test)] +mod tests { + use super::{Error, PyOpenSslError}; + + #[test] + fn test_pyopenssl_error_from_openssl_error() { + pyo3::Python::with_gil(|py| { + // Literally anything that returns a non-empty error stack + let err = openssl::x509::X509::from_der(b"").unwrap_err(); + + let py_err: pyo3::PyErr = PyOpenSslError::from(err).into(); + assert!(py_err.is_instance_of::(py)); + assert!(py_err.to_string().starts_with("Error: [("),); + }); + } +} diff --git a/src/rust/src/pyopenssl/mod.rs b/src/rust/src/pyopenssl/mod.rs new file mode 100644 index 000000000000..964ed9b1b495 --- /dev/null +++ b/src/rust/src/pyopenssl/mod.rs @@ -0,0 +1,6 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +pub(crate) mod error; +pub(crate) mod ssl; diff --git a/src/rust/src/pyopenssl/ssl.rs b/src/rust/src/pyopenssl/ssl.rs new file mode 100644 index 000000000000..9844a9cc3dc3 --- /dev/null +++ b/src/rust/src/pyopenssl/ssl.rs @@ -0,0 +1,75 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +use pyo3::types::PyAnyMethods; + +use crate::pyopenssl::error::{PyOpenSslError, PyOpenSslResult}; +use crate::types; + +pub(crate) const SSLV23_METHOD: u32 = 3; +pub(crate) const TLSV1_METHOD: u32 = 4; +pub(crate) const TLSV1_1_METHOD: u32 = 5; +pub(crate) const TLSV1_2_METHOD: u32 = 6; +pub(crate) const TLS_METHOD: u32 = 7; +pub(crate) const TLS_SERVER_METHOD: u32 = 8; +pub(crate) const TLS_CLIENT_METHOD: u32 = 9; +pub(crate) const DTLS_METHOD: u32 = 10; +pub(crate) const DTLS_SERVER_METHOD: u32 = 11; +pub(crate) const DTLS_CLIENT_METHOD: u32 = 12; + +#[pyo3::pyclass(subclass, module = "OpenSSL.SSL")] +pub(crate) struct Context { + ssl_ctx: openssl::ssl::SslContextBuilder, +} + +#[pyo3::pymethods] +impl Context { + #[new] + fn new(method: u32) -> PyOpenSslResult { + let (ssl_method, version) = match method { + SSLV23_METHOD => (openssl::ssl::SslMethod::tls(), None), + TLSV1_METHOD => ( + openssl::ssl::SslMethod::tls(), + Some(openssl::ssl::SslVersion::TLS1), + ), + TLSV1_1_METHOD => ( + openssl::ssl::SslMethod::tls(), + Some(openssl::ssl::SslVersion::TLS1_1), + ), + TLSV1_2_METHOD => ( + openssl::ssl::SslMethod::tls(), + Some(openssl::ssl::SslVersion::TLS1_2), + ), + TLS_METHOD => (openssl::ssl::SslMethod::tls(), None), + TLS_SERVER_METHOD => (openssl::ssl::SslMethod::tls_server(), None), + TLS_CLIENT_METHOD => (openssl::ssl::SslMethod::tls_client(), None), + DTLS_METHOD => (openssl::ssl::SslMethod::dtls(), None), + DTLS_SERVER_METHOD => (openssl::ssl::SslMethod::dtls_server(), None), + DTLS_CLIENT_METHOD => (openssl::ssl::SslMethod::dtls_client(), None), + _ => { + return Err(PyOpenSslError::from( + pyo3::exceptions::PyValueError::new_err("No such protocol"), + )) + } + }; + let mut ssl_ctx = openssl::ssl::SslContext::builder(ssl_method)?; + if let Some(version) = version { + ssl_ctx.set_min_proto_version(Some(version))?; + ssl_ctx.set_max_proto_version(Some(version))?; + } + + Ok(Context { ssl_ctx }) + } + + #[getter] + fn _context<'p>(&self, py: pyo3::Python<'p>) -> PyOpenSslResult> { + Ok(types::FFI.get(py)?.call_method1( + pyo3::intern!(py, "cast"), + ( + pyo3::intern!(py, "SSL_CTX *"), + self.ssl_ctx.as_ptr() as usize, + ), + )?) + } +} diff --git a/src/rust/src/types.rs b/src/rust/src/types.rs index 37ca3f424249..30ecc6730dbb 100644 --- a/src/rust/src/types.rs +++ b/src/rust/src/types.rs @@ -32,6 +32,9 @@ impl LazyPyImport { } } +pub static FFI: LazyPyImport = + LazyPyImport::new("cryptography.hazmat.bindings._rust", &["_openssl", "ffi"]); + pub static DATETIME_DATETIME: LazyPyImport = LazyPyImport::new("datetime", &["datetime"]); pub static DATETIME_TIMEZONE_UTC: LazyPyImport = LazyPyImport::new("datetime", &["timezone", "utc"]); diff --git a/tests/pyopenssl/__init__.py b/tests/pyopenssl/__init__.py new file mode 100644 index 000000000000..b509336233c2 --- /dev/null +++ b/tests/pyopenssl/__init__.py @@ -0,0 +1,3 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. diff --git a/tests/pyopenssl/test_ssl.py b/tests/pyopenssl/test_ssl.py new file mode 100644 index 000000000000..decdc98acc0a --- /dev/null +++ b/tests/pyopenssl/test_ssl.py @@ -0,0 +1,37 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import pytest + +from cryptography.hazmat.bindings._rust import _openssl, pyopenssl + + +class TestContext: + def test_create(self): + for method in [ + pyopenssl.SSLv23_METHOD, + pyopenssl.TLSv1_METHOD, + pyopenssl.TLSv1_1_METHOD, + pyopenssl.TLSv1_2_METHOD, + pyopenssl.TLS_METHOD, + pyopenssl.TLS_SERVER_METHOD, + pyopenssl.TLS_CLIENT_METHOD, + pyopenssl.DTLS_METHOD, + pyopenssl.DTLS_SERVER_METHOD, + pyopenssl.DTLS_CLIENT_METHOD, + ]: + ctx = pyopenssl.Context(method) + assert ctx + + with pytest.raises(TypeError): + pyopenssl.Context(object()) # type: ignore[arg-type] + + with pytest.raises(ValueError): + pyopenssl.Context(12324213) + + def test__context(self): + ctx = pyopenssl.Context(pyopenssl.TLS_METHOD) + assert ctx._context + assert _openssl.ffi.typeof(ctx._context).cname == "SSL_CTX *" + assert _openssl.ffi.cast("uintptr_t", ctx._context) > 0