Repository: woodruffw/pyrage (language: python)

Issue:
Password-based encryption and decryption
Separate from #1, to avoid cluttering those APIs.


Discussion / hints:
Braindump: the APIs here should be:

```py
from pyrage import passphrase

pyrage.encrypt(plaintext, passphrase)
pyrage.decrypt(ciphertext, passphrase)
```

Fix patch:
diff --git a/pyrage-stubs/pyrage-stubs/passphrase.py b/pyrage-stubs/pyrage-stubs/passphrase.py
new file mode 100644
index 0000000..33d209f
--- /dev/null
+++ b/pyrage-stubs/pyrage-stubs/passphrase.py
@@ -0,0 +1,2 @@
+def encrypt(plaintext: bytes, passphrase: str) -> bytes: ...
+def decrypt(ciphertext: bytes, passphrase: str) -> bytes: ...
diff --git a/src/lib.rs b/src/lib.rs
index bf66541..e2cdde8 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,5 @@
+#![forbid(unsafe_code)]
+
 use std::io::{Read, Write};
 
 use age::{DecryptError, EncryptError, Encryptor, Identity, Recipient};
@@ -9,6 +11,7 @@ use pyo3::{
     types::PyBytes,
 };
 
+mod passphrase;
 mod ssh;
 mod x25519;
 
@@ -187,6 +190,14 @@ fn pyrage(py: Python, m: &PyModule) -> PyResult<()> {
     py_run!(py, ssh, "import sys; sys.modules['pyrage.ssh'] = ssh");
     m.add_submodule(ssh)?;
 
+    let passphrase = passphrase::module(py)?;
+    py_run!(
+        py,
+        passphrase,
+        "import sys; sys.modules['pyrage.passphrase'] = passphrase"
+    );
+    m.add_submodule(passphrase)?;
+
     m.add_wrapped(wrap_pyfunction!(encrypt))?;
     m.add_wrapped(wrap_pyfunction!(decrypt))?;
 
diff --git a/src/passphrase.rs b/src/passphrase.rs
new file mode 100644
index 0000000..157a5d5
--- /dev/null
+++ b/src/passphrase.rs
@@ -0,0 +1,52 @@
+use std::io::{Read, Write};
+
+use age::{secrecy::Secret, Decryptor, Encryptor};
+use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes};
+
+#[pyfunction]
+fn encrypt<'p>(py: Python<'p>, plaintext: &[u8], passphrase: &str) -> PyResult<&'p PyBytes> {
+    let encryptor = Encryptor::with_user_passphrase(Secret::new(passphrase.into()));
+    let mut encrypted = vec![];
+    let mut writer = encryptor
+        .wrap_output(&mut encrypted)
+        .map_err(|e| PyValueError::new_err(e.to_string()))?;
+    writer
+        .write_all(plaintext)
+        .map_err(|e| PyValueError::new_err(e.to_string()))?;
+    writer
+        .finish()
+        .map_err(|e| PyValueError::new_err(e.to_string()))?;
+
+    Ok(PyBytes::new(py, &encrypted))
+}
+
+#[pyfunction]
+fn decrypt<'p>(py: Python<'p>, ciphertext: &[u8], passphrase: &str) -> PyResult<&'p PyBytes> {
+    let decryptor =
+        match Decryptor::new(ciphertext).map_err(|e| PyValueError::new_err(e.to_string()))? {
+            Decryptor::Passphrase(d) => d,
+            _ => {
+                return Err(PyValueError::new_err(
+                    "invalid ciphertext (not passphrase encrypted)",
+                ))
+            }
+        };
+    let mut decrypted = vec![];
+    let mut reader = decryptor
+        .decrypt(&Secret::new(passphrase.into()), None)
+        .map_err(|e| PyValueError::new_err(e.to_string()))?;
+    reader
+        .read_to_end(&mut decrypted)
+        .map_err(|e| PyValueError::new_err(e.to_string()))?;
+
+    Ok(PyBytes::new(py, &decrypted))
+}
+
+pub(crate) fn module(py: Python) -> PyResult<&PyModule> {
+    let module = PyModule::new(py, "passphrase")?;
+
+    module.add_wrapped(wrap_pyfunction!(encrypt))?;
+    module.add_wrapped(wrap_pyfunction!(decrypt))?;
+
+    Ok(module)
+}


Test patch:
diff --git a/test/test_passphrase.py b/test/test_passphrase.py
new file mode 100644
index 0000000..fb908ca
--- /dev/null
+++ b/test/test_passphrase.py
@@ -0,0 +1,12 @@
+import unittest
+
+from pyrage import passphrase
+
+
+class TestPassphrase(unittest.TestCase):
+    def test_roundtrip(self):
+        plaintext = b"junk"
+        encrypted = passphrase.encrypt(plaintext, "some password")
+        decrypted = passphrase.decrypt(encrypted, "some password")
+
+        self.assertEqual(plaintext, decrypted)