transwarpconduit 0.1.0__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.
Files changed (36) hide show
  1. transwarpconduit-0.1.0/LICENSE +21 -0
  2. transwarpconduit-0.1.0/PKG-INFO +115 -0
  3. transwarpconduit-0.1.0/README.md +89 -0
  4. transwarpconduit-0.1.0/pyproject.toml +74 -0
  5. transwarpconduit-0.1.0/setup.cfg +4 -0
  6. transwarpconduit-0.1.0/transwarpconduit/__init__.py +0 -0
  7. transwarpconduit-0.1.0/transwarpconduit/addressing.py +80 -0
  8. transwarpconduit-0.1.0/transwarpconduit/auth.py +113 -0
  9. transwarpconduit-0.1.0/transwarpconduit/cli.py +217 -0
  10. transwarpconduit-0.1.0/transwarpconduit/client.py +69 -0
  11. transwarpconduit-0.1.0/transwarpconduit/crypto.py +288 -0
  12. transwarpconduit-0.1.0/transwarpconduit/daemon.py +358 -0
  13. transwarpconduit-0.1.0/transwarpconduit/enroll.py +1728 -0
  14. transwarpconduit-0.1.0/transwarpconduit/errors.py +35 -0
  15. transwarpconduit-0.1.0/transwarpconduit/groups.py +120 -0
  16. transwarpconduit-0.1.0/transwarpconduit/identity.py +187 -0
  17. transwarpconduit-0.1.0/transwarpconduit/injectors.py +274 -0
  18. transwarpconduit-0.1.0/transwarpconduit/keycache.py +200 -0
  19. transwarpconduit-0.1.0/transwarpconduit/mcp_server.py +368 -0
  20. transwarpconduit-0.1.0/transwarpconduit/nexus.py +774 -0
  21. transwarpconduit-0.1.0/transwarpconduit/nexuspost.py +106 -0
  22. transwarpconduit-0.1.0/transwarpconduit/observe.py +288 -0
  23. transwarpconduit-0.1.0/transwarpconduit/provision.py +930 -0
  24. transwarpconduit-0.1.0/transwarpconduit/server.py +280 -0
  25. transwarpconduit-0.1.0/transwarpconduit/tenancy.py +129 -0
  26. transwarpconduit-0.1.0/transwarpconduit/transport.py +216 -0
  27. transwarpconduit-0.1.0/transwarpconduit/wire.py +67 -0
  28. transwarpconduit-0.1.0/transwarpconduit/wslink.py +154 -0
  29. transwarpconduit-0.1.0/transwarpconduit/wsnexus.py +162 -0
  30. transwarpconduit-0.1.0/transwarpconduit/wsserver.py +153 -0
  31. transwarpconduit-0.1.0/transwarpconduit.egg-info/PKG-INFO +115 -0
  32. transwarpconduit-0.1.0/transwarpconduit.egg-info/SOURCES.txt +34 -0
  33. transwarpconduit-0.1.0/transwarpconduit.egg-info/dependency_links.txt +1 -0
  34. transwarpconduit-0.1.0/transwarpconduit.egg-info/entry_points.txt +3 -0
  35. transwarpconduit-0.1.0/transwarpconduit.egg-info/requires.txt +5 -0
  36. transwarpconduit-0.1.0/transwarpconduit.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 transwarpconduit.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: transwarpconduit
3
+ Version: 0.1.0
4
+ Summary: Addressed, end-to-end encrypted messaging between agents. Four outcomes, no silence.
5
+ Author-email: transwarp <hello@transwarpconduit.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://transwarpconduit.com
8
+ Keywords: agents,messaging,mcp,end-to-end-encryption,ed25519
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications
17
+ Classifier: Topic :: Security :: Cryptography
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: cryptography>=3.4.0
22
+ Requires-Dist: websocket-client>=1.6
23
+ Provides-Extra: mcp
24
+ Requires-Dist: mcp>=1.2.0; extra == "mcp"
25
+ Dynamic: license-file
26
+
27
+ # transwarpconduit
28
+
29
+ Addressed, end-to-end encrypted messaging between agents — and the people who
30
+ run them. **Four outcomes, no silence.**
31
+
32
+ ```bash
33
+ pip install transwarpconduit
34
+ ```
35
+
36
+ > The import name is `transwarpconduit`, not `transwarp`. `transwarp` on PyPI
37
+ > is an unrelated web framework last released in 2015; installing it will not
38
+ > get you this.
39
+
40
+ ## What it is
41
+
42
+ A message sent through transwarp resolves to an **address**:
43
+
44
+ ```
45
+ twp://<tenant>/<space>/<local> twp://acme/platform/alice
46
+ ```
47
+
48
+ Every send ends in exactly one of four outcomes, and you are always told which:
49
+
50
+ | outcome | meaning |
51
+ |---|---|
52
+ | `consumed` | someone read it |
53
+ | `queued` | nobody is bound yet; it is held until its TTL |
54
+ | `undeliverable` | the TTL expired, or the binding went stale. You are told. |
55
+ | `refused` | it was rejected, and the reason is given |
56
+
57
+ There is no fifth outcome and there is no silence. A refusal is an answer — an
58
+ undelivered message and a message nobody sent look identical from the sender's
59
+ side, and "nobody messaged me today" reads as a quiet day.
60
+
61
+ ## Sending and reading
62
+
63
+ ```bash
64
+ twp --keyfile alice.key --nexus wss://nexus.transwarpconduit.com \
65
+ send --to twp://acme/platform/bob "first message"
66
+
67
+ twp --keyfile bob.key --nexus wss://nexus.transwarpconduit.com inbox
68
+ ```
69
+
70
+ Bodies are sealed to the recipient (X25519 + ChaCha20-Poly1305) and every
71
+ envelope is signed (Ed25519). **The nexus cannot read your messages.** Neither
72
+ can the web console — it has no body-viewing surface at all.
73
+
74
+ Without a keyfile the client refuses to send rather than quietly sending in the
75
+ clear; `--allow-cleartext` exists so that choosing it is a choice.
76
+
77
+ ## Provisioning an address
78
+
79
+ Enrolment is signed by the **tenant key**, so it runs wherever that key is —
80
+ not where the agent runs. An agent cannot vouch for itself; that is what makes
81
+ enrolment checkable rather than trust-on-first-use.
82
+
83
+ ```bash
84
+ twp-provision --tenant-key ./acme.root.key \
85
+ --addr twp://acme/platform/alice --out alice.key \
86
+ --nexus https://nexus.transwarpconduit.com
87
+ ```
88
+
89
+ Pipe the key from a secret store (`--tenant-key -`) so it never lands on a
90
+ disk. A file mode of 0600 separates a key from other Unix users and from
91
+ nothing else — wherever several processes run as the same uid, read it as
92
+ tidiness rather than as protection.
93
+
94
+ ## MCP server
95
+
96
+ Injects arriving messages into a running agent session.
97
+
98
+ ```bash
99
+ pip install "transwarpconduit[mcp]"
100
+ TRANSWARP_ADDR=twp://acme/platform/alice python3 -m transwarpconduit.mcp_server
101
+ ```
102
+
103
+ **Messages are untrusted input.** This service delivers text written by one
104
+ party into another party's model context; the injector marks it as data rather
105
+ than as instructions, and you should treat it that way too.
106
+
107
+ ## Requirements
108
+
109
+ Python 3.9+. `cryptography` and `websocket-client` are required rather than
110
+ optional, deliberately: a missing crypto dependency would surface as messages
111
+ going out in the clear rather than as an error at install time.
112
+
113
+ ## Links
114
+
115
+ - <https://transwarpconduit.com>
@@ -0,0 +1,89 @@
1
+ # transwarpconduit
2
+
3
+ Addressed, end-to-end encrypted messaging between agents — and the people who
4
+ run them. **Four outcomes, no silence.**
5
+
6
+ ```bash
7
+ pip install transwarpconduit
8
+ ```
9
+
10
+ > The import name is `transwarpconduit`, not `transwarp`. `transwarp` on PyPI
11
+ > is an unrelated web framework last released in 2015; installing it will not
12
+ > get you this.
13
+
14
+ ## What it is
15
+
16
+ A message sent through transwarp resolves to an **address**:
17
+
18
+ ```
19
+ twp://<tenant>/<space>/<local> twp://acme/platform/alice
20
+ ```
21
+
22
+ Every send ends in exactly one of four outcomes, and you are always told which:
23
+
24
+ | outcome | meaning |
25
+ |---|---|
26
+ | `consumed` | someone read it |
27
+ | `queued` | nobody is bound yet; it is held until its TTL |
28
+ | `undeliverable` | the TTL expired, or the binding went stale. You are told. |
29
+ | `refused` | it was rejected, and the reason is given |
30
+
31
+ There is no fifth outcome and there is no silence. A refusal is an answer — an
32
+ undelivered message and a message nobody sent look identical from the sender's
33
+ side, and "nobody messaged me today" reads as a quiet day.
34
+
35
+ ## Sending and reading
36
+
37
+ ```bash
38
+ twp --keyfile alice.key --nexus wss://nexus.transwarpconduit.com \
39
+ send --to twp://acme/platform/bob "first message"
40
+
41
+ twp --keyfile bob.key --nexus wss://nexus.transwarpconduit.com inbox
42
+ ```
43
+
44
+ Bodies are sealed to the recipient (X25519 + ChaCha20-Poly1305) and every
45
+ envelope is signed (Ed25519). **The nexus cannot read your messages.** Neither
46
+ can the web console — it has no body-viewing surface at all.
47
+
48
+ Without a keyfile the client refuses to send rather than quietly sending in the
49
+ clear; `--allow-cleartext` exists so that choosing it is a choice.
50
+
51
+ ## Provisioning an address
52
+
53
+ Enrolment is signed by the **tenant key**, so it runs wherever that key is —
54
+ not where the agent runs. An agent cannot vouch for itself; that is what makes
55
+ enrolment checkable rather than trust-on-first-use.
56
+
57
+ ```bash
58
+ twp-provision --tenant-key ./acme.root.key \
59
+ --addr twp://acme/platform/alice --out alice.key \
60
+ --nexus https://nexus.transwarpconduit.com
61
+ ```
62
+
63
+ Pipe the key from a secret store (`--tenant-key -`) so it never lands on a
64
+ disk. A file mode of 0600 separates a key from other Unix users and from
65
+ nothing else — wherever several processes run as the same uid, read it as
66
+ tidiness rather than as protection.
67
+
68
+ ## MCP server
69
+
70
+ Injects arriving messages into a running agent session.
71
+
72
+ ```bash
73
+ pip install "transwarpconduit[mcp]"
74
+ TRANSWARP_ADDR=twp://acme/platform/alice python3 -m transwarpconduit.mcp_server
75
+ ```
76
+
77
+ **Messages are untrusted input.** This service delivers text written by one
78
+ party into another party's model context; the injector marks it as data rather
79
+ than as instructions, and you should treat it that way too.
80
+
81
+ ## Requirements
82
+
83
+ Python 3.9+. `cryptography` and `websocket-client` are required rather than
84
+ optional, deliberately: a missing crypto dependency would surface as messages
85
+ going out in the clear rather than as an error at install time.
86
+
87
+ ## Links
88
+
89
+ - <https://transwarpconduit.com>
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "transwarpconduit"
7
+ version = "0.1.0"
8
+ description = "Addressed, end-to-end encrypted messaging between agents. Four outcomes, no silence."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ # THE LICENCE FILE IS INSIDE python/, NOT AT THE REPO ROOT, so it actually
13
+ # ships. A LICENSE outside the packaging directory produces metadata that
14
+ # claims a licence over an artefact carrying no licence text -- a real package
15
+ # on PyPI is in exactly that state today. tools/check_package.py REQUIRES
16
+ # LICENSE in both artefacts, so an artefact without the text now fails:
17
+ # absence is invisible to a check that only lists what is permitted.
18
+ license-files = ["LICENSE"]
19
+ # NO `License :: OSI Approved :: MIT License` CLASSIFIER. PEP 639 superseded
20
+ # them and current setuptools REFUSES THE BUILD if both are present, which is
21
+ # the good kind of refusal -- two places to state a licence is two places for
22
+ # them to disagree. The SPDX expression above is the single statement.
23
+ authors = [{ name = "transwarp", email = "hello@transwarpconduit.com" }]
24
+ keywords = ["agents", "messaging", "mcp", "end-to-end-encryption", "ed25519"]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Communications",
34
+ "Topic :: Security :: Cryptography",
35
+ ]
36
+
37
+ # BOTH ARE REQUIRED, NOT EXTRAS, AND THAT IS THE SECURITY POSTURE.
38
+ #
39
+ # The core is stdlib-only and every third-party import in the tree is lazy, so
40
+ # the package imports fine without either of these. That is exactly the danger:
41
+ # a missing dependency would surface as messages going out IN THE CLEAR rather
42
+ # than as an ImportError at install time. "Sending unencrypted has to be chosen,
43
+ # not defaulted into by omitting an argument" is the rule the CLI already
44
+ # enforces with --allow-cleartext; making cryptography optional would let a
45
+ # packaging decision quietly override it.
46
+ dependencies = [
47
+ "cryptography>=3.4.0", # Ed25519 signing, X25519 + ChaCha20-Poly1305 sealing
48
+ "websocket-client>=1.6", # the client transport. Sync, no event loop.
49
+ ]
50
+
51
+ [project.optional-dependencies]
52
+ # The MCP server, for injecting messages into a running agent session.
53
+ mcp = ["mcp>=1.2.0"]
54
+
55
+ [project.scripts]
56
+ twp = "transwarpconduit.cli:main"
57
+ twp-provision = "transwarpconduit.provision:main"
58
+
59
+ [project.urls]
60
+ Homepage = "https://transwarpconduit.com"
61
+ # No Repository or Bug Tracker URL, and their absence is deliberate rather than
62
+ # forgotten: the source lives on a private forge a customer cannot reach, and a
63
+ # link that 404s is worse than no link. Add both the moment there is a public
64
+ # mirror -- a package with no source link is harder to trust, and rightly.
65
+
66
+ # AN ALLOW-LIST, NOT AN EXCLUDE LIST. `find` with an explicit `include` ships
67
+ # what is named and nothing else. A default-exclude list fails OPEN, silently,
68
+ # once, and permanently: PyPI never accepts a re-upload of a version, and a
69
+ # yanked file is still downloadable. The repo root -- CLAUDE.md, tools/, tests/,
70
+ # evidence/ -- is physically outside this directory as the first line of that
71
+ # defence, and this is the second.
72
+ [tool.setuptools.packages.find]
73
+ where = ["."]
74
+ include = ["transwarpconduit*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,80 @@
1
+ """Address parsing and validation.
2
+
3
+ Implements WARP / Spec: address grammar and resolution. The rule that shapes
4
+ this module: normalisation is a REFUSAL, not a fixup. Two spellings of one
5
+ address that compare unequal is an authorisation bug, so we never lowercase or
6
+ repair -- we reject and let the caller see the error.
7
+ """
8
+
9
+ import re
10
+
11
+ SEGMENT = r"[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?"
12
+ LOCAL = r"(?:#|role:)?[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?|\*"
13
+
14
+ _ADDR_RE = re.compile(rf"^twp://({SEGMENT})/({SEGMENT})/({LOCAL})$")
15
+
16
+ RESERVED_LOCALS = frozenset({"_nexus", "_system", "_admin"})
17
+
18
+
19
+ class AddressError(ValueError):
20
+ """An address was refused. Carries why, because 'invalid address' sends the
21
+ caller looking in the wrong place."""
22
+
23
+
24
+ class Address:
25
+ """A parsed, validated address. Immutable and hashable so it can be a dict
26
+ key and a set member without anyone having to remember to normalise first."""
27
+
28
+ __slots__ = ("tenant", "space", "local", "_s")
29
+
30
+ def __init__(self, tenant: str, space: str, local: str):
31
+ self.tenant = tenant
32
+ self.space = space
33
+ self.local = local
34
+ self._s = f"twp://{tenant}/{space}/{local}"
35
+
36
+ @classmethod
37
+ def parse(cls, s: str) -> "Address":
38
+ if not isinstance(s, str):
39
+ raise AddressError(f"address must be a string, got {type(s).__name__}")
40
+ if s != s.lower():
41
+ # Deliberately not s.lower(). See module docstring.
42
+ raise AddressError(
43
+ f"address contains uppercase and is refused rather than folded: {s!r}"
44
+ )
45
+ if not s.isascii():
46
+ raise AddressError(
47
+ f"address contains non-ASCII; confusable characters in a routing "
48
+ f"identity are a homograph attack with delivery attached: {s!r}"
49
+ )
50
+ m = _ADDR_RE.match(s)
51
+ if not m:
52
+ raise AddressError(f"address does not match twp://<tenant>/<space>/<local>: {s!r}")
53
+ tenant, space, local = m.group(1), m.group(2), m.group(3)
54
+ if local in RESERVED_LOCALS:
55
+ raise AddressError(f"local part {local!r} is reserved for the service itself")
56
+ return cls(tenant, space, local)
57
+
58
+ @property
59
+ def is_group(self) -> bool:
60
+ """True for a group address: an explicit topic, a role, or the wildcard.
61
+ A group resolves to a SET OF ADDRESSES, which then each resolve to
62
+ bindings. It is the same lookup with one more hop, not a second
63
+ mechanism."""
64
+ return self.local.startswith("#") or self.local.startswith("role:") or self.local == "*"
65
+
66
+ @property
67
+ def space_key(self) -> tuple:
68
+ return (self.tenant, self.space)
69
+
70
+ def __str__(self) -> str:
71
+ return self._s
72
+
73
+ def __repr__(self) -> str:
74
+ return f"Address({self._s!r})"
75
+
76
+ def __eq__(self, other) -> bool:
77
+ return isinstance(other, Address) and self._s == other._s
78
+
79
+ def __hash__(self) -> int:
80
+ return hash(self._s)
@@ -0,0 +1,113 @@
1
+ """Proving a bind. WARP-27.
2
+
3
+ Before this, the nexus served whoever reached it: anyone could bind to any
4
+ registered address and collect its mail. Enrollment (WARP-26) put signing keys
5
+ on file, which is what makes a proof possible -- an assertion becomes a check.
6
+
7
+ CHALLENGE-RESPONSE, and the shape of it matters:
8
+
9
+ - the NEXUS chooses the challenge. A challenge the binder picks is a
10
+ challenge the binder can pre-compute, which is the same defect as a frame
11
+ delimiter the sender chooses (WARP-19). Twice now, in unrelated places, the
12
+ party being checked must not choose the thing they are checked against.
13
+ - a challenge is single-use and expires. Replay is otherwise free forever.
14
+ - the address is bound INTO the signed bytes, so a valid response for one
15
+ address cannot be presented for another -- the same reason a message
16
+ signature covers the header and not only the payload.
17
+ - no secret crosses the wire in either direction.
18
+
19
+ AND IT REFUSES RATHER THAN DEGRADING. An address with no enrolled key cannot
20
+ bind at all. "No key on file" must never quietly mean "no check performed":
21
+ that is how a security control becomes a no-op that still reports success, and
22
+ this project has already shipped one of those.
23
+ """
24
+
25
+ import json
26
+ import os
27
+ import time
28
+
29
+ from .errors import Refusal
30
+ from .addressing import Address
31
+ from .crypto import CryptoError, canon_json
32
+ from .enroll import EnrollmentError, _verify_raw
33
+
34
+ CHALLENGE_TTL_S = 60.0
35
+
36
+
37
+ class AuthError(Refusal):
38
+ """A bind was refused. Distinct from an enrollment failure and from a
39
+ transport failure: this one means the caller could not prove who it is."""
40
+
41
+
42
+ def challenge_input(addr: str, nonce: str, purpose: str = "bind") -> bytes:
43
+ """What the binder signs. The address travels inside it, so a response
44
+ collected for one address cannot be replayed against another."""
45
+ return canon_json({"p": purpose, "addr": addr, "n": nonce})
46
+
47
+
48
+ class Authenticator:
49
+ """Issues challenges and checks responses. Holds no secrets -- only public
50
+ keys from the directory and the nonces it has issued."""
51
+
52
+ def __init__(self, directory, clock=time.time, ttl_s=CHALLENGE_TTL_S):
53
+ self._dir = directory
54
+ self._clock = clock
55
+ self._ttl = ttl_s
56
+ self._open = {} # nonce -> {"addr", "issued"}
57
+
58
+ def challenge(self, addr) -> dict:
59
+ a = Address.parse(str(addr))
60
+ try:
61
+ self._dir.keys_of(str(a))
62
+ except EnrollmentError as e:
63
+ # Refuse at the FIRST step rather than issuing a challenge nobody
64
+ # can answer. An unenrolled address getting a challenge and then
65
+ # failing looks like a wrong key; refusing here says what is
66
+ # actually true.
67
+ raise AuthError(f"{a} is not enrolled, so a bind cannot be proven: {e}") from e
68
+ nonce = os.urandom(16).hex()
69
+ self._open[nonce] = {"addr": str(a), "issued": self._clock()}
70
+ return {"nonce": nonce, "addr": str(a), "expires_in_s": self._ttl}
71
+
72
+ def verify(self, nonce: str, signature_b64: str) -> str:
73
+ """Consume a challenge. Returns the proven address, or raises.
74
+
75
+ Single-use by construction: the nonce is removed before the signature
76
+ is checked, so a failed attempt cannot be retried against the same
77
+ challenge and a successful one cannot be replayed.
78
+ """
79
+ entry = self._open.pop(nonce, None)
80
+ if entry is None:
81
+ raise AuthError("unknown or already-used challenge")
82
+ if self._clock() - entry["issued"] > self._ttl:
83
+ raise AuthError("challenge expired")
84
+
85
+ keys = self._dir.keys_of(entry["addr"])
86
+ try:
87
+ ok = _verify_raw(keys["signing_pub"],
88
+ challenge_input(entry["addr"], nonce),
89
+ signature_b64)
90
+ except CryptoError as e:
91
+ raise AuthError(f"enrolled signing key is unusable: {e}") from e
92
+ if not ok:
93
+ raise AuthError(f"signature does not match the key enrolled for {entry['addr']}")
94
+ return entry["addr"]
95
+
96
+ def open_count(self) -> int:
97
+ """Outstanding challenges. Exposed because an unbounded pile of them is
98
+ a memory leak an attacker can drive, and a number nobody can see is a
99
+ number nobody will notice growing."""
100
+ return len(self._open)
101
+
102
+ def expire(self) -> int:
103
+ now = self._clock()
104
+ dead = [n for n, e in self._open.items() if now - e["issued"] > self._ttl]
105
+ for n in dead:
106
+ del self._open[n]
107
+ return len(dead)
108
+
109
+
110
+ def respond(addr: str, nonce: str, signing_key) -> str:
111
+ """The binder's half. Signs the challenge with the address's enrolled key."""
112
+ from .crypto import _b64
113
+ return _b64(signing_key._sk.sign(challenge_input(str(addr), nonce)))