remailers 0.1.0a2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- remailers-0.1.0a2/LICENSE +19 -0
- remailers-0.1.0a2/PKG-INFO +110 -0
- remailers-0.1.0a2/README.md +81 -0
- remailers-0.1.0a2/pyproject.toml +47 -0
- remailers-0.1.0a2/remailers/__init__.py +26 -0
- remailers-0.1.0a2/remailers/aam.py +56 -0
- remailers-0.1.0a2/remailers/cypherpunk.py +102 -0
- remailers-0.1.0a2/remailers/esub.py +48 -0
- remailers-0.1.0a2/remailers/gpg.py +80 -0
- remailers-0.1.0a2/remailers/hsub.py +60 -0
- remailers-0.1.0a2/remailers/keys.py +106 -0
- remailers-0.1.0a2/remailers/mail.py +105 -0
- remailers-0.1.0a2/remailers/network.py +125 -0
- remailers-0.1.0a2/remailers/utils.py +9 -0
- remailers-0.1.0a2/remailers/version.py +8 -0
- remailers-0.1.0a2/remailers/zax/__init__.py +99 -0
- remailers-0.1.0a2/remailers.egg-info/PKG-INFO +110 -0
- remailers-0.1.0a2/remailers.egg-info/SOURCES.txt +28 -0
- remailers-0.1.0a2/remailers.egg-info/dependency_links.txt +1 -0
- remailers-0.1.0a2/remailers.egg-info/requires.txt +10 -0
- remailers-0.1.0a2/remailers.egg-info/top_level.txt +1 -0
- remailers-0.1.0a2/setup.cfg +4 -0
- remailers-0.1.0a2/test/test_anonbox.py +72 -0
- remailers-0.1.0a2/test/test_cypherpunk.py +69 -0
- remailers-0.1.0a2/test/test_gpg.py +42 -0
- remailers-0.1.0a2/test/test_keys.py +39 -0
- remailers-0.1.0a2/test/test_mail.py +19 -0
- remailers-0.1.0a2/test/test_network.py +57 -0
- remailers-0.1.0a2/test/test_subjects.py +41 -0
- remailers-0.1.0a2/test/test_utils.py +17 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
|
|
17
|
+
Copyright 2026 JarbasAi
|
|
18
|
+
|
|
19
|
+
Full text: https://www.apache.org/licenses/LICENSE-2.0.txt
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remailers
|
|
3
|
+
Version: 0.1.0a2
|
|
4
|
+
Summary: PGP anonymous-message (nym/remailer) toolkit over Usenet and Tor email.
|
|
5
|
+
Author-email: JarbasAi <jarbasai@mailfence.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/JarbasAl/remailers
|
|
8
|
+
Keywords: remailer,nym,anonymity,pgp,usenet,mixmaster,hsub,esub
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: pycryptodomex
|
|
20
|
+
Requires-Dist: PGPy
|
|
21
|
+
Requires-Dist: dateparser
|
|
22
|
+
Requires-Dist: PySocks
|
|
23
|
+
Requires-Dist: requests
|
|
24
|
+
Requires-Dist: usenet
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# remailers
|
|
31
|
+
|
|
32
|
+
A toolkit for **anonymous messaging** with PGP over Usenet and Tor email:
|
|
33
|
+
hashed/encrypted subjects (hSub/eSub), nym-server (ZAX) registration, and
|
|
34
|
+
SOCKS/Tor SMTP. Built on top of [`usenet`](https://github.com/JarbasAl/usenet).
|
|
35
|
+
|
|
36
|
+
> For research and privacy education. The public nym/remailer network is largely
|
|
37
|
+
> historical. Treat the bundled server definitions as starting points.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install remailers
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
Generate (or load) a PGP identity:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from remailers import Credentials
|
|
51
|
+
|
|
52
|
+
creds = Credentials("my_key.asc", name="PythonicAnon")
|
|
53
|
+
print(creds.pubkey)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Hashed and encrypted subjects: let a recipient spot a message meant for them
|
|
57
|
+
without revealing the subject:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from remailers import create_hsub, match_hsub, create_esub, match_esub
|
|
61
|
+
|
|
62
|
+
hsub = create_hsub("evil dolphin captain")
|
|
63
|
+
assert match_hsub(hsub, "evil dolphin captain")
|
|
64
|
+
|
|
65
|
+
esub = create_esub("evil dolphin captain", key="shared-secret")
|
|
66
|
+
assert match_esub("evil dolphin captain", "shared-secret", esub)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Post and retrieve anonymous messages via `alt.anonymous.messages`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from usenet import UsenetServer
|
|
73
|
+
from remailers import Credentials, AnonBox, create_hsub
|
|
74
|
+
|
|
75
|
+
creds = Credentials("my_key.asc")
|
|
76
|
+
hsub = create_hsub("evil dolphin captain")
|
|
77
|
+
ciphertext = creds.encrypt("meet at noon")
|
|
78
|
+
|
|
79
|
+
with UsenetServer("news.neodome.net") as server:
|
|
80
|
+
server.post(ciphertext, hsub, "alt.anonymous.messages")
|
|
81
|
+
|
|
82
|
+
inbox = AnonBox(creds, UsenetServer("news.neodome.net"))
|
|
83
|
+
for article in inbox.retrieve_by_subject("evil dolphin captain"):
|
|
84
|
+
print(article.text)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
ZAX nym servers and Tor email are in `remailers.zax` and `remailers.mail`. See
|
|
88
|
+
`examples/`.
|
|
89
|
+
|
|
90
|
+
## Security notes
|
|
91
|
+
|
|
92
|
+
- Initialization vectors come from `os.urandom` (`remailers.utils.generate_iv`).
|
|
93
|
+
- hSub uses SHA-256. eSub uses Blowfish for Type-I compatibility: it exists for
|
|
94
|
+
interop with the legacy remailer ecosystem, not as modern AEAD.
|
|
95
|
+
- Message bodies are protected by PGP (RSA-4096, AES-256), not by the subject
|
|
96
|
+
scheme.
|
|
97
|
+
|
|
98
|
+
## Testing
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e .[test]
|
|
102
|
+
pytest test/
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Tests are offline: subject round-trips, IV entropy, and a PGP encrypt/decrypt
|
|
106
|
+
cycle with a freshly generated key.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
Apache-2.0
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# remailers
|
|
2
|
+
|
|
3
|
+
A toolkit for **anonymous messaging** with PGP over Usenet and Tor email:
|
|
4
|
+
hashed/encrypted subjects (hSub/eSub), nym-server (ZAX) registration, and
|
|
5
|
+
SOCKS/Tor SMTP. Built on top of [`usenet`](https://github.com/JarbasAl/usenet).
|
|
6
|
+
|
|
7
|
+
> For research and privacy education. The public nym/remailer network is largely
|
|
8
|
+
> historical. Treat the bundled server definitions as starting points.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install remailers
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
Generate (or load) a PGP identity:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from remailers import Credentials
|
|
22
|
+
|
|
23
|
+
creds = Credentials("my_key.asc", name="PythonicAnon")
|
|
24
|
+
print(creds.pubkey)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Hashed and encrypted subjects: let a recipient spot a message meant for them
|
|
28
|
+
without revealing the subject:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from remailers import create_hsub, match_hsub, create_esub, match_esub
|
|
32
|
+
|
|
33
|
+
hsub = create_hsub("evil dolphin captain")
|
|
34
|
+
assert match_hsub(hsub, "evil dolphin captain")
|
|
35
|
+
|
|
36
|
+
esub = create_esub("evil dolphin captain", key="shared-secret")
|
|
37
|
+
assert match_esub("evil dolphin captain", "shared-secret", esub)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Post and retrieve anonymous messages via `alt.anonymous.messages`:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from usenet import UsenetServer
|
|
44
|
+
from remailers import Credentials, AnonBox, create_hsub
|
|
45
|
+
|
|
46
|
+
creds = Credentials("my_key.asc")
|
|
47
|
+
hsub = create_hsub("evil dolphin captain")
|
|
48
|
+
ciphertext = creds.encrypt("meet at noon")
|
|
49
|
+
|
|
50
|
+
with UsenetServer("news.neodome.net") as server:
|
|
51
|
+
server.post(ciphertext, hsub, "alt.anonymous.messages")
|
|
52
|
+
|
|
53
|
+
inbox = AnonBox(creds, UsenetServer("news.neodome.net"))
|
|
54
|
+
for article in inbox.retrieve_by_subject("evil dolphin captain"):
|
|
55
|
+
print(article.text)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
ZAX nym servers and Tor email are in `remailers.zax` and `remailers.mail`. See
|
|
59
|
+
`examples/`.
|
|
60
|
+
|
|
61
|
+
## Security notes
|
|
62
|
+
|
|
63
|
+
- Initialization vectors come from `os.urandom` (`remailers.utils.generate_iv`).
|
|
64
|
+
- hSub uses SHA-256. eSub uses Blowfish for Type-I compatibility: it exists for
|
|
65
|
+
interop with the legacy remailer ecosystem, not as modern AEAD.
|
|
66
|
+
- Message bodies are protected by PGP (RSA-4096, AES-256), not by the subject
|
|
67
|
+
scheme.
|
|
68
|
+
|
|
69
|
+
## Testing
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e .[test]
|
|
73
|
+
pytest test/
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Tests are offline: subject round-trips, IV entropy, and a PGP encrypt/decrypt
|
|
77
|
+
cycle with a freshly generated key.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remailers"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "PGP anonymous-message (nym/remailer) toolkit over Usenet and Tor email."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [{name = "JarbasAi", email = "jarbasai@mailfence.com"}]
|
|
13
|
+
keywords = ["remailer", "nym", "anonymity", "pgp", "usenet", "mixmaster", "hsub", "esub"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Communications :: Email",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pycryptodomex",
|
|
25
|
+
"PGPy",
|
|
26
|
+
"dateparser",
|
|
27
|
+
"PySocks",
|
|
28
|
+
"requests",
|
|
29
|
+
"usenet",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest>=7", "pytest-cov"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/JarbasAl/remailers"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = {attr = "remailers.version.__version__"}
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
include = ["remailers*"]
|
|
43
|
+
exclude = ["test*", "examples*", "docs*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["test"]
|
|
47
|
+
addopts = "-q"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from remailers.esub import match_esub, create_esub
|
|
2
|
+
from remailers.hsub import match_hsub, create_hsub
|
|
3
|
+
from remailers.keys import Credentials
|
|
4
|
+
from remailers.aam import AnonBox
|
|
5
|
+
from remailers.network import Remailer, fetch_live_remailers, parse_stats
|
|
6
|
+
from remailers.cypherpunk import build_chain, final_request, wrap_encrypted
|
|
7
|
+
from remailers.gpg import GPGKeyring, gpg_available
|
|
8
|
+
from remailers.version import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"match_esub",
|
|
12
|
+
"create_esub",
|
|
13
|
+
"match_hsub",
|
|
14
|
+
"create_hsub",
|
|
15
|
+
"Credentials",
|
|
16
|
+
"AnonBox",
|
|
17
|
+
"Remailer",
|
|
18
|
+
"fetch_live_remailers",
|
|
19
|
+
"parse_stats",
|
|
20
|
+
"build_chain",
|
|
21
|
+
"final_request",
|
|
22
|
+
"wrap_encrypted",
|
|
23
|
+
"GPGKeyring",
|
|
24
|
+
"gpg_available",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from remailers.keys import PGPError
|
|
2
|
+
from remailers import match_esub, match_hsub
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AnonBox:
|
|
6
|
+
GROUP = 'alt.anonymous.messages'
|
|
7
|
+
|
|
8
|
+
def __init__(self, creds, usenet_server, esub_key=None):
|
|
9
|
+
self.creds = creds
|
|
10
|
+
self.usenet_server = usenet_server
|
|
11
|
+
self.esub_key = esub_key
|
|
12
|
+
|
|
13
|
+
def _decrypt_into(self, article):
|
|
14
|
+
"""Decrypt the article body in place; return True on success."""
|
|
15
|
+
decrypted = self.creds.decrypt(article.text)
|
|
16
|
+
article._body = [line.encode("utf-8")
|
|
17
|
+
for line in decrypted.split("\n")]
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
def retrieve(self, limit=50):
|
|
21
|
+
"""Pull the latest `limit` messages and keep the ones we can decrypt.
|
|
22
|
+
|
|
23
|
+
Browses by GROUP rather than NEWNEWS, which public servers disable.
|
|
24
|
+
"""
|
|
25
|
+
articles = []
|
|
26
|
+
with self.usenet_server as server:
|
|
27
|
+
for article in server.get_articles(self.GROUP, limit=limit):
|
|
28
|
+
if "BEGIN PGP MESSAGE" not in article.text:
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
self._decrypt_into(article)
|
|
32
|
+
articles.append(article)
|
|
33
|
+
except (PGPError, ValueError):
|
|
34
|
+
continue
|
|
35
|
+
return articles
|
|
36
|
+
|
|
37
|
+
def retrieve_by_subject(self, subject, limit=200, esubs=True, hsubs=True):
|
|
38
|
+
"""Keep messages whose subject matches `subject` directly or via an
|
|
39
|
+
hSub/eSub, and that decrypt with our key."""
|
|
40
|
+
articles = []
|
|
41
|
+
with self.usenet_server as server:
|
|
42
|
+
for article in server.get_articles(self.GROUP, limit=limit):
|
|
43
|
+
matched = (
|
|
44
|
+
subject == article.subject
|
|
45
|
+
or (hsubs and match_hsub(article.subject, subject))
|
|
46
|
+
or (esubs and self.esub_key
|
|
47
|
+
and match_esub(subject, self.esub_key, article.subject))
|
|
48
|
+
)
|
|
49
|
+
if not matched:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
self._decrypt_into(article)
|
|
53
|
+
articles.append(article)
|
|
54
|
+
except (PGPError, ValueError):
|
|
55
|
+
continue # subject matched, but not encrypted to our key
|
|
56
|
+
return articles
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Build Type-I (Cypherpunk) remailer messages.
|
|
2
|
+
|
|
3
|
+
A Cypherpunk remailer reads a message whose body is a "pasting-token" block:
|
|
4
|
+
|
|
5
|
+
::
|
|
6
|
+
Encrypted: PGP
|
|
7
|
+
|
|
8
|
+
-----BEGIN PGP MESSAGE-----
|
|
9
|
+
...
|
|
10
|
+
-----END PGP MESSAGE-----
|
|
11
|
+
|
|
12
|
+
Decrypting that yields another ``::`` block of instructions — ``Anon-To:`` to
|
|
13
|
+
relay to the next hop or final recipient, ``Anon-Post-To:`` to post to a
|
|
14
|
+
newsgroup, ``Latent-Time:`` to delay. Nesting these, each layer encrypted to the
|
|
15
|
+
next remailer's key, forms the onion that hides the path. This module assembles
|
|
16
|
+
that onion; sending the entry message is left to :mod:`remailers.mail`.
|
|
17
|
+
"""
|
|
18
|
+
from typing import List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
from remailers.keys import encrypt_text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _block(headers: dict, body: str = "") -> str:
|
|
24
|
+
lines = ["::"]
|
|
25
|
+
for key, value in headers.items():
|
|
26
|
+
lines.append(f"{key}: {value}")
|
|
27
|
+
lines.append("") # blank line terminates the header block
|
|
28
|
+
if body:
|
|
29
|
+
lines.append(body)
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def final_request(dest: Optional[str] = None, anon_post_to: Optional[str] = None,
|
|
34
|
+
body: str = "", latent: Optional[str] = None,
|
|
35
|
+
extra_headers: Optional[dict] = None) -> str:
|
|
36
|
+
"""Build the innermost instruction block delivered by the exit remailer.
|
|
37
|
+
|
|
38
|
+
Provide ``dest`` (email) or ``anon_post_to`` (newsgroup).
|
|
39
|
+
"""
|
|
40
|
+
if not dest and not anon_post_to:
|
|
41
|
+
raise ValueError("final_request needs dest or anon_post_to")
|
|
42
|
+
headers = {}
|
|
43
|
+
if dest:
|
|
44
|
+
headers["Anon-To"] = dest
|
|
45
|
+
if anon_post_to:
|
|
46
|
+
headers["Anon-Post-To"] = anon_post_to
|
|
47
|
+
if latent:
|
|
48
|
+
headers["Latent-Time"] = latent
|
|
49
|
+
if extra_headers:
|
|
50
|
+
headers.update(extra_headers)
|
|
51
|
+
return _block(headers, body)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def wrap_encrypted(remailer_key, inner: str) -> str:
|
|
55
|
+
"""Encrypt ``inner`` to a remailer's PGP key and wrap it for that remailer."""
|
|
56
|
+
ciphertext = encrypt_text(remailer_key, inner)
|
|
57
|
+
return _block({"Encrypted": "PGP"}, ciphertext)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_chain(hops: Sequence[Tuple[str, object]], dest: Optional[str] = None,
|
|
61
|
+
anon_post_to: Optional[str] = None, body: str = "",
|
|
62
|
+
latent: Optional[str] = None, encrypt=None) -> Tuple[str, str]:
|
|
63
|
+
"""Assemble a nested Cypherpunk message through a chain of remailers.
|
|
64
|
+
|
|
65
|
+
``hops`` is an ordered sequence of ``(address, recipient)`` from entry to
|
|
66
|
+
exit. ``encrypt(recipient, plaintext) -> armored`` does each layer; it
|
|
67
|
+
defaults to PGPy (``recipient`` is a ``PGPKey``). To reach the real network
|
|
68
|
+
(DSA/ElGamal keys), pass a GnuPG encryptor — e.g. ``GPGKeyring.encrypt`` with
|
|
69
|
+
``recipient`` set to each remailer's address. Returns ``(message,
|
|
70
|
+
entry_address)`` — send ``message`` to ``entry_address``.
|
|
71
|
+
"""
|
|
72
|
+
if not hops:
|
|
73
|
+
raise ValueError("need at least one remailer hop")
|
|
74
|
+
if encrypt is None:
|
|
75
|
+
encrypt = encrypt_text
|
|
76
|
+
inner = final_request(dest=dest, anon_post_to=anon_post_to,
|
|
77
|
+
body=body, latent=latent)
|
|
78
|
+
message = None
|
|
79
|
+
next_addr = None
|
|
80
|
+
# build from the exit hop inward
|
|
81
|
+
for address, recipient in reversed(list(hops)):
|
|
82
|
+
if message is None:
|
|
83
|
+
payload = inner # exit hop delivers to dest
|
|
84
|
+
else:
|
|
85
|
+
payload = _block({"Anon-To": next_addr}, message) # relay to next hop
|
|
86
|
+
message = _block({"Encrypted": "PGP"}, encrypt(recipient, payload))
|
|
87
|
+
next_addr = address
|
|
88
|
+
return message, hops[0][0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def send_chain(message: str, entry_address: str, user: str, password: str,
|
|
92
|
+
host: str = "mail.smtp2go.com", port: int = 465,
|
|
93
|
+
tor: bool = False) -> None:
|
|
94
|
+
"""Deliver an assembled message to the entry remailer over SMTP.
|
|
95
|
+
|
|
96
|
+
Needs an email sender (any account, or a Tor-routed SMTP with ``tor=True``).
|
|
97
|
+
"""
|
|
98
|
+
from remailers.mail import send_email, send_tor_email
|
|
99
|
+
|
|
100
|
+
sender = send_tor_email if tor else send_email
|
|
101
|
+
sender(user, password, entry_address, subject="", contents=message,
|
|
102
|
+
host=host, port=port)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
try:
|
|
2
|
+
# pycryptodomex
|
|
3
|
+
from Cryptodome.Cipher import Blowfish
|
|
4
|
+
except ImportError:
|
|
5
|
+
# pycrypto + pycryptodome
|
|
6
|
+
from Crypto.Cipher import Blowfish
|
|
7
|
+
|
|
8
|
+
from hashlib import md5
|
|
9
|
+
from remailers.utils import generate_iv
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_esub(text, key, iv=None):
|
|
13
|
+
"""Produce a 192bit Encrypted Subject. The first 64 bits are the
|
|
14
|
+
Initialization vector used in the Blowfish CFB Mode. The Subject text
|
|
15
|
+
is MD5 hashed and then encrypted using an MD5 hash of the Key."""
|
|
16
|
+
texthash = md5(text.encode("utf-8")).digest()
|
|
17
|
+
keyhash = md5(key.encode("utf-8")).digest()
|
|
18
|
+
if iv is None:
|
|
19
|
+
iv = generate_iv(8)
|
|
20
|
+
crypt1 = Blowfish.new(keyhash,
|
|
21
|
+
Blowfish.MODE_OFB, iv).encrypt(texthash)[:8]
|
|
22
|
+
crypt2 = Blowfish.new(keyhash,
|
|
23
|
+
Blowfish.MODE_OFB, crypt1).encrypt(texthash[8:])
|
|
24
|
+
return (iv + crypt1 + crypt2).hex()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def match_esub(text, key, esub):
|
|
28
|
+
"""Extract the IV from a passed eSub and generate another based on it,
|
|
29
|
+
using a passed Subject and Key. If the resulting eSub collides with
|
|
30
|
+
the supplied one, return True."""
|
|
31
|
+
# All eSubs should be 48 bytes long
|
|
32
|
+
if len(esub) != 48:
|
|
33
|
+
return False
|
|
34
|
+
# The 64bit IV is hex encoded (16 digits) at the start of the esub.
|
|
35
|
+
try:
|
|
36
|
+
iv = bytes.fromhex(esub[:16])
|
|
37
|
+
except TypeError:
|
|
38
|
+
return False
|
|
39
|
+
return create_esub(text, key, iv) == esub
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
key = "key"
|
|
44
|
+
subject = "text"
|
|
45
|
+
# new IV for each esub
|
|
46
|
+
esubs = [create_esub(subject, key) for i in range(10)]
|
|
47
|
+
for sub in esubs:
|
|
48
|
+
print(sub, match_esub(subject, key, sub))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""GnuPG backend for encrypting to real remailer keys.
|
|
2
|
+
|
|
3
|
+
The live remailer network uses DSA primary keys with ElGamal encryption
|
|
4
|
+
subkeys. PGPy cannot encrypt to ElGamal, so messages destined for actual
|
|
5
|
+
remailers are encrypted by shelling out to GnuPG, which handles those keys
|
|
6
|
+
natively. Our own RSA identity (remailers.keys.Credentials) still uses PGPy.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def gpg_available() -> bool:
|
|
16
|
+
"""True if a usable `gpg` binary is on PATH."""
|
|
17
|
+
exe = shutil.which("gpg")
|
|
18
|
+
if not exe:
|
|
19
|
+
return False
|
|
20
|
+
try:
|
|
21
|
+
subprocess.run([exe, "--version"], capture_output=True, check=True)
|
|
22
|
+
return True
|
|
23
|
+
except (OSError, subprocess.CalledProcessError):
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GPGKeyring:
|
|
28
|
+
"""A throwaway GnuPG home seeded with a keyring blob.
|
|
29
|
+
|
|
30
|
+
Use as a context manager; the temporary home is removed on exit.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, keyring_blob: str, gpg_bin: Optional[str] = None):
|
|
34
|
+
self.gpg = gpg_bin or shutil.which("gpg")
|
|
35
|
+
if not self.gpg:
|
|
36
|
+
raise RuntimeError("gpg not found on PATH")
|
|
37
|
+
self.home = tempfile.mkdtemp(prefix="remailers-gpg-")
|
|
38
|
+
self._import(keyring_blob)
|
|
39
|
+
|
|
40
|
+
def _run(self, args: List[str], data: Optional[bytes] = None) -> subprocess.CompletedProcess:
|
|
41
|
+
env = dict(os.environ, GNUPGHOME=self.home)
|
|
42
|
+
return subprocess.run(
|
|
43
|
+
[self.gpg, "--batch", "--no-tty", "--yes"] + args,
|
|
44
|
+
input=data, capture_output=True, env=env)
|
|
45
|
+
|
|
46
|
+
def _import(self, blob: str) -> None:
|
|
47
|
+
result = self._run(["--import"], blob.encode("utf-8", "replace"))
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
raise RuntimeError(f"gpg import failed: {result.stderr.decode()[-200:]}")
|
|
50
|
+
|
|
51
|
+
def recipients(self) -> List[str]:
|
|
52
|
+
"""Email addresses of imported keys."""
|
|
53
|
+
result = self._run(["--list-keys", "--with-colons"])
|
|
54
|
+
emails = []
|
|
55
|
+
for line in result.stdout.decode("utf-8", "replace").splitlines():
|
|
56
|
+
if line.startswith("uid"):
|
|
57
|
+
uid = line.split(":")[9]
|
|
58
|
+
if "<" in uid and ">" in uid:
|
|
59
|
+
emails.append(uid.split("<", 1)[1].split(">", 1)[0])
|
|
60
|
+
return emails
|
|
61
|
+
|
|
62
|
+
def encrypt(self, recipient: str, plaintext: str) -> str:
|
|
63
|
+
"""Return an ASCII-armored message encrypted to `recipient`."""
|
|
64
|
+
data = plaintext.encode("utf-8") if isinstance(plaintext, str) else plaintext
|
|
65
|
+
result = self._run(
|
|
66
|
+
["--trust-model", "always", "--armor",
|
|
67
|
+
"--encrypt", "--recipient", recipient], data)
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"gpg encrypt to {recipient} failed: {result.stderr.decode()[-200:]}")
|
|
71
|
+
return result.stdout.decode("utf-8")
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
shutil.rmtree(self.home, ignore_errors=True)
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> "GPGKeyring":
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
80
|
+
self.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
from remailers.utils import generate_iv
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_hsub(text, iv=None, hsublen=48):
|
|
6
|
+
"""Create an hSub (Hashed Subject). This is constructed as:
|
|
7
|
+
--------------------------------------
|
|
8
|
+
| 64bit iv | 256bit SHA2 'iv + text' |
|
|
9
|
+
--------------------------------------"""
|
|
10
|
+
# Generate a 64bit random IV if none is provided.
|
|
11
|
+
if iv is None:
|
|
12
|
+
iv = generate_iv()
|
|
13
|
+
if isinstance(iv, str):
|
|
14
|
+
iv = iv
|
|
15
|
+
hashed = sha256(iv + text.encode("utf-8")).digest()
|
|
16
|
+
# Concatenate our IV with a SHA256 hash of text + IV.
|
|
17
|
+
hsub = iv + hashed
|
|
18
|
+
return hsub.hex()[:hsublen]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def match_hsub(hsub, subject):
|
|
22
|
+
"""Create an hSub using a known iv, (stripped from a passed hSub). If
|
|
23
|
+
the supplied and generated hSub's collide, the message is probably for
|
|
24
|
+
us."""
|
|
25
|
+
# We are prepared to check variable length hsubs within boundaries.
|
|
26
|
+
# The low bound is the current Type-I esub length. The high bound
|
|
27
|
+
# is the 256 bits within SHA2-256.
|
|
28
|
+
hsublen = len(hsub)
|
|
29
|
+
# 48 digits = 192 bit hsub, the smallest we allow.
|
|
30
|
+
# 80 digits = 320 bit hsub, the full length of SHA256 + 64 bit IV
|
|
31
|
+
if hsublen < 48 or hsublen > 80:
|
|
32
|
+
return False
|
|
33
|
+
iv = iv_from_hsub(hsub)
|
|
34
|
+
if not iv:
|
|
35
|
+
return False
|
|
36
|
+
# Return True if our generated hSub collides with the supplied
|
|
37
|
+
# sample.
|
|
38
|
+
return create_hsub(subject, iv, hsublen) == hsub
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def iv_from_hsub(hsub, digits=16):
|
|
42
|
+
"""Return the decoded IV from an hsub. By default the IV is the first
|
|
43
|
+
64bits of the hsub. As it's hex encoded, this equates to 16 digits."""
|
|
44
|
+
# We don't want to process IVs of inadequate length.
|
|
45
|
+
if len(hsub) < digits:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
return bytes.fromhex(hsub[:digits])
|
|
49
|
+
except:
|
|
50
|
+
# Not all Subjects are hSub'd
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
subject = "captain dolphin"
|
|
56
|
+
print("Subject: " + subject)
|
|
57
|
+
print("Should return True: %s" % match_hsub(create_hsub(subject),
|
|
58
|
+
subject))
|
|
59
|
+
print("Should return False: %s" % match_hsub(create_hsub("subject"),
|
|
60
|
+
subject))
|