ecosemantic-relay 0.1.14__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.
- ecosemantic_relay-0.1.14/.gitignore +29 -0
- ecosemantic_relay-0.1.14/LICENSE +73 -0
- ecosemantic_relay-0.1.14/NOTICE +13 -0
- ecosemantic_relay-0.1.14/PKG-INFO +29 -0
- ecosemantic_relay-0.1.14/README.md +1 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/__init__.py +3 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/discovery.py +50 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/errors.py +11 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/frames.py +45 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/messages.py +102 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/proof.py +154 -0
- ecosemantic_relay-0.1.14/ecosemantic/relay/py.typed +0 -0
- ecosemantic_relay-0.1.14/pyproject.toml +39 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
.cache/
|
|
8
|
+
build/
|
|
9
|
+
node_modules/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
coverage.json
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
19
|
+
!.env.example
|
|
20
|
+
|
|
21
|
+
.DS_Store
|
|
22
|
+
graphify-out
|
|
23
|
+
knowledge/
|
|
24
|
+
.okf-cache.json
|
|
25
|
+
**/ecosemantic/view/static/
|
|
26
|
+
|
|
27
|
+
installer/windows/defines.iss
|
|
28
|
+
|
|
29
|
+
/payload/
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
12
|
+
|
|
13
|
+
## Distribution License
|
|
14
|
+
|
|
15
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
16
|
+
|
|
17
|
+
## Notices
|
|
18
|
+
|
|
19
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
20
|
+
|
|
21
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
22
|
+
|
|
23
|
+
## Changes and New Works License
|
|
24
|
+
|
|
25
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
26
|
+
|
|
27
|
+
## Patent License
|
|
28
|
+
|
|
29
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
30
|
+
|
|
31
|
+
## Noncommercial Purposes
|
|
32
|
+
|
|
33
|
+
Any noncommercial purpose is a permitted purpose.
|
|
34
|
+
|
|
35
|
+
## Personal Uses
|
|
36
|
+
|
|
37
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
38
|
+
|
|
39
|
+
## Noncommercial Organizations
|
|
40
|
+
|
|
41
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
42
|
+
|
|
43
|
+
## Fair Use
|
|
44
|
+
|
|
45
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
46
|
+
|
|
47
|
+
## No Other Rights
|
|
48
|
+
|
|
49
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
50
|
+
|
|
51
|
+
## Patent Defense
|
|
52
|
+
|
|
53
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
54
|
+
|
|
55
|
+
## Violations
|
|
56
|
+
|
|
57
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
58
|
+
|
|
59
|
+
## No Liability
|
|
60
|
+
|
|
61
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
62
|
+
|
|
63
|
+
## Definitions
|
|
64
|
+
|
|
65
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
66
|
+
|
|
67
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
68
|
+
|
|
69
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
70
|
+
|
|
71
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
72
|
+
|
|
73
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
EcoSemantic
|
|
2
|
+
Required Notice: Copyright 2026 Carlos Gaete-Morales (https://github.com/cdgaete/ecosemantic)
|
|
3
|
+
|
|
4
|
+
EcoSemantic is licensed under the PolyForm Noncommercial License 1.0.0, in LICENSE
|
|
5
|
+
and at https://polyformproject.org/licenses/noncommercial/1.0.0. Any noncommercial
|
|
6
|
+
purpose is permitted. Commercial use needs a separate licence from Carlos
|
|
7
|
+
Gaete-Morales: cdgaete@gmail.com.
|
|
8
|
+
|
|
9
|
+
The page that ecosemantic-view carries bundles third-party software under its
|
|
10
|
+
own licences; their notices are in ecosemantic/view/static/THIRD_PARTY_NOTICES.txt
|
|
11
|
+
inside that distribution. Components of the page adapt shadcn/ui, Copyright (c)
|
|
12
|
+
2023 shadcn, under the MIT licence, as page/PROVENANCE.md in the source
|
|
13
|
+
repository records.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ecosemantic-relay
|
|
3
|
+
Version: 0.1.14
|
|
4
|
+
Summary: The EcoSemantic bridge tunnel: its messages, its frames, the bridge's discovery document and the Ed25519 proof a bridged request carries.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cdgaete/ecosemantic
|
|
6
|
+
Project-URL: Repository, https://github.com/cdgaete/ecosemantic
|
|
7
|
+
Project-URL: Issues, https://github.com/cdgaete/ecosemantic/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
|
|
10
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Keywords: Ed25519,LCA,bridge,life cycle assessment,tunnel
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: cryptography<51,>=44
|
|
25
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
26
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
`ecosemantic-relay` holds the tunnel between a hosted bridge and an engine: the messages its text frames carry — the engine's hello, the bridge's welcome, a request's and a response's start, and a stream's reset — the binary frames that carry a stream's body in chunks of at most 64 KiB, the document the bridge publishes at its well-known address with what an engine registers and is registered as, and the Ed25519 proof a bridged request carries with the JWKs the bridge publishes to check it. The bridge service imports it for both sides of the tunnel, and `ecosemantic` depends on it for `ecosemantic serve --bridge`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
`ecosemantic-relay` holds the tunnel between a hosted bridge and an engine: the messages its text frames carry — the engine's hello, the bridge's welcome, a request's and a response's start, and a stream's reset — the binary frames that carry a stream's body in chunks of at most 64 KiB, the document the bridge publishes at its well-known address with what an engine registers and is registered as, and the Ed25519 proof a bridged request carries with the JWKs the bridge publishes to check it. The bridge service imports it for both sides of the tunnel, and `ecosemantic` depends on it for `ecosemantic serve --bridge`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What the bridge publishes at its well-known address, and what an engine registers as and is
|
|
2
|
+
registered with."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
WELL_KNOWN = "/.well-known/ecosemantic-bridge"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Document(BaseModel):
|
|
14
|
+
"""One document of the bridge's discovery, frozen: what the bridge answers and what an engine
|
|
15
|
+
sends it."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(frozen=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BridgeConfiguration(_Document):
|
|
21
|
+
"""The bridge's discovery document: the issuer, the browser's and the device's client ids, the
|
|
22
|
+
tunnel's resource, scope and address, and the connector's and the registration's addresses."""
|
|
23
|
+
|
|
24
|
+
issuer: str
|
|
25
|
+
browser_client_id: str
|
|
26
|
+
device_client_id: str
|
|
27
|
+
tunnel_resource: str
|
|
28
|
+
tunnel_scope: str
|
|
29
|
+
tunnel_url: str
|
|
30
|
+
connector_url: str
|
|
31
|
+
registration_url: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Registration(_Document):
|
|
35
|
+
"""What an engine registers: itself when it has an id, the name it answers to and its
|
|
36
|
+
version."""
|
|
37
|
+
|
|
38
|
+
engine: UUID | None
|
|
39
|
+
name: str
|
|
40
|
+
version: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Registered(_Document):
|
|
44
|
+
"""What the bridge answers a registration with: the engine, its account, its handle and the
|
|
45
|
+
page's address."""
|
|
46
|
+
|
|
47
|
+
engine: UUID
|
|
48
|
+
account: str
|
|
49
|
+
handle: str
|
|
50
|
+
page_url: str
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The relay's typed errors: text, a frame or a proof it cannot accept."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RelayError(ValueError):
|
|
7
|
+
"""The tunnel carries something the protocol does not define, or not the thing it defines."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProofRefused(RelayError):
|
|
11
|
+
"""A bridged request's proof does not admit the request; the message names the fault."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The tunnel's binary frames: a stream's body in chunks of at most 64 KiB, each frame carrying its
|
|
2
|
+
stream and whether it ends it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ecosemantic.relay.errors import RelayError
|
|
10
|
+
|
|
11
|
+
CHUNK_BYTES = 65536
|
|
12
|
+
_HEADER = struct.Struct(">IB")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Chunk:
|
|
17
|
+
"""One frame of a stream's body: its stream, its bytes, and whether it is the last."""
|
|
18
|
+
|
|
19
|
+
stream: int
|
|
20
|
+
data: bytes
|
|
21
|
+
end: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pack(chunk: Chunk) -> bytes:
|
|
25
|
+
"""The binary frame that carries the chunk: its stream, the bit that ends it, then its body."""
|
|
26
|
+
return _HEADER.pack(chunk.stream, 1 if chunk.end else 0) + chunk.data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unpack(frame: bytes) -> Chunk:
|
|
30
|
+
"""The chunk a binary frame carries, refused when the frame is shorter than its header."""
|
|
31
|
+
if len(frame) < _HEADER.size:
|
|
32
|
+
raise RelayError(
|
|
33
|
+
f"a frame of {len(frame)} bytes is shorter than its {_HEADER.size}-byte header"
|
|
34
|
+
)
|
|
35
|
+
stream, flags = _HEADER.unpack_from(frame)
|
|
36
|
+
return Chunk(stream=stream, data=frame[_HEADER.size :], end=bool(flags & 1))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def chunks(stream: int, data: bytes, end: bool) -> list[Chunk]:
|
|
40
|
+
"""A stream's body split at 64 KiB, the last chunk alone carrying whether it ends the stream."""
|
|
41
|
+
pieces = [data[at : at + CHUNK_BYTES] for at in range(0, len(data), CHUNK_BYTES)] or [b""]
|
|
42
|
+
last = len(pieces) - 1
|
|
43
|
+
return [
|
|
44
|
+
Chunk(stream=stream, data=piece, end=end and at == last) for at, piece in enumerate(pieces)
|
|
45
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The tunnel's text messages: the opening exchange, a request's and a response's start, and a
|
|
2
|
+
stream's reset, each carried as one JSON document."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
from ecosemantic.relay.errors import RelayError
|
|
13
|
+
|
|
14
|
+
PROTOCOL = 1
|
|
15
|
+
Door = Literal["mcp", "page"]
|
|
16
|
+
ResetReason = Literal["cancelled", "failed"]
|
|
17
|
+
Header = tuple[str, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Message(BaseModel):
|
|
21
|
+
"""One message of the tunnel, frozen: what a text frame carries."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Hello(_Message):
|
|
27
|
+
"""The engine opens the tunnel: the protocol it speaks, itself and the version it runs."""
|
|
28
|
+
|
|
29
|
+
type: Literal["hello"] = "hello"
|
|
30
|
+
protocol: int
|
|
31
|
+
engine: UUID
|
|
32
|
+
version: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Welcome(_Message):
|
|
36
|
+
"""The bridge answers: the protocol, the engine and its account, the handle it answers to, the
|
|
37
|
+
moment of the exchange and the keys a bridged request's proof is checked against."""
|
|
38
|
+
|
|
39
|
+
type: Literal["welcome"] = "welcome"
|
|
40
|
+
protocol: int
|
|
41
|
+
engine: UUID
|
|
42
|
+
account: str
|
|
43
|
+
handle: str
|
|
44
|
+
now: datetime
|
|
45
|
+
keys: list[dict[str, str]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequestStart(_Message):
|
|
49
|
+
"""A request opens a stream: its door, its method, path and query, its headers and the proof
|
|
50
|
+
that admits it."""
|
|
51
|
+
|
|
52
|
+
type: Literal["request"] = "request"
|
|
53
|
+
stream: int = Field(ge=1)
|
|
54
|
+
door: Door
|
|
55
|
+
method: str
|
|
56
|
+
path: str
|
|
57
|
+
query: str
|
|
58
|
+
headers: list[Header]
|
|
59
|
+
proof: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResponseStart(_Message):
|
|
63
|
+
"""The engine answers a stream: the status and headers of the response its body follows."""
|
|
64
|
+
|
|
65
|
+
type: Literal["response"] = "response"
|
|
66
|
+
stream: int
|
|
67
|
+
status: int
|
|
68
|
+
headers: list[Header]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Reset(_Message):
|
|
72
|
+
"""A stream ends without its answer: the reason it was cancelled or failed."""
|
|
73
|
+
|
|
74
|
+
type: Literal["reset"] = "reset"
|
|
75
|
+
stream: int
|
|
76
|
+
reason: ResetReason
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Message = Annotated[
|
|
80
|
+
Hello | Welcome | RequestStart | ResponseStart | Reset, Field(discriminator="type")
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
_ADAPTER: TypeAdapter[Message] = TypeAdapter(Message)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def encode(message: Message) -> str:
|
|
87
|
+
"""The text frame that carries the message."""
|
|
88
|
+
return _ADAPTER.dump_json(message).decode("utf-8")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def decode(text: str) -> Message:
|
|
92
|
+
"""The message a text frame carries, refused when the text is no message of this protocol."""
|
|
93
|
+
try:
|
|
94
|
+
return _ADAPTER.validate_json(text)
|
|
95
|
+
except ValidationError as error:
|
|
96
|
+
raise RelayError(f"no message of protocol {PROTOCOL} in {text!r}: {error}") from error
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def door_of(path: str) -> Door:
|
|
100
|
+
"""The door a request's path belongs to: the chats' for `/mcp` and its subpaths, the page's for
|
|
101
|
+
every other path."""
|
|
102
|
+
return "mcp" if path == "/mcp" or path.startswith("/mcp/") else "page"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The Ed25519 proof a bridged request carries, and the JWKs the bridge's welcome publishes to check
|
|
2
|
+
it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from uuid import UUID, uuid4
|
|
11
|
+
|
|
12
|
+
import jwt
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
14
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
15
|
+
from jwt.types import Options
|
|
16
|
+
|
|
17
|
+
from ecosemantic.relay.errors import ProofRefused
|
|
18
|
+
from ecosemantic.relay.messages import Door
|
|
19
|
+
|
|
20
|
+
PROOF_SECONDS = 60
|
|
21
|
+
_ALGORITHM = "EdDSA"
|
|
22
|
+
_CLAIMS: Options = {
|
|
23
|
+
"verify_exp": False,
|
|
24
|
+
"verify_aud": False,
|
|
25
|
+
"verify_iss": False,
|
|
26
|
+
"verify_jti": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Proof:
|
|
32
|
+
"""What a verified proof admits: the account, the engine, the door and its expiry."""
|
|
33
|
+
|
|
34
|
+
account: str
|
|
35
|
+
engine: UUID
|
|
36
|
+
door: Door
|
|
37
|
+
expires: datetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def public_jwk(key: Ed25519PublicKey, kid: str) -> dict[str, str]:
|
|
41
|
+
"""The JWK a welcome carries for this key: its raw public bytes under the key's id."""
|
|
42
|
+
raw = key.public_bytes(encoding=Encoding.Raw, format=PublicFormat.Raw)
|
|
43
|
+
return {
|
|
44
|
+
"kty": "OKP",
|
|
45
|
+
"crv": "Ed25519",
|
|
46
|
+
"x": base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii"),
|
|
47
|
+
"kid": kid,
|
|
48
|
+
"alg": _ALGORITHM,
|
|
49
|
+
"use": "sig",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mint_proof(
|
|
54
|
+
key: Ed25519PrivateKey,
|
|
55
|
+
kid: str,
|
|
56
|
+
issuer: str,
|
|
57
|
+
account: str,
|
|
58
|
+
engine: UUID,
|
|
59
|
+
door: Door,
|
|
60
|
+
now: datetime,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""The token that admits one bridged request: the account, the engine and the door it names,
|
|
63
|
+
its own id, expiring 60 seconds after `now`."""
|
|
64
|
+
issued = int(now.timestamp())
|
|
65
|
+
return jwt.encode(
|
|
66
|
+
{
|
|
67
|
+
"iss": issuer,
|
|
68
|
+
"sub": account,
|
|
69
|
+
"aud": str(engine),
|
|
70
|
+
"door": door,
|
|
71
|
+
"iat": issued,
|
|
72
|
+
"exp": issued + PROOF_SECONDS,
|
|
73
|
+
"jti": uuid4().hex,
|
|
74
|
+
},
|
|
75
|
+
key,
|
|
76
|
+
algorithm=_ALGORITHM,
|
|
77
|
+
headers={"kid": kid},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def verify_proof(
|
|
82
|
+
token: str,
|
|
83
|
+
keys: Sequence[Mapping[str, str]],
|
|
84
|
+
issuer: str,
|
|
85
|
+
account: str,
|
|
86
|
+
engine: UUID,
|
|
87
|
+
door: Door,
|
|
88
|
+
now: datetime,
|
|
89
|
+
) -> Proof:
|
|
90
|
+
"""What the proof admits. Refused when no key of the welcome carries the id it names, when its
|
|
91
|
+
signature is not that key's, when it names another issuer, account, engine or door, when it
|
|
92
|
+
carries no id of its own, or once `now` reaches its expiry."""
|
|
93
|
+
kid = _header(token).get("kid")
|
|
94
|
+
key = next((candidate for candidate in keys if candidate.get("kid") == kid), None)
|
|
95
|
+
if key is None:
|
|
96
|
+
raise ProofRefused(f"no key of the welcome carries the id {kid!r}")
|
|
97
|
+
claims = _claims(token, _public(key))
|
|
98
|
+
if claims.get("iss") != issuer:
|
|
99
|
+
raise ProofRefused(f"the proof is issued by {claims.get('iss')!r}, not {issuer!r}")
|
|
100
|
+
if claims.get("sub") != account:
|
|
101
|
+
raise ProofRefused(f"the proof names the account {claims.get('sub')!r}, not {account!r}")
|
|
102
|
+
if claims.get("aud") != str(engine):
|
|
103
|
+
raise ProofRefused(f"the proof names the engine {claims.get('aud')!r}, not {engine}")
|
|
104
|
+
if claims.get("door") != door:
|
|
105
|
+
raise ProofRefused(f"the proof names the door {claims.get('door')!r}, not {door!r}")
|
|
106
|
+
jti = claims.get("jti")
|
|
107
|
+
if not isinstance(jti, str) or not jti:
|
|
108
|
+
raise ProofRefused(f"the proof carries no jti, only {jti!r}")
|
|
109
|
+
expires = _moment(claims, "exp")
|
|
110
|
+
if expires <= now:
|
|
111
|
+
raise ProofRefused(
|
|
112
|
+
f"the proof expires at {expires.isoformat()}, not after {now.isoformat()}"
|
|
113
|
+
)
|
|
114
|
+
return Proof(account=account, engine=engine, door=door, expires=expires)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _header(token: str) -> Mapping[str, object]:
|
|
118
|
+
"""The unverified header of a token, refused when the text is no token."""
|
|
119
|
+
try:
|
|
120
|
+
return jwt.get_unverified_header(token)
|
|
121
|
+
except jwt.InvalidTokenError as error:
|
|
122
|
+
raise ProofRefused(f"the proof is no token: {error}") from error
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _public(key: Mapping[str, str]) -> Ed25519PublicKey:
|
|
126
|
+
"""The Ed25519 key a JWK of the welcome carries, refused when it carries none."""
|
|
127
|
+
try:
|
|
128
|
+
return Ed25519PublicKey.from_public_bytes(_raw(key["x"]))
|
|
129
|
+
except (KeyError, ValueError) as error:
|
|
130
|
+
raise ProofRefused(f"the key {key.get('kid')!r} is no Ed25519 public key") from error
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _raw(encoded: str) -> bytes:
|
|
134
|
+
"""The bytes a base64url value of a JWK stands for."""
|
|
135
|
+
return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _claims(token: str, key: Ed25519PublicKey) -> Mapping[str, object]:
|
|
139
|
+
"""What a token claims once its signature is the key's; the clock and the claims it names are
|
|
140
|
+
checked by the caller."""
|
|
141
|
+
try:
|
|
142
|
+
return jwt.decode(token, key, algorithms=[_ALGORITHM], options=_CLAIMS)
|
|
143
|
+
except jwt.InvalidSignatureError as error:
|
|
144
|
+
raise ProofRefused("the signature is not the key's") from error
|
|
145
|
+
except jwt.InvalidTokenError as error:
|
|
146
|
+
raise ProofRefused(f"the proof cannot be read: {error}") from error
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _moment(claims: Mapping[str, object], name: str) -> datetime:
|
|
150
|
+
"""The moment a claim carries, in seconds, refused when the claim carries none."""
|
|
151
|
+
value = claims.get(name)
|
|
152
|
+
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
153
|
+
raise ProofRefused(f"the proof carries no numeric {name}")
|
|
154
|
+
return datetime.fromtimestamp(value, UTC)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ecosemantic-relay"
|
|
3
|
+
version = "0.1.14"
|
|
4
|
+
description = "The EcoSemantic bridge tunnel: its messages, its frames, the bridge's discovery document and the Ed25519 proof a bridged request carries."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
7
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{ name = "Carlos Gaete-Morales", email = "cdgaete@gmail.com" }]
|
|
10
|
+
keywords = ["life cycle assessment", "LCA", "bridge", "tunnel", "Ed25519"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"pydantic>=2.7,<3",
|
|
24
|
+
"pyjwt[crypto]>=2.10,<3",
|
|
25
|
+
"cryptography>=44,<51",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/cdgaete/ecosemantic"
|
|
30
|
+
Repository = "https://github.com/cdgaete/ecosemantic"
|
|
31
|
+
Issues = "https://github.com/cdgaete/ecosemantic/issues"
|
|
32
|
+
Changelog = "https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling>=1.27"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["ecosemantic"]
|