t2em 0.6.0__py3-none-any.whl
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.
- t2em/__init__.py +74 -0
- t2em/_meta.py +8 -0
- t2em/base64url.py +45 -0
- t2em/client.py +313 -0
- t2em/envelope.py +162 -0
- t2em/exceptions.py +54 -0
- t2em/facade.py +93 -0
- t2em/fingerprint.py +42 -0
- t2em/keyfile.py +129 -0
- t2em/keys.py +64 -0
- t2em/mode_a.py +158 -0
- t2em/mode_b.py +58 -0
- t2em/protocol.py +27 -0
- t2em/py.typed +0 -0
- t2em-0.6.0.dist-info/METADATA +99 -0
- t2em-0.6.0.dist-info/RECORD +17 -0
- t2em-0.6.0.dist-info/WHEEL +4 -0
t2em/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""t2em — Python SDK for t2em.com (ТЗ docs/TZ_t2em.md §8.3).
|
|
2
|
+
|
|
3
|
+
Zero-knowledge secret drops over a Blind Relay: the server stores only
|
|
4
|
+
an AES-256-GCM envelope and can never derive the key. Bit-compatible
|
|
5
|
+
with the TypeScript protocol `@t2em/protocol` — verified against
|
|
6
|
+
`vectors/t2em-drop.v1.json`.
|
|
7
|
+
|
|
8
|
+
Typical use::
|
|
9
|
+
|
|
10
|
+
from t2em import T2emClient, generate_x25519_keypair
|
|
11
|
+
|
|
12
|
+
client = T2emClient("https://t2em.com")
|
|
13
|
+
|
|
14
|
+
# Mode B (symmetric, key lives in the URL #fragment)
|
|
15
|
+
drop = client.push(secret="sk-live-...", ttl=300)
|
|
16
|
+
secret = client.pull_text(drop.url)
|
|
17
|
+
|
|
18
|
+
# Mode A (asymmetric, sealed to the recipient's X25519 pubkey)
|
|
19
|
+
keys = generate_x25519_keypair()
|
|
20
|
+
drop = client.push(secret="sk-live-...", recipient_pubkey=keys.public_key, ttl=300)
|
|
21
|
+
secret = client.pull_text(drop.url, recipient_privkey=keys.private_key)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .base64url import b64u_decode, b64u_encode, is_b64u_of_length
|
|
25
|
+
from ._meta import SDK_VERSION, SDK_USER_AGENT
|
|
26
|
+
from .client import CreatedDrop, ShareUrl, T2emClient
|
|
27
|
+
from .envelope import Envelope, ModeAEnvelope, ModeBEnvelope, validate_envelope
|
|
28
|
+
from .exceptions import HttpError, ProtocolError, T2emError, UsageError
|
|
29
|
+
from .facade import pack_drop, unpack_drop
|
|
30
|
+
from .fingerprint import fingerprint_pubkey, generate_fingerprint
|
|
31
|
+
from .keyfile import KEYFILE_KIND, KEYFILE_TYPE, KEYFILE_VERSION, load_keyfile, write_keyfile
|
|
32
|
+
from .keys import X25519KeyPair, generate_x25519_keypair
|
|
33
|
+
from .protocol import (
|
|
34
|
+
HKDF_INFO_DROP_V1,
|
|
35
|
+
MAX_TTL_SECONDS,
|
|
36
|
+
MIN_TTL_SECONDS,
|
|
37
|
+
T2EM_PROTOCOL_VERSION,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = SDK_VERSION
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"CreatedDrop",
|
|
44
|
+
"Envelope",
|
|
45
|
+
"HKDF_INFO_DROP_V1",
|
|
46
|
+
"KEYFILE_KIND",
|
|
47
|
+
"KEYFILE_TYPE",
|
|
48
|
+
"KEYFILE_VERSION",
|
|
49
|
+
"MAX_TTL_SECONDS",
|
|
50
|
+
"MIN_TTL_SECONDS",
|
|
51
|
+
"ModeAEnvelope",
|
|
52
|
+
"ModeBEnvelope",
|
|
53
|
+
"ProtocolError",
|
|
54
|
+
"HttpError",
|
|
55
|
+
"SDK_USER_AGENT",
|
|
56
|
+
"SDK_VERSION",
|
|
57
|
+
"ShareUrl",
|
|
58
|
+
"T2EM_PROTOCOL_VERSION",
|
|
59
|
+
"T2emClient",
|
|
60
|
+
"T2emError",
|
|
61
|
+
"UsageError",
|
|
62
|
+
"X25519KeyPair",
|
|
63
|
+
"b64u_decode",
|
|
64
|
+
"b64u_encode",
|
|
65
|
+
"fingerprint_pubkey",
|
|
66
|
+
"generate_fingerprint",
|
|
67
|
+
"generate_x25519_keypair",
|
|
68
|
+
"is_b64u_of_length",
|
|
69
|
+
"load_keyfile",
|
|
70
|
+
"pack_drop",
|
|
71
|
+
"unpack_drop",
|
|
72
|
+
"validate_envelope",
|
|
73
|
+
"write_keyfile",
|
|
74
|
+
]
|
t2em/_meta.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""SDK identity metadata — single source of the version string."""
|
|
2
|
+
|
|
3
|
+
SDK_VERSION = "0.6.0"
|
|
4
|
+
|
|
5
|
+
#: Identifying User-Agent (Cloudflare browser-integrity checks reject
|
|
6
|
+
#: the bare `Python-urllib` default; the ecosystem is agent-friendly,
|
|
7
|
+
#: so clients identify themselves — ТЗ §5.5, §8.6).
|
|
8
|
+
SDK_USER_AGENT = f"t2em-python-sdk/{SDK_VERSION}"
|
t2em/base64url.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Base64URL helpers (RFC 4648 §5, no padding) — ТЗ §4.1.
|
|
2
|
+
|
|
3
|
+
Strict mirror of `@t2em/protocol` `base64url.ts`: the same alphabet,
|
|
4
|
+
the same rejection rules, so every wire string produced here is
|
|
5
|
+
byte-identical to the TypeScript implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .exceptions import ProtocolError
|
|
14
|
+
|
|
15
|
+
_B64URL_ALPHABET = re.compile(r"^[A-Za-z0-9\-_]+$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def b64u_encode(data: bytes) -> str:
|
|
19
|
+
"""Encode bytes to unpadded base64url."""
|
|
20
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def b64u_decode(text: str) -> bytes:
|
|
24
|
+
"""Throwing strict decoder: rejects any non-base64url alphabet or padding."""
|
|
25
|
+
if not isinstance(text, str) or len(text) == 0:
|
|
26
|
+
raise ProtocolError("MALFORMED", "base64url string must be non-empty")
|
|
27
|
+
if not _B64URL_ALPHABET.match(text):
|
|
28
|
+
raise ProtocolError("MALFORMED", "not base64url (RFC 4648 §5, unpadded)")
|
|
29
|
+
if len(text) % 4 == 1:
|
|
30
|
+
raise ProtocolError("MALFORMED", "invalid base64url length")
|
|
31
|
+
pad = "=" * (-len(text) % 4)
|
|
32
|
+
try:
|
|
33
|
+
return base64.urlsafe_b64decode(text + pad)
|
|
34
|
+
except Exception as exc: # binascii.Error and friends
|
|
35
|
+
raise ProtocolError("MALFORMED", f"invalid base64url: {exc}") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_b64u_of_length(text: object, byte_length: int) -> bool:
|
|
39
|
+
"""True when `text` is valid base64url decoding to exactly `byte_length` bytes."""
|
|
40
|
+
if not isinstance(text, str):
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
return len(b64u_decode(text)) == byte_length
|
|
44
|
+
except ProtocolError:
|
|
45
|
+
return False
|
t2em/client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Relay API client — `T2emClient` (ТЗ §5, §8.3).
|
|
2
|
+
|
|
3
|
+
Speaks only the JSON contract (`Accept: application/json`) — no HTML
|
|
4
|
+
parsing, ever (§8.1 API-first). Networking uses stdlib `urllib` only
|
|
5
|
+
(§8.3: zero external network deps); the transport is injectable so the
|
|
6
|
+
client is testable without sockets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
from .envelope import Envelope, validate_envelope
|
|
19
|
+
from .exceptions import HttpError, ProtocolError, UsageError
|
|
20
|
+
from .mode_a import pack_drop_asym, unpack_drop_asym
|
|
21
|
+
from .mode_b import pack_drop_sym, unpack_drop_sym
|
|
22
|
+
from ._meta import SDK_USER_AGENT
|
|
23
|
+
from .protocol import MAX_TTL_SECONDS, MIN_TTL_SECONDS
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://t2em.com"
|
|
26
|
+
|
|
27
|
+
_SECRET_RE = re.compile(r"^/s/([A-Za-z0-9_-]+)$")
|
|
28
|
+
|
|
29
|
+
Transport = Callable[[str, str, Sequence[Tuple[str, str]], Optional[bytes]], "TransportResponse"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TransportResponse:
|
|
34
|
+
"""Raw HTTP response as seen by the transport."""
|
|
35
|
+
|
|
36
|
+
status: int
|
|
37
|
+
headers: Mapping[str, str]
|
|
38
|
+
body: bytes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CreatedDrop:
|
|
43
|
+
"""`{ id, url, expires_in, delete_token }` from `POST /api/v1/drop`."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
url: str
|
|
47
|
+
expires_in: int
|
|
48
|
+
delete_token: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ShareUrl:
|
|
53
|
+
"""A share URL split into its relay id and the secret #fragment."""
|
|
54
|
+
|
|
55
|
+
id: str
|
|
56
|
+
#: None for Mode A URLs — they carry no fragment.
|
|
57
|
+
fragment: Optional[str]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def has_fragment(self) -> bool:
|
|
61
|
+
return self.fragment is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_share_url(raw: str) -> ShareUrl:
|
|
65
|
+
"""Parse `https://<host>/s/<id>[#<fragment>]` into its parts (§8.2)."""
|
|
66
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
67
|
+
raise UsageError("url must be a non-empty string")
|
|
68
|
+
from urllib.parse import urlsplit
|
|
69
|
+
|
|
70
|
+
parts = urlsplit(raw.strip())
|
|
71
|
+
if parts.scheme not in ("https", "http"):
|
|
72
|
+
raise UsageError(f"unsupported url scheme: {parts.scheme!r}")
|
|
73
|
+
if not parts.netloc:
|
|
74
|
+
raise UsageError(f"invalid url: {raw}")
|
|
75
|
+
match = _SECRET_RE.match(parts.path)
|
|
76
|
+
if not match:
|
|
77
|
+
raise UsageError("url must point to a drop: https://<host>/s/<id>[#<key>]")
|
|
78
|
+
return ShareUrl(id=match.group(1), fragment=parts.fragment or None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _urllib_transport(timeout: float) -> Transport:
|
|
82
|
+
"""Default transport factory: stdlib urllib, no external deps (§8.3)."""
|
|
83
|
+
|
|
84
|
+
def transport(
|
|
85
|
+
method: str, url: str, headers: Sequence[Tuple[str, str]], body: Optional[bytes]
|
|
86
|
+
) -> TransportResponse:
|
|
87
|
+
request = urllib.request.Request(url, data=body, method=method)
|
|
88
|
+
for key, value in headers:
|
|
89
|
+
request.add_header(key, value)
|
|
90
|
+
# Identify the client (default urllib UA is rejected by CF 1010).
|
|
91
|
+
request.add_header("user-agent", SDK_USER_AGENT)
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
94
|
+
return TransportResponse(
|
|
95
|
+
status=response.status,
|
|
96
|
+
headers={k: v for k, v in response.headers.items()},
|
|
97
|
+
body=response.read(),
|
|
98
|
+
)
|
|
99
|
+
except urllib.error.HTTPError as err:
|
|
100
|
+
return TransportResponse(
|
|
101
|
+
status=err.code,
|
|
102
|
+
headers={k: v for k, v in err.headers.items()},
|
|
103
|
+
body=err.read(),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return transport
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class T2emClient:
|
|
110
|
+
"""Client for the t2em.com Blind Relay (ТЗ §5).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
base_url: relay origin, e.g. ``https://t2em.com``.
|
|
114
|
+
transport: injectable transport ``(method, url, headers, body)
|
|
115
|
+
-> TransportResponse`` for tests; defaults to stdlib urllib.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
121
|
+
*,
|
|
122
|
+
transport: Optional[Transport] = None,
|
|
123
|
+
timeout: float = 15.0,
|
|
124
|
+
) -> None:
|
|
125
|
+
if not isinstance(base_url, str) or not base_url.strip():
|
|
126
|
+
raise UsageError("base_url must be a non-empty string")
|
|
127
|
+
self._base_url = base_url.rstrip("/")
|
|
128
|
+
self._timeout = timeout
|
|
129
|
+
if transport is not None:
|
|
130
|
+
self._transport: Transport = transport
|
|
131
|
+
else:
|
|
132
|
+
self._transport = _urllib_transport(timeout)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Push / pull / delete
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def push(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
secret: Union[str, bytes, bytearray],
|
|
142
|
+
ttl: int = 300,
|
|
143
|
+
recipient_pubkey: Optional[Union[str, bytes, bytearray]] = None,
|
|
144
|
+
) -> CreatedDrop:
|
|
145
|
+
"""Create a drop — Mode B by default, Mode A with `recipient_pubkey`.
|
|
146
|
+
|
|
147
|
+
Returns `CreatedDrop` whose `url` carries the key in the
|
|
148
|
+
`#fragment` (Mode B only). The delete token is returned here
|
|
149
|
+
once — store it if you need `delete()`.
|
|
150
|
+
"""
|
|
151
|
+
self._check_ttl(ttl)
|
|
152
|
+
plaintext = self._as_plaintext(secret)
|
|
153
|
+
|
|
154
|
+
if recipient_pubkey is not None:
|
|
155
|
+
from .mode_a import pack_drop_asym
|
|
156
|
+
|
|
157
|
+
envelope = pack_drop_asym(plaintext, recipient_pubkey)
|
|
158
|
+
fragment: Optional[str] = None
|
|
159
|
+
else:
|
|
160
|
+
envelope, fragment = pack_drop_sym(plaintext)
|
|
161
|
+
|
|
162
|
+
body = json.dumps({"envelope": envelope.to_dict(), "ttl": ttl}).encode("utf-8")
|
|
163
|
+
response = self._transport(
|
|
164
|
+
"POST",
|
|
165
|
+
f"{self._base_url}/api/v1/drop",
|
|
166
|
+
(("content-type", "application/json"), ("accept", "application/json")),
|
|
167
|
+
body,
|
|
168
|
+
)
|
|
169
|
+
payload = self._json_body(response.status, response.body)
|
|
170
|
+
if response.status != 201:
|
|
171
|
+
raise self._http_error(response.status, response.headers, response.body, payload)
|
|
172
|
+
try:
|
|
173
|
+
drop_id = payload["id"]
|
|
174
|
+
drop_url = payload["url"]
|
|
175
|
+
expires_in = payload["expires_in"]
|
|
176
|
+
delete_token = payload["delete_token"]
|
|
177
|
+
except (KeyError, TypeError) as exc:
|
|
178
|
+
raise ProtocolError("MALFORMED", f"relay 201 payload missing field: {exc}") from exc
|
|
179
|
+
if not all(
|
|
180
|
+
isinstance(x, str) for x in (drop_id, drop_url, delete_token)
|
|
181
|
+
) or not isinstance(expires_in, int):
|
|
182
|
+
raise ProtocolError("MALFORMED", "relay 201 payload has unexpected field types")
|
|
183
|
+
# Mode B: the relay never sees the key — the #fragment is appended
|
|
184
|
+
# client-side, exactly like the Web UI and the CLI do (§4.3).
|
|
185
|
+
url = drop_url if fragment is None else f"{drop_url}#{fragment}"
|
|
186
|
+
return CreatedDrop(
|
|
187
|
+
id=drop_id, url=url, expires_in=expires_in, delete_token=delete_token
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def pull_bytes(
|
|
191
|
+
self,
|
|
192
|
+
url: str,
|
|
193
|
+
*,
|
|
194
|
+
recipient_privkey: Optional[Union[str, bytes, bytearray]] = None,
|
|
195
|
+
) -> bytes:
|
|
196
|
+
"""Fetch and decrypt a drop; the relay copy is consumed (GETDEL).
|
|
197
|
+
|
|
198
|
+
Mode B needs the `#fragment` of the URL; Mode A needs the
|
|
199
|
+
recipient's X25519 private key.
|
|
200
|
+
"""
|
|
201
|
+
parts = parse_share_url(url)
|
|
202
|
+
envelope = self._consume(parts.id)
|
|
203
|
+
|
|
204
|
+
if envelope.mode == "sym":
|
|
205
|
+
if not parts.fragment:
|
|
206
|
+
raise UsageError(
|
|
207
|
+
"Mode B url carries no #fragment — the key is missing"
|
|
208
|
+
)
|
|
209
|
+
return unpack_drop_sym(envelope, parts.fragment)
|
|
210
|
+
|
|
211
|
+
# Mode A
|
|
212
|
+
if recipient_privkey is None:
|
|
213
|
+
raise UsageError(
|
|
214
|
+
"Mode A url requires recipient_privkey (e.g. from load_keyfile)"
|
|
215
|
+
)
|
|
216
|
+
return unpack_drop_asym(envelope, recipient_privkey)
|
|
217
|
+
|
|
218
|
+
def pull_text(
|
|
219
|
+
self,
|
|
220
|
+
url: str,
|
|
221
|
+
*,
|
|
222
|
+
recipient_privkey: Optional[Union[str, bytes, bytearray]] = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""`pull_bytes` decoded as UTF-8 text (ТЗ §8.3 `pull_text`)."""
|
|
225
|
+
return self.pull_bytes(url, recipient_privkey=recipient_privkey).decode("utf-8")
|
|
226
|
+
|
|
227
|
+
def delete(self, url_or_id: str, delete_token: str) -> None:
|
|
228
|
+
"""Delete a drop before TTL expiry (§5.4); idempotent 404 is NOT ok."""
|
|
229
|
+
raw = url_or_id.strip()
|
|
230
|
+
drop_id = parse_share_url(raw).id if "/s/" in raw else raw
|
|
231
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", drop_id):
|
|
232
|
+
raise UsageError(f"invalid drop id: {drop_id!r}")
|
|
233
|
+
if not delete_token:
|
|
234
|
+
raise UsageError("delete_token is required")
|
|
235
|
+
response = self._transport(
|
|
236
|
+
"DELETE",
|
|
237
|
+
f"{self._base_url}/api/v1/drop/{drop_id}",
|
|
238
|
+
(("x-delete-token", delete_token), ("accept", "application/json")),
|
|
239
|
+
None,
|
|
240
|
+
)
|
|
241
|
+
if response.status != 200:
|
|
242
|
+
raise self._http_error(response.status, response.headers, response.body)
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Internals
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _consume(self, drop_id: str) -> Envelope:
|
|
249
|
+
response = self._transport(
|
|
250
|
+
"GET",
|
|
251
|
+
f"{self._base_url}/s/{drop_id}",
|
|
252
|
+
(("accept", "application/json"),),
|
|
253
|
+
None,
|
|
254
|
+
)
|
|
255
|
+
payload = self._json_body(response.status, response.body)
|
|
256
|
+
if response.status != 200:
|
|
257
|
+
raise self._http_error(response.status, response.headers, response.body, payload)
|
|
258
|
+
# §5.3 wire shape: { envelope: {...} }
|
|
259
|
+
if not isinstance(payload, dict) or "envelope" not in payload:
|
|
260
|
+
raise ProtocolError("MALFORMED", 'relay response missing "envelope" object')
|
|
261
|
+
return validate_envelope(payload["envelope"])
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _check_ttl(ttl: int) -> None:
|
|
265
|
+
if not isinstance(ttl, int) or isinstance(ttl, bool):
|
|
266
|
+
raise UsageError("ttl must be an integer number of seconds")
|
|
267
|
+
if not MIN_TTL_SECONDS <= ttl <= MAX_TTL_SECONDS:
|
|
268
|
+
raise UsageError(
|
|
269
|
+
f"ttl must be between {MIN_TTL_SECONDS} and {MAX_TTL_SECONDS} seconds"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _as_plaintext(secret: Union[str, bytes, bytearray]) -> bytes:
|
|
274
|
+
if isinstance(secret, str):
|
|
275
|
+
return secret.encode("utf-8")
|
|
276
|
+
if isinstance(secret, (bytes, bytearray)):
|
|
277
|
+
return bytes(secret)
|
|
278
|
+
raise UsageError("secret must be str or bytes")
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _json_body(status: int, body: bytes) -> Any:
|
|
282
|
+
if not body:
|
|
283
|
+
return None
|
|
284
|
+
try:
|
|
285
|
+
return json.loads(body.decode("utf-8"))
|
|
286
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
287
|
+
if 200 <= status < 300:
|
|
288
|
+
raise ProtocolError("MALFORMED", f"relay response is not valid JSON: {exc}") from exc
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _http_error(
|
|
293
|
+
status: int,
|
|
294
|
+
headers: Mapping[str, str],
|
|
295
|
+
body: bytes,
|
|
296
|
+
payload: Any = None,
|
|
297
|
+
) -> HttpError:
|
|
298
|
+
retry_after: Optional[int] = None
|
|
299
|
+
raw_retry = headers.get("retry-after") if headers else None
|
|
300
|
+
if raw_retry is not None:
|
|
301
|
+
try:
|
|
302
|
+
retry_after = int(raw_retry)
|
|
303
|
+
except ValueError:
|
|
304
|
+
retry_after = None
|
|
305
|
+
code: Optional[str] = None
|
|
306
|
+
message = f"relay returned HTTP {status}"
|
|
307
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
308
|
+
err = payload["error"]
|
|
309
|
+
if isinstance(err.get("code"), str):
|
|
310
|
+
code = err["code"]
|
|
311
|
+
if isinstance(err.get("message"), str):
|
|
312
|
+
message = err["message"]
|
|
313
|
+
return HttpError(status, code, message, retry_after)
|
t2em/envelope.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Envelope shapes and validation — mirror of `@t2em/protocol` `envelope.ts`.
|
|
2
|
+
|
|
3
|
+
The relay validates envelopes on write; the SDK validates envelopes it
|
|
4
|
+
pulls before decrypting, so a hostile/broken relay cannot feed the
|
|
5
|
+
crypto layer arbitrary bytes (§11.4 injection protection).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, Union
|
|
13
|
+
|
|
14
|
+
from .base64url import b64u_decode, is_b64u_of_length
|
|
15
|
+
from .exceptions import ProtocolError
|
|
16
|
+
from .protocol import GCM_TAG_BYTES, IV_BYTES, T2EM_PROTOCOL_VERSION, X25519_KEY_BYTES
|
|
17
|
+
|
|
18
|
+
MAX_IV_BYTES = IV_BYTES
|
|
19
|
+
MAX_EPHEMERAL_PUBKEY_BYTES = X25519_KEY_BYTES
|
|
20
|
+
MAX_ED25519_SIG_BYTES = 64
|
|
21
|
+
MAX_ED25519_PUBKEY_BYTES = 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ModeBEnvelope:
|
|
26
|
+
"""Mode B — symmetric #fragment bootstrap (ТЗ §4.3)."""
|
|
27
|
+
|
|
28
|
+
ciphertext: str
|
|
29
|
+
iv: str
|
|
30
|
+
mode: str = "sym"
|
|
31
|
+
v: int = T2EM_PROTOCOL_VERSION
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
return {"v": self.v, "mode": self.mode, "ciphertext": self.ciphertext, "iv": self.iv}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ModeAEnvelope:
|
|
39
|
+
"""Mode A — asymmetric E2EE on X25519 (ТЗ §4.2).
|
|
40
|
+
|
|
41
|
+
Optional Ed25519 sender fields (§5.2): ``sender_signature`` (64B
|
|
42
|
+
base64url) and ``sender_pubkey`` (32B base64url), preserved on
|
|
43
|
+
validation — TS and Python must agree bit-for-bit (§4.5 vectors).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
ephemeral_pubkey: str
|
|
47
|
+
ciphertext: str
|
|
48
|
+
iv: str
|
|
49
|
+
mode: str = "x25519"
|
|
50
|
+
v: int = T2EM_PROTOCOL_VERSION
|
|
51
|
+
sender_signature: str | None = None
|
|
52
|
+
sender_pubkey: str | None = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
out: Dict[str, Any] = {
|
|
56
|
+
"v": self.v,
|
|
57
|
+
"mode": self.mode,
|
|
58
|
+
"ephemeralPubKey": self.ephemeral_pubkey,
|
|
59
|
+
"ciphertext": self.ciphertext,
|
|
60
|
+
"iv": self.iv,
|
|
61
|
+
}
|
|
62
|
+
if self.sender_signature is not None:
|
|
63
|
+
out["senderSignature"] = self.sender_signature
|
|
64
|
+
if self.sender_pubkey is not None:
|
|
65
|
+
out["senderPubKey"] = self.sender_pubkey
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Envelope = Union[ModeAEnvelope, ModeBEnvelope]
|
|
70
|
+
|
|
71
|
+
_FORBIDDEN_KEYS = frozenset({"__proto__", "constructor", "prototype"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_json(value: Any) -> Dict[str, Any]:
|
|
75
|
+
"""Prototype-safe parse guard (§11.4): reject poisoned keys outright."""
|
|
76
|
+
if not isinstance(value, dict):
|
|
77
|
+
raise ProtocolError("MALFORMED", "envelope must be a JSON object")
|
|
78
|
+
if any(k in _FORBIDDEN_KEYS for k in value):
|
|
79
|
+
raise ProtocolError("MALFORMED", "forbidden key in envelope")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_envelope(value: Any) -> Envelope:
|
|
84
|
+
"""Validate an unknown value as a protocol-v1 envelope.
|
|
85
|
+
|
|
86
|
+
Raises `ProtocolError` with code ``UNSUPPORTED_VERSION`` or
|
|
87
|
+
``MALFORMED`` on any deviation; returns the narrowed envelope
|
|
88
|
+
otherwise.
|
|
89
|
+
"""
|
|
90
|
+
v = _safe_json(value)
|
|
91
|
+
|
|
92
|
+
if v.get("v") != T2EM_PROTOCOL_VERSION:
|
|
93
|
+
if isinstance(v.get("v"), int):
|
|
94
|
+
raise ProtocolError("UNSUPPORTED_VERSION", f"envelope.v={v.get('v')}")
|
|
95
|
+
raise ProtocolError("MALFORMED", "envelope.v must be a number")
|
|
96
|
+
|
|
97
|
+
ciphertext = v.get("ciphertext")
|
|
98
|
+
if not isinstance(ciphertext, str) or len(ciphertext) == 0:
|
|
99
|
+
raise ProtocolError("MALFORMED", "ciphertext must be a non-empty base64url string")
|
|
100
|
+
iv = v.get("iv")
|
|
101
|
+
if not isinstance(iv, str) or not is_b64u_of_length(iv, MAX_IV_BYTES):
|
|
102
|
+
raise ProtocolError("MALFORMED", f"iv must be base64url of exactly {MAX_IV_BYTES} bytes")
|
|
103
|
+
try:
|
|
104
|
+
ct_len = len(b64u_decode(ciphertext))
|
|
105
|
+
except ProtocolError:
|
|
106
|
+
raise ProtocolError("MALFORMED", "ciphertext is not valid base64url") from None
|
|
107
|
+
if ct_len < GCM_TAG_BYTES:
|
|
108
|
+
raise ProtocolError("MALFORMED", f"ciphertext shorter than GCM tag ({GCM_TAG_BYTES} bytes)")
|
|
109
|
+
|
|
110
|
+
mode = v.get("mode")
|
|
111
|
+
if mode == "sym":
|
|
112
|
+
# §4.5 negative contract: a sym envelope carrying Mode A fields
|
|
113
|
+
# is malformed (cross-mode confusion, §4.5 vectors).
|
|
114
|
+
if (
|
|
115
|
+
v.get("ephemeralPubKey") is not None
|
|
116
|
+
or v.get("senderSignature") is not None
|
|
117
|
+
or v.get("senderPubKey") is not None
|
|
118
|
+
):
|
|
119
|
+
raise ProtocolError(
|
|
120
|
+
"MALFORMED",
|
|
121
|
+
"sym envelope must not carry Mode A fields "
|
|
122
|
+
"(ephemeralPubKey/senderSignature/senderPubKey)",
|
|
123
|
+
)
|
|
124
|
+
return ModeBEnvelope(ciphertext=ciphertext, iv=iv)
|
|
125
|
+
|
|
126
|
+
if mode == "x25519":
|
|
127
|
+
eph = v.get("ephemeralPubKey")
|
|
128
|
+
if not isinstance(eph, str) or not is_b64u_of_length(eph, MAX_EPHEMERAL_PUBKEY_BYTES):
|
|
129
|
+
raise ProtocolError(
|
|
130
|
+
"MALFORMED",
|
|
131
|
+
f"ephemeralPubKey must be base64url of exactly {MAX_EPHEMERAL_PUBKEY_BYTES} bytes",
|
|
132
|
+
)
|
|
133
|
+
sig = v.get("senderSignature")
|
|
134
|
+
if sig is not None and (not isinstance(sig, str) or not is_b64u_of_length(sig, MAX_ED25519_SIG_BYTES)):
|
|
135
|
+
raise ProtocolError(
|
|
136
|
+
"MALFORMED",
|
|
137
|
+
"senderSignature must be base64url of exactly 64 bytes",
|
|
138
|
+
)
|
|
139
|
+
spk = v.get("senderPubKey")
|
|
140
|
+
if spk is not None and (not isinstance(spk, str) or not is_b64u_of_length(spk, MAX_ED25519_PUBKEY_BYTES)):
|
|
141
|
+
raise ProtocolError(
|
|
142
|
+
"MALFORMED",
|
|
143
|
+
"senderPubKey must be base64url of exactly 32 bytes",
|
|
144
|
+
)
|
|
145
|
+
return ModeAEnvelope(
|
|
146
|
+
ephemeral_pubkey=eph,
|
|
147
|
+
ciphertext=ciphertext,
|
|
148
|
+
iv=iv,
|
|
149
|
+
sender_signature=sig,
|
|
150
|
+
sender_pubkey=spk,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raise ProtocolError("MALFORMED", f"unknown envelope.mode: {mode!r}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_envelope_json(text: str) -> Envelope:
|
|
157
|
+
"""Parse + validate an envelope from relay JSON (safe re-raise)."""
|
|
158
|
+
try:
|
|
159
|
+
raw = json.loads(text)
|
|
160
|
+
except json.JSONDecodeError as exc:
|
|
161
|
+
raise ProtocolError("MALFORMED", f"relay response is not valid JSON: {exc}") from exc
|
|
162
|
+
return validate_envelope(raw)
|
t2em/exceptions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception taxonomy (ТЗ §4.5) — mirrors the TS client errors.
|
|
2
|
+
|
|
3
|
+
Integrators branch on `ProtocolError.code`, never on message text.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class T2emError(Exception):
|
|
12
|
+
"""Base class for every t2em SDK error."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UsageError(T2emError):
|
|
16
|
+
"""Invalid caller input: bad URL, TTL out of range, wrong key length."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProtocolError(T2emError):
|
|
20
|
+
"""Envelope-level failure (shape, version, or AEAD decryption).
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
code: ``MALFORMED`` | ``UNSUPPORTED_VERSION`` | ``DECRYPT_FAILED``
|
|
24
|
+
— the §4.5 taxonomy used by the relay and the TS SDK.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, code: str, message: str) -> None:
|
|
28
|
+
super().__init__(f"{code}: {message}")
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HttpError(T2emError):
|
|
34
|
+
"""Non-success relay response carrying the §5.2 error JSON.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
status: HTTP status code.
|
|
38
|
+
code: machine-readable error code from the body, if present.
|
|
39
|
+
message: human-readable message from the body or a fallback.
|
|
40
|
+
retry_after: parsed ``Retry-After`` header (seconds), if present.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
status: int,
|
|
46
|
+
code: Optional[str],
|
|
47
|
+
message: str,
|
|
48
|
+
retry_after: Optional[int] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(f"HTTP {status}{f' ({code})' if code else ''}: {message}")
|
|
51
|
+
self.status = status
|
|
52
|
+
self.code = code
|
|
53
|
+
self.message = message
|
|
54
|
+
self.retry_after = retry_after
|