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 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