Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/site
/target
/.shadowenv.d
.venv/
*.so
__pycache__/
12 changes: 12 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ except (SignError, CertificateError) as e:
print("Failed to sign:", e)
```

If you already have the PEM data in memory you can also provide it directly:

```python
from pathlib import Path
from rsmime import Rsmime

certificate = Path("some.crt").read_text()
private_key = Path("some.key").read_text()

client = Rsmime(cert_data=certificate, key_data=private_key)
```

### Output

```bash
Expand Down
20 changes: 15 additions & 5 deletions python/rsmime/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
class Rsmime:
def __init__(self, cert_file: str, key_file: str) -> None:
"""Initialize client and load certificate from disk.
def __init__(
self,
cert_file: str | None = ...,
key_file: str | None = ...,
*,
cert_data: str | None = ...,
key_data: str | None = ...,
) -> None:
"""Initialize client and load certificate material.

Parameters:
cert_file: Path to certificate on disk.
key_file: Path to private key on disk.
cert_file: Path to certificate on disk. Mutually exclusive with ``cert_data``.
key_file: Path to private key on disk. Mutually exclusive with ``key_data``.
cert_data: PEM-encoded certificate contents provided as a string.
key_data: PEM-encoded private key contents provided as a string.

Raises:
exceptions.CertificateError: If there is an error reading the certificate or key.
exceptions.CertificateError: If there is an error loading, parsing, or when
both a file path and in-memory value are provided for the same artifact.
"""
...
def sign(self, message: bytes, *, detached: bool = False) -> bytes:
Expand Down
55 changes: 44 additions & 11 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,53 @@ struct Rsmime {
#[pymethods]
impl Rsmime {
#[new]
#[pyo3(signature = (cert_file, key_file))]
fn new(cert_file: String, key_file: String) -> PyResult<Self> {
#[pyo3(signature = (cert_file=None, key_file=None, *, cert_data=None, key_data=None))]
fn new(
cert_file: Option<String>,
key_file: Option<String>,
cert_data: Option<String>,
key_data: Option<String>,
) -> PyResult<Self> {
let stack = Stack::new().unwrap();

let cert_data =
std::fs::read(cert_file).map_err(|err| CertificateError::new_err(err.to_string()))?;

let cert =
X509::from_pem(&cert_data).map_err(|err| CertificateError::new_err(err.to_string()))?;

let key_data =
std::fs::read(key_file).map_err(|err| CertificateError::new_err(err.to_string()))?;
let cert_bytes = match (cert_file, cert_data) {
(Some(path), None) => {
std::fs::read(path).map_err(|err| CertificateError::new_err(err.to_string()))?
}
(None, Some(data)) => data.into_bytes(),
(Some(_), Some(_)) => {
return Err(CertificateError::new_err(
"Provide either cert_file or cert_data, not both",
))
}
(None, None) => {
return Err(CertificateError::new_err(
"A certificate must be provided via cert_file or cert_data",
))
}
};

let key_bytes = match (key_file, key_data) {
(Some(path), None) => {
std::fs::read(path).map_err(|err| CertificateError::new_err(err.to_string()))?
}
(None, Some(data)) => data.into_bytes(),
(Some(_), Some(_)) => {
return Err(CertificateError::new_err(
"Provide either key_file or key_data, not both",
))
}
(None, None) => {
return Err(CertificateError::new_err(
"A private key must be provided via key_file or key_data",
))
}
};

let cert = X509::from_pem(&cert_bytes)
.map_err(|err| CertificateError::new_err(err.to_string()))?;

let rsa = Rsa::private_key_from_pem(&key_data)
let rsa = Rsa::private_key_from_pem(&key_bytes)
.map_err(|err| CertificateError::new_err(err.to_string()))?;
let pkey = PKey::from_rsa(rsa).map_err(|err| CertificateError::new_err(err.to_string()))?;

Expand Down
111 changes: 85 additions & 26 deletions tests/test_rsmime.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,68 @@
from pathlib import Path

import pytest
from callee import strings

from rsmime import Rsmime, exceptions


ATTACHED_SIGNATURE_REGEX = strings.Regex(
b"MIME-Version: 1.0\n"
b"Content-Disposition: attachment; filename=\"smime.p7m\"\n"
b"Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name=\"smime.p7m\"\n"
b"Content-Transfer-Encoding: base64\n"
b"\n"
b"MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n"
b"[A-Za-z0-9/+=\n]+\n"
b"\n"
)

DETACHED_SIGNATURE_REGEX = strings.Regex(
b"MIME-Version: 1.0\n"
b"Content-Type: multipart/signed; protocol=\"application/x-pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"----[A-Z0-9]+\"\n\n"
b"This is an S/MIME signed message\n\n"
b"------[A-Z0-9]+\n"
b"abc\n"
b"------[A-Z0-9]+\n"
b"Content-Type: application/x-pkcs7-signature; name=\"smime.p7s\"\n"
b"Content-Transfer-Encoding: base64\n"
b"Content-Disposition: attachment; filename=\"smime.p7s\"\n"
b"\n"
b"MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n"
b"[A-Za-z0-9/+=\n]+\n"
b"\n"
b"------[A-Z0-9]+--\n"
b"\n"
)


def _load_text(path: str) -> str:
return Path(path).read_text()


working_client = Rsmime('tests/data/certificate.crt', 'tests/data/certificate.key')
expired_client = Rsmime('tests/data/expired.crt', 'tests/data/certificate.key')


class TestRsmime:
def test_sign(self):
signed_data = working_client.sign(b'abc')
assert signed_data == strings.Regex(
b'MIME-Version: 1.0\n'
b'Content-Disposition: attachment; filename="smime.p7m"\n'
b'Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"\n'
b'Content-Transfer-Encoding: base64\n'
b'\n'
b'MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n'
b'[A-Za-z0-9/+=\n]+\n'
b'\n'
)
assert signed_data == ATTACHED_SIGNATURE_REGEX

def test_sign_detached(self):
signed_data = working_client.sign(b'abc', detached=True)
assert signed_data == strings.Regex(
b'MIME-Version: 1.0\n'
b'Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----[A-Z0-9]+"\n\n'
b'This is an S/MIME signed message\n\n'
b'------[A-Z0-9]+\n'
b'abc\n'
b'------[A-Z0-9]+\n'
b'Content-Type: application/x-pkcs7-signature; name="smime.p7s"\n'
b'Content-Transfer-Encoding: base64\n'
b'Content-Disposition: attachment; filename="smime.p7s"\n'
b'\n'
b'MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n'
b'[A-Za-z0-9/+=\n]+\n'
b'\n'
b'------[A-Z0-9]+--\n'
b'\n'
assert signed_data == DETACHED_SIGNATURE_REGEX

def test_sign_with_in_memory_material(self):
client = Rsmime(
cert_data=_load_text('tests/data/certificate.crt'),
key_data=_load_text('tests/data/certificate.key'),
)

signed_data = client.sign(b'abc')

assert signed_data == ATTACHED_SIGNATURE_REGEX

def test_sign_missing_cert(self):
with pytest.raises(
exceptions.CertificateError, match='No such file or directory'
Expand All @@ -64,6 +87,42 @@ def test_sign_empty_data(self):
with pytest.raises(exceptions.SignError, match='Cannot sign empty data'):
working_client.sign(b'')

def test_conflicting_certificate_inputs(self):
with pytest.raises(
exceptions.CertificateError,
match='Provide either cert_file or cert_data',
):
Rsmime(
'tests/data/certificate.crt',
'tests/data/certificate.key',
cert_data=_load_text('tests/data/certificate.crt'),
)

def test_conflicting_key_inputs(self):
with pytest.raises(
exceptions.CertificateError,
match='Provide either key_file or key_data',
):
Rsmime(
'tests/data/certificate.crt',
'tests/data/certificate.key',
key_data=_load_text('tests/data/certificate.key'),
)

def test_missing_certificate_input(self):
with pytest.raises(
exceptions.CertificateError,
match='cert_file or cert_data',
):
Rsmime(key_data=_load_text('tests/data/certificate.key'))

def test_missing_key_input(self):
with pytest.raises(
exceptions.CertificateError,
match='key_file or key_data',
):
Rsmime(cert_data=_load_text('tests/data/certificate.crt'))

def test_verify(self):
data = b'abc'
signed_data = working_client.sign(data)
Expand Down
Loading