Skip to content

Commit d57af66

Browse files
committed
Allow passing PEM strings to Rsmime
1 parent 568bf6e commit d57af66

File tree

5 files changed

+157
-42
lines changed

5 files changed

+157
-42
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/site
22
/target
33
/.shadowenv.d
4+
.venv/
45
*.so
56
__pycache__/

docs/getting_started.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ except (SignError, CertificateError) as e:
2323
print("Failed to sign:", e)
2424
```
2525

26+
If you already have the PEM data in memory you can also provide it directly:
27+
28+
```python
29+
from pathlib import Path
30+
from rsmime import Rsmime
31+
32+
certificate = Path("some.crt").read_text()
33+
private_key = Path("some.key").read_text()
34+
35+
client = Rsmime(cert_data=certificate, key_data=private_key)
36+
```
37+
2638
### Output
2739

2840
```bash

python/rsmime/__init__.pyi

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
class Rsmime:
2-
def __init__(self, cert_file: str, key_file: str) -> None:
3-
"""Initialize client and load certificate from disk.
2+
def __init__(
3+
self,
4+
cert_file: str | None = ...,
5+
key_file: str | None = ...,
6+
*,
7+
cert_data: str | None = ...,
8+
key_data: str | None = ...,
9+
) -> None:
10+
"""Initialize client and load certificate material.
411
512
Parameters:
6-
cert_file: Path to certificate on disk.
7-
key_file: Path to private key on disk.
13+
cert_file: Path to certificate on disk. Mutually exclusive with ``cert_data``.
14+
key_file: Path to private key on disk. Mutually exclusive with ``key_data``.
15+
cert_data: PEM-encoded certificate contents provided as a string.
16+
key_data: PEM-encoded private key contents provided as a string.
817
918
Raises:
10-
exceptions.CertificateError: If there is an error reading the certificate or key.
19+
exceptions.CertificateError: If there is an error loading, parsing, or when
20+
both a file path and in-memory value are provided for the same artifact.
1121
"""
1222
...
1323
def sign(self, message: bytes, *, detached: bool = False) -> bytes:

src/lib.rs

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,20 +132,53 @@ struct Rsmime {
132132
#[pymethods]
133133
impl Rsmime {
134134
#[new]
135-
#[pyo3(signature = (cert_file, key_file))]
136-
fn new(cert_file: String, key_file: String) -> PyResult<Self> {
135+
#[pyo3(signature = (cert_file=None, key_file=None, *, cert_data=None, key_data=None))]
136+
fn new(
137+
cert_file: Option<String>,
138+
key_file: Option<String>,
139+
cert_data: Option<String>,
140+
key_data: Option<String>,
141+
) -> PyResult<Self> {
137142
let stack = Stack::new().unwrap();
138143

139-
let cert_data =
140-
std::fs::read(cert_file).map_err(|err| CertificateError::new_err(err.to_string()))?;
141-
142-
let cert =
143-
X509::from_pem(&cert_data).map_err(|err| CertificateError::new_err(err.to_string()))?;
144-
145-
let key_data =
146-
std::fs::read(key_file).map_err(|err| CertificateError::new_err(err.to_string()))?;
144+
let cert_bytes = match (cert_file, cert_data) {
145+
(Some(path), None) => {
146+
std::fs::read(path).map_err(|err| CertificateError::new_err(err.to_string()))?
147+
}
148+
(None, Some(data)) => data.into_bytes(),
149+
(Some(_), Some(_)) => {
150+
return Err(CertificateError::new_err(
151+
"Provide either cert_file or cert_data, not both",
152+
))
153+
}
154+
(None, None) => {
155+
return Err(CertificateError::new_err(
156+
"A certificate must be provided via cert_file or cert_data",
157+
))
158+
}
159+
};
160+
161+
let key_bytes = match (key_file, key_data) {
162+
(Some(path), None) => {
163+
std::fs::read(path).map_err(|err| CertificateError::new_err(err.to_string()))?
164+
}
165+
(None, Some(data)) => data.into_bytes(),
166+
(Some(_), Some(_)) => {
167+
return Err(CertificateError::new_err(
168+
"Provide either key_file or key_data, not both",
169+
))
170+
}
171+
(None, None) => {
172+
return Err(CertificateError::new_err(
173+
"A private key must be provided via key_file or key_data",
174+
))
175+
}
176+
};
177+
178+
let cert = X509::from_pem(&cert_bytes)
179+
.map_err(|err| CertificateError::new_err(err.to_string()))?;
147180

148-
let rsa = Rsa::private_key_from_pem(&key_data)
181+
let rsa = Rsa::private_key_from_pem(&key_bytes)
149182
.map_err(|err| CertificateError::new_err(err.to_string()))?;
150183
let pkey = PKey::from_rsa(rsa).map_err(|err| CertificateError::new_err(err.to_string()))?;
151184

tests/test_rsmime.py

Lines changed: 85 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,68 @@
1+
from pathlib import Path
2+
13
import pytest
24
from callee import strings
5+
36
from rsmime import Rsmime, exceptions
47

8+
9+
ATTACHED_SIGNATURE_REGEX = strings.Regex(
10+
b"MIME-Version: 1.0\n"
11+
b"Content-Disposition: attachment; filename=\"smime.p7m\"\n"
12+
b"Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name=\"smime.p7m\"\n"
13+
b"Content-Transfer-Encoding: base64\n"
14+
b"\n"
15+
b"MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n"
16+
b"[A-Za-z0-9/+=\n]+\n"
17+
b"\n"
18+
)
19+
20+
DETACHED_SIGNATURE_REGEX = strings.Regex(
21+
b"MIME-Version: 1.0\n"
22+
b"Content-Type: multipart/signed; protocol=\"application/x-pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"----[A-Z0-9]+\"\n\n"
23+
b"This is an S/MIME signed message\n\n"
24+
b"------[A-Z0-9]+\n"
25+
b"abc\n"
26+
b"------[A-Z0-9]+\n"
27+
b"Content-Type: application/x-pkcs7-signature; name=\"smime.p7s\"\n"
28+
b"Content-Transfer-Encoding: base64\n"
29+
b"Content-Disposition: attachment; filename=\"smime.p7s\"\n"
30+
b"\n"
31+
b"MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n"
32+
b"[A-Za-z0-9/+=\n]+\n"
33+
b"\n"
34+
b"------[A-Z0-9]+--\n"
35+
b"\n"
36+
)
37+
38+
39+
def _load_text(path: str) -> str:
40+
return Path(path).read_text()
41+
42+
543
working_client = Rsmime('tests/data/certificate.crt', 'tests/data/certificate.key')
644
expired_client = Rsmime('tests/data/expired.crt', 'tests/data/certificate.key')
745

846

947
class TestRsmime:
1048
def test_sign(self):
1149
signed_data = working_client.sign(b'abc')
12-
assert signed_data == strings.Regex(
13-
b'MIME-Version: 1.0\n'
14-
b'Content-Disposition: attachment; filename="smime.p7m"\n'
15-
b'Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"\n'
16-
b'Content-Transfer-Encoding: base64\n'
17-
b'\n'
18-
b'MIIJwQYJKoZIhvcNAQcCoIIJsjCCCa4CAQExDzANBglghkgBZQMEAgEFADASBgkq\n'
19-
b'[A-Za-z0-9/+=\n]+\n'
20-
b'\n'
21-
)
50+
assert signed_data == ATTACHED_SIGNATURE_REGEX
2251

2352
def test_sign_detached(self):
2453
signed_data = working_client.sign(b'abc', detached=True)
25-
assert signed_data == strings.Regex(
26-
b'MIME-Version: 1.0\n'
27-
b'Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----[A-Z0-9]+"\n\n'
28-
b'This is an S/MIME signed message\n\n'
29-
b'------[A-Z0-9]+\n'
30-
b'abc\n'
31-
b'------[A-Z0-9]+\n'
32-
b'Content-Type: application/x-pkcs7-signature; name="smime.p7s"\n'
33-
b'Content-Transfer-Encoding: base64\n'
34-
b'Content-Disposition: attachment; filename="smime.p7s"\n'
35-
b'\n'
36-
b'MIIJugYJKoZIhvcNAQcCoIIJqzCCCacCAQExDzANBglghkgBZQMEAgEFADALBgkq\n'
37-
b'[A-Za-z0-9/+=\n]+\n'
38-
b'\n'
39-
b'------[A-Z0-9]+--\n'
40-
b'\n'
54+
assert signed_data == DETACHED_SIGNATURE_REGEX
55+
56+
def test_sign_with_in_memory_material(self):
57+
client = Rsmime(
58+
cert_data=_load_text('tests/data/certificate.crt'),
59+
key_data=_load_text('tests/data/certificate.key'),
4160
)
4261

62+
signed_data = client.sign(b'abc')
63+
64+
assert signed_data == ATTACHED_SIGNATURE_REGEX
65+
4366
def test_sign_missing_cert(self):
4467
with pytest.raises(
4568
exceptions.CertificateError, match='No such file or directory'
@@ -64,6 +87,42 @@ def test_sign_empty_data(self):
6487
with pytest.raises(exceptions.SignError, match='Cannot sign empty data'):
6588
working_client.sign(b'')
6689

90+
def test_conflicting_certificate_inputs(self):
91+
with pytest.raises(
92+
exceptions.CertificateError,
93+
match='Provide either cert_file or cert_data',
94+
):
95+
Rsmime(
96+
'tests/data/certificate.crt',
97+
'tests/data/certificate.key',
98+
cert_data=_load_text('tests/data/certificate.crt'),
99+
)
100+
101+
def test_conflicting_key_inputs(self):
102+
with pytest.raises(
103+
exceptions.CertificateError,
104+
match='Provide either key_file or key_data',
105+
):
106+
Rsmime(
107+
'tests/data/certificate.crt',
108+
'tests/data/certificate.key',
109+
key_data=_load_text('tests/data/certificate.key'),
110+
)
111+
112+
def test_missing_certificate_input(self):
113+
with pytest.raises(
114+
exceptions.CertificateError,
115+
match='cert_file or cert_data',
116+
):
117+
Rsmime(key_data=_load_text('tests/data/certificate.key'))
118+
119+
def test_missing_key_input(self):
120+
with pytest.raises(
121+
exceptions.CertificateError,
122+
match='key_file or key_data',
123+
):
124+
Rsmime(cert_data=_load_text('tests/data/certificate.crt'))
125+
67126
def test_verify(self):
68127
data = b'abc'
69128
signed_data = working_client.sign(data)

0 commit comments

Comments
 (0)