From 0467326c07654aca4889eea557b3cfb8406d0335 Mon Sep 17 00:00:00 2001 From: Vika Date: Fri, 26 Jan 2024 21:15:38 +0300 Subject: [PATCH] feat: plugin support API surface exposed is equivalent to the Rust API, including plugin callbacks (duck-typed at runtime, exposed in type stubs as a `pyrage.plugin.Callbacks` protocol, all of which's methods are required to be implemented at runtime, otherwise `AttributeError` is thrown). Non-`Clone`ability of plugin instances is resolved by putting them behind an `Arc`. Perhaps a more elegant solution could be produced later, by someone with more knowledge of PyO3 internals. This was tested with rage's example `age-unencrypted-plugin` which happens to exercise the `display_message` callback, and additionally with `age-plugin-tpm` to exercise actual encryption functionality. Typing stubs are amended to cover the new API surface. Conformance to type-stubs was checked by writing a short Python program exercising the functionality and type-checking it using `mypy` with the type stubs installed. --- Cargo.lock | 174 ++++++++++++++++++++-- Cargo.toml | 2 +- pyrage-stubs/pyrage-stubs/__init__.pyi | 6 +- pyrage-stubs/pyrage-stubs/plugin.pyi | 48 ++++++ src/lib.rs | 17 ++- src/plugin.rs | 198 +++++++++++++++++++++++++ 6 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 pyrage-stubs/pyrage-stubs/plugin.pyi create mode 100644 src/plugin.rs diff --git a/Cargo.lock b/Cargo.lock index 9ff7704..77d8bf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,8 @@ dependencies = [ "scrypt", "sha2", "subtle", + "which", + "wsl", "x25519-dalek", "zeroize", ] @@ -87,6 +89,7 @@ dependencies = [ "rand", "secrecy", "sha2", + "tempfile", ] [[package]] @@ -136,6 +139,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + [[package]] name = "block-buffer" version = "0.10.4" @@ -334,12 +343,34 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "either" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + [[package]] name = "fiat-crypto" version = "0.2.6" @@ -460,6 +491,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys", +] + [[package]] name = "i18n-config" version = "0.4.6" @@ -601,6 +641,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "lock_api" version = "0.4.11" @@ -728,7 +774,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -973,7 +1019,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1045,6 +1091,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "salsa20" version = "0.10.2" @@ -1221,6 +1280,19 @@ version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c39fd04924ca3a864207c66fc2cd7d22d7c016007f9ce846cbb9326331930a" +[[package]] +name = "tempfile" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.50" @@ -1371,6 +1443,18 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1402,19 +1486,43 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", ] [[package]] @@ -1423,42 +1531,84 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + [[package]] name = "winnow" version = "0.5.26" @@ -1468,6 +1618,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wsl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" + [[package]] name = "x25519-dalek" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 52c6c53..66ad89b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib"] [dependencies] age-core = "0.10" -age = { version = "0.10", features = ["ssh"] } +age = { version = "0.10", features = ["ssh", "plugin"] } pyo3 = { version = "0.21", features = [ "extension-module", "abi3", diff --git a/pyrage-stubs/pyrage-stubs/__init__.pyi b/pyrage-stubs/pyrage-stubs/__init__.pyi index d44dd2d..8119866 100644 --- a/pyrage-stubs/pyrage-stubs/__init__.pyi +++ b/pyrage-stubs/pyrage-stubs/__init__.pyi @@ -1,9 +1,9 @@ from typing import Sequence, Union -from pyrage import ssh, x25519, passphrase +from pyrage import ssh, x25519, passphrase, plugin -Identity = Union[ssh.Identity, x25519.Identity] -Recipient = Union[ssh.Recipient, x25519.Recipient] +Identity = Union[ssh.Identity, x25519.Identity, plugin.IdentityPluginV1] +Recipient = Union[ssh.Recipient, x25519.Recipient, plugin.RecipientPluginV1] class RecipientError(Exception): diff --git a/pyrage-stubs/pyrage-stubs/plugin.pyi b/pyrage-stubs/pyrage-stubs/plugin.pyi new file mode 100644 index 0000000..d927bc0 --- /dev/null +++ b/pyrage-stubs/pyrage-stubs/plugin.pyi @@ -0,0 +1,48 @@ +from __future__ import annotations +from typing import Sequence, Self, Optional, Protocol + + +class Callbacks(Protocol): + def display_message(self, message: str) -> None: + ... + + def confirm(self, message: str, yes_string: str, no_string: Optional[str]) -> Optional[bool]: + ... + + def request_public_string(self, description: str) -> Optional[str]: + ... + + def request_passphrase(self, description: str) -> Optional[str]: + ... + + +class Recipient: + @classmethod + def from_str(cls, v: str) -> Recipient: + ... + + def plugin(self) -> str: + ... + + +class RecipientPluginV1: + def __new__(cls, plugin_name: str, recipients: Sequence[Recipient], identities: Sequence[Identity], callbacks: Callbacks) -> Self: + ... + + +class Identity: + @classmethod + def from_str(cls, v: str) -> Identity: + ... + + @classmethod + def default_for_plugin(cls, plugin: str) -> Identity: + ... + + def plugin(self) -> str: + ... + + +class IdentityPluginV1: + def __new__(cls, plugin_name: str, identities: Sequence[Identity], callbacks: Callbacks) -> Self: + ... diff --git a/src/lib.rs b/src/lib.rs index 216bca8..b313883 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ use pyo3::{ }; mod passphrase; +mod plugin; mod ssh; mod x25519; @@ -65,7 +66,7 @@ macro_rules! recipient_traits { } } -recipient_traits!(ssh::Recipient, x25519::Recipient); +recipient_traits!(ssh::Recipient, x25519::Recipient, plugin::RecipientPluginV1); // This macro generates two trait impls for each passed in type: // @@ -89,7 +90,7 @@ macro_rules! identity_traits { } } -identity_traits!(ssh::Identity, x25519::Identity); +identity_traits!(ssh::Identity, x25519::Identity, plugin::IdentityPluginV1); // This is where the magic happens, and why we need to do the trait dance // above: `FromPyObject` is a third-party trait, so we need to implement it @@ -104,6 +105,8 @@ impl<'source> FromPyObject<'source> for Box { Ok(Box::new(recipient) as Box) } else if let Ok(recipient) = ob.extract::() { Ok(Box::new(recipient) as Box) + } else if let Ok(recipient) = ob.extract::() { + Ok(Box::new(recipient) as Box) } else { Err(PyTypeError::new_err( "invalid type (expected a recipient type)", @@ -120,6 +123,8 @@ impl<'source> FromPyObject<'source> for Box { Ok(Box::new(identity) as Box) } else if let Ok(identity) = ob.extract::() { Ok(Box::new(identity) as Box) + } else if let Ok(identity) = ob.extract::() { + Ok(Box::new(identity) as Box) } else { Err(PyTypeError::new_err( "invalid type (expected an identity type)", @@ -279,6 +284,14 @@ fn pyrage(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { ); m.add_submodule(&passphrase)?; + let plugin = plugin::module(py)?; + py_run!( + py, + plugin, + "import sys; sys.modules['pyrage.plugin'] = plugin" + ); + m.add_submodule(&plugin)?; + m.add("IdentityError", py.get_type_bound::())?; m.add("RecipientError", py.get_type_bound::())?; diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..cb81e8c --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,198 @@ +use std::str::FromStr; +use std::sync::Arc; + +use pyo3::{prelude::*, types::PyType}; + +use crate::{DecryptError, EncryptError, IdentityError, RecipientError}; + +/// Hack, because the orphan rule would prevent us from deriving a +/// foreign trait on a foreign object. Instead, define a newtype. +/// +/// Inner type is PyAny, because we do duck-typing at runtime, and +/// declaring a protocol in the type stubs. +#[derive(Clone)] +pub(crate) struct PyCallbacks(Py); + +impl PyCallbacks { + fn new(inner: Bound<'_, PyAny>) -> PyResult { + Ok(Self(inner.unbind())) + } +} + +// Since we have no way to pass errors from these callbacks, we might +// as well panic. +// +// These callbacks don't look like they're supposed to fail anyway. +impl age::Callbacks for PyCallbacks { + fn display_message(&self, message: &str) { + Python::with_gil(|py| { + self.0 + .call_method1(py, pyo3::intern!(py, "display_message"), (message,)) + .expect("`display_message` callback error") + }); + } + fn confirm(&self, message: &str, yes_string: &str, no_string: Option<&str>) -> Option { + Python::with_gil(|py| { + self.0 + .call_method1( + py, + pyo3::intern!(py, "confirm"), + (message, yes_string, no_string), + ) + .expect("`confirm` callback error") + .extract::>(py) + }) + .expect("type error in `confirm` callback") + } + fn request_public_string(&self, description: &str) -> Option { + Python::with_gil(|py| { + self.0 + .call_method1( + py, + pyo3::intern!(py, "request_public_string"), + (description,), + ) + .expect("`request_public_string` callback error") + .extract::>(py) + }) + .expect("type error in `request_public_string` callback") + } + fn request_passphrase(&self, description: &str) -> Option { + Python::with_gil(|py| { + self.0 + .call_method1(py, pyo3::intern!(py, "request_passphrase"), (description,)) + .expect("`request_passphrase` callback error") + .extract::>(py) + }) + .expect("type error in `request_passphrase` callback") + .map(age::secrecy::SecretString::new) + } +} + +#[pyclass(module = "pyrage.plugin")] +#[derive(Clone)] +pub(crate) struct Recipient(pub(crate) age::plugin::Recipient); + +#[pymethods] +impl Recipient { + #[classmethod] + fn from_str(_cls: &Bound<'_, PyType>, v: &str) -> PyResult { + age::plugin::Recipient::from_str(v) + .map(Self) + .map_err(RecipientError::new_err) + } + + fn plugin(&self) -> String { + self.0.plugin().to_owned() + } + + fn __str__(&self) -> String { + self.0.to_string() + } +} + +#[pyclass(module = "pyrage.plugin")] +#[derive(Clone)] +pub(crate) struct Identity(pub(crate) age::plugin::Identity); + +#[pymethods] +impl Identity { + #[classmethod] + fn from_str(_cls: &Bound<'_, PyType>, v: &str) -> PyResult { + age::plugin::Identity::from_str(v) + .map(Self) + .map_err(|e| IdentityError::new_err(e.to_string())) + } + + #[classmethod] + fn default_for_plugin(_cls: &Bound<'_, PyType>, plugin: &str) -> Self { + Self(age::plugin::Identity::default_for_plugin(plugin)) + } + + fn plugin(&self) -> String { + self.0.plugin().to_owned() + } + + fn __str__(&self) -> String { + self.0.to_string() + } +} + +#[pyclass(module = "pyrage.plugin")] +#[derive(Clone)] +pub(crate) struct RecipientPluginV1(pub(crate) Arc>); + +#[pymethods] +impl RecipientPluginV1 { + #[new] + #[pyo3( + text_signature = "(plugin_name: str, recipients: typing.Sequence[Recipient], identities: typing.Sequence[Identity], callbacks: Callbacks)" + )] + fn new( + _py: Python<'_>, + plugin_name: &str, + recipients: Vec, + identities: Vec, + callbacks: Bound<'_, PyAny>, + ) -> PyResult { + age::plugin::RecipientPluginV1::new( + plugin_name, + recipients + .into_iter() + .map(|i| i.0) + .collect::>() + .as_slice(), + identities + .into_iter() + .map(|i| i.0) + .collect::>() + .as_slice(), + PyCallbacks::new(callbacks)?, + ) + .map(Arc::new) + .map(Self) + .map_err(|err| EncryptError::new_err(err.to_string())) + } +} + +#[pyclass(module = "pyrage.plugin")] +#[derive(Clone)] +pub(crate) struct IdentityPluginV1(pub(crate) Arc>); + +#[pymethods] +impl IdentityPluginV1 { + #[new] + #[pyo3( + text_signature = "(plugin_name: str, identities: typing.Sequence[Identity], callbacks: Callbacks)" + )] + fn new( + _py: Python<'_>, + plugin_name: &str, + identities: Vec, + callbacks: Bound<'_, PyAny>, + ) -> PyResult { + age::plugin::IdentityPluginV1::new( + plugin_name, + identities + .into_iter() + .map(|i| i.0) + .collect::>() + .as_slice(), + PyCallbacks::new(callbacks)?, + ) + .map(Arc::new) + .map(Self) + .map_err(|err| DecryptError::new_err(err.to_string())) + } +} + +pub(crate) fn module(py: Python<'_>) -> PyResult> { + let module = PyModule::new_bound(py, "plugin")?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(module) +}