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.
@@ -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))