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.
- transwarpconduit-0.1.0/LICENSE +21 -0
- transwarpconduit-0.1.0/PKG-INFO +115 -0
- transwarpconduit-0.1.0/README.md +89 -0
- transwarpconduit-0.1.0/pyproject.toml +74 -0
- transwarpconduit-0.1.0/setup.cfg +4 -0
- transwarpconduit-0.1.0/transwarpconduit/__init__.py +0 -0
- transwarpconduit-0.1.0/transwarpconduit/addressing.py +80 -0
- transwarpconduit-0.1.0/transwarpconduit/auth.py +113 -0
- transwarpconduit-0.1.0/transwarpconduit/cli.py +217 -0
- transwarpconduit-0.1.0/transwarpconduit/client.py +69 -0
- transwarpconduit-0.1.0/transwarpconduit/crypto.py +288 -0
- transwarpconduit-0.1.0/transwarpconduit/daemon.py +358 -0
- transwarpconduit-0.1.0/transwarpconduit/enroll.py +1728 -0
- transwarpconduit-0.1.0/transwarpconduit/errors.py +35 -0
- transwarpconduit-0.1.0/transwarpconduit/groups.py +120 -0
- transwarpconduit-0.1.0/transwarpconduit/identity.py +187 -0
- transwarpconduit-0.1.0/transwarpconduit/injectors.py +274 -0
- transwarpconduit-0.1.0/transwarpconduit/keycache.py +200 -0
- transwarpconduit-0.1.0/transwarpconduit/mcp_server.py +368 -0
- transwarpconduit-0.1.0/transwarpconduit/nexus.py +774 -0
- transwarpconduit-0.1.0/transwarpconduit/nexuspost.py +106 -0
- transwarpconduit-0.1.0/transwarpconduit/observe.py +288 -0
- transwarpconduit-0.1.0/transwarpconduit/provision.py +930 -0
- transwarpconduit-0.1.0/transwarpconduit/server.py +280 -0
- transwarpconduit-0.1.0/transwarpconduit/tenancy.py +129 -0
- transwarpconduit-0.1.0/transwarpconduit/transport.py +216 -0
- transwarpconduit-0.1.0/transwarpconduit/wire.py +67 -0
- transwarpconduit-0.1.0/transwarpconduit/wslink.py +154 -0
- transwarpconduit-0.1.0/transwarpconduit/wsnexus.py +162 -0
- transwarpconduit-0.1.0/transwarpconduit/wsserver.py +153 -0
- transwarpconduit-0.1.0/transwarpconduit.egg-info/PKG-INFO +115 -0
- transwarpconduit-0.1.0/transwarpconduit.egg-info/SOURCES.txt +34 -0
- transwarpconduit-0.1.0/transwarpconduit.egg-info/dependency_links.txt +1 -0
- transwarpconduit-0.1.0/transwarpconduit.egg-info/entry_points.txt +3 -0
- transwarpconduit-0.1.0/transwarpconduit.egg-info/requires.txt +5 -0
- 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*"]
|
|
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)))
|