voidnote 0.2.0__tar.gz → 0.4.1__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.
@@ -0,0 +1,11 @@
1
+ # Build output — never commit
2
+ **/.build/
3
+ **/dist/
4
+ **/target/
5
+ **/__pycache__/
6
+ *.egg-info/
7
+ **/zig-out/
8
+ **/.zig-cache/
9
+
10
+ # OS
11
+ .DS_Store
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.5
2
+ Name: voidnote
3
+ Version: 0.4.1
4
+ Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams
5
+ Project-URL: Homepage, https://voidnote.net
6
+ Project-URL: Repository, https://github.com/quantum-encoding/voidnote-python
7
+ Project-URL: Documentation, https://voidnote.net/docs
8
+ License-Expression: MIT
9
+ Keywords: encryption,one-time,secrets,voidnote,zero-knowledge
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security :: Cryptography
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: cryptography>=41.0.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # voidnote
23
+
24
+ Official Python SDK for [VoidNote](https://voidnote.net) — zero-knowledge self-destructing notes and live encrypted streams.
25
+
26
+ **v0.4.1** · Python 3.9+ · zero-knowledge v1 API
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install voidnote
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ import voidnote
38
+
39
+ # Create a note — content is encrypted locally, server never sees plaintext
40
+ result = voidnote.create(
41
+ "my secret value",
42
+ api_key="vn_...",
43
+ max_views=1, # destroy after this many reads (1–100)
44
+ expires_in=1, # hours until expiry (1–720)
45
+ )
46
+ print(result.url) # https://voidnote.net/n/<tokenId>#<secret>
47
+ print(result.token) # 32-char tokenId
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `voidnote.create(content, *, api_key, max_views=1, expires_in=1) → CreateResult`
53
+
54
+ Encrypt `content` locally and store the ciphertext. Requires an API key from your [dashboard](https://voidnote.net/dashboard).
55
+
56
+ ```python
57
+ result = voidnote.create("deploy key: abc123", api_key="vn_...", max_views=3)
58
+ print(result.url) # full shareable URL — includes #secret
59
+ print(result.token) # 32-char tokenId only
60
+ print(result.expires_at)
61
+ ```
62
+
63
+ ### `voidnote.read(url) → ReadResult`
64
+
65
+ Fetch and decrypt a note. Consumes one view. Accepts both v1 URLs (`/n/<token>#<secret>`) and legacy URLs.
66
+
67
+ ```python
68
+ note = voidnote.read("https://voidnote.net/n/<tokenId>#<secret>")
69
+ print(note.content) # decrypted plaintext
70
+ print(note.views_remaining) # views left after this read
71
+ print(note.destroyed) # True if this was the last view
72
+ ```
73
+
74
+ ### `voidnote.peek(url) → PeekResult`
75
+
76
+ Check metadata without consuming a view.
77
+
78
+ ```python
79
+ meta = voidnote.peek("https://voidnote.net/n/<tokenId>#<secret>")
80
+ print(meta.exists)
81
+ print(meta.views_remaining)
82
+ print(meta.expires_at)
83
+ ```
84
+
85
+ ### Void Streams
86
+
87
+ Live encrypted real-time channels. Messages are encrypted before leaving your machine.
88
+
89
+ ```python
90
+ import voidnote
91
+
92
+ # Create a channel
93
+ stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
94
+ print(stream.url) # share this URL with watchers
95
+
96
+ # Write messages (write and close are authenticated with the same API key)
97
+ stream.write("Starting deployment...")
98
+ stream.write("Build complete.")
99
+ stream.close() # self-destructs all content
100
+
101
+ # Watch a stream — yields decrypted messages as they arrive
102
+ for message in voidnote.watch(stream.url):
103
+ print(message)
104
+
105
+ # Password-protected stream
106
+ for message in voidnote.watch(url, password="hunter2"):
107
+ print(message)
108
+ ```
109
+
110
+ ### Crypto payments
111
+
112
+ ```python
113
+ # Create a payment order
114
+ order = voidnote.create_crypto_order(
115
+ api_key="vn_...",
116
+ bundle_id="starter", # "test" | "starter" | "standard" | "pro"
117
+ chain="polygon",
118
+ token="USDT",
119
+ )
120
+ print(f"Send {order.amount} {order.token} to {order.to_address}")
121
+
122
+ # After sending, submit the tx hash
123
+ result = voidnote.submit_crypto_payment(
124
+ api_key="vn_...",
125
+ order_id=order.order_id,
126
+ tx_hash="0x...",
127
+ )
128
+ print(f"+{result.credits_added} credits, balance: {result.credits}")
129
+ ```
130
+
131
+ ## Security model
132
+
133
+ ```
134
+ server generates tokenId (32-char hex — stored as lookup key)
135
+ client generates secret (32-char hex — NEVER sent to server)
136
+
137
+ key = SHA-256(bytes.fromhex(secret))
138
+ ciphertext = AES-256-GCM(plaintext, key, random_12_byte_iv)
139
+ share URL = https://voidnote.net/n/{tokenId}#{secret}
140
+ ```
141
+
142
+ The `#fragment` is never transmitted to HTTP servers per spec. The server has ciphertext; you have the key.
143
+
144
+ ## Requirements
145
+
146
+ - Python 3.9+
147
+ - `cryptography >= 41.0.0` (installed automatically)
148
+
149
+ ## Links
150
+
151
+ - [voidnote.net](https://voidnote.net)
152
+ - [API reference](https://voidnote.net/docs)
153
+ - [CLI reference](https://voidnote.net/cli)
154
+ - [GitHub](https://github.com/quantum-encoding/voidnote-python)
@@ -0,0 +1,133 @@
1
+ # voidnote
2
+
3
+ Official Python SDK for [VoidNote](https://voidnote.net) — zero-knowledge self-destructing notes and live encrypted streams.
4
+
5
+ **v0.4.1** · Python 3.9+ · zero-knowledge v1 API
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install voidnote
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ import voidnote
17
+
18
+ # Create a note — content is encrypted locally, server never sees plaintext
19
+ result = voidnote.create(
20
+ "my secret value",
21
+ api_key="vn_...",
22
+ max_views=1, # destroy after this many reads (1–100)
23
+ expires_in=1, # hours until expiry (1–720)
24
+ )
25
+ print(result.url) # https://voidnote.net/n/<tokenId>#<secret>
26
+ print(result.token) # 32-char tokenId
27
+ ```
28
+
29
+ ## API
30
+
31
+ ### `voidnote.create(content, *, api_key, max_views=1, expires_in=1) → CreateResult`
32
+
33
+ Encrypt `content` locally and store the ciphertext. Requires an API key from your [dashboard](https://voidnote.net/dashboard).
34
+
35
+ ```python
36
+ result = voidnote.create("deploy key: abc123", api_key="vn_...", max_views=3)
37
+ print(result.url) # full shareable URL — includes #secret
38
+ print(result.token) # 32-char tokenId only
39
+ print(result.expires_at)
40
+ ```
41
+
42
+ ### `voidnote.read(url) → ReadResult`
43
+
44
+ Fetch and decrypt a note. Consumes one view. Accepts both v1 URLs (`/n/<token>#<secret>`) and legacy URLs.
45
+
46
+ ```python
47
+ note = voidnote.read("https://voidnote.net/n/<tokenId>#<secret>")
48
+ print(note.content) # decrypted plaintext
49
+ print(note.views_remaining) # views left after this read
50
+ print(note.destroyed) # True if this was the last view
51
+ ```
52
+
53
+ ### `voidnote.peek(url) → PeekResult`
54
+
55
+ Check metadata without consuming a view.
56
+
57
+ ```python
58
+ meta = voidnote.peek("https://voidnote.net/n/<tokenId>#<secret>")
59
+ print(meta.exists)
60
+ print(meta.views_remaining)
61
+ print(meta.expires_at)
62
+ ```
63
+
64
+ ### Void Streams
65
+
66
+ Live encrypted real-time channels. Messages are encrypted before leaving your machine.
67
+
68
+ ```python
69
+ import voidnote
70
+
71
+ # Create a channel
72
+ stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
73
+ print(stream.url) # share this URL with watchers
74
+
75
+ # Write messages (write and close are authenticated with the same API key)
76
+ stream.write("Starting deployment...")
77
+ stream.write("Build complete.")
78
+ stream.close() # self-destructs all content
79
+
80
+ # Watch a stream — yields decrypted messages as they arrive
81
+ for message in voidnote.watch(stream.url):
82
+ print(message)
83
+
84
+ # Password-protected stream
85
+ for message in voidnote.watch(url, password="hunter2"):
86
+ print(message)
87
+ ```
88
+
89
+ ### Crypto payments
90
+
91
+ ```python
92
+ # Create a payment order
93
+ order = voidnote.create_crypto_order(
94
+ api_key="vn_...",
95
+ bundle_id="starter", # "test" | "starter" | "standard" | "pro"
96
+ chain="polygon",
97
+ token="USDT",
98
+ )
99
+ print(f"Send {order.amount} {order.token} to {order.to_address}")
100
+
101
+ # After sending, submit the tx hash
102
+ result = voidnote.submit_crypto_payment(
103
+ api_key="vn_...",
104
+ order_id=order.order_id,
105
+ tx_hash="0x...",
106
+ )
107
+ print(f"+{result.credits_added} credits, balance: {result.credits}")
108
+ ```
109
+
110
+ ## Security model
111
+
112
+ ```
113
+ server generates tokenId (32-char hex — stored as lookup key)
114
+ client generates secret (32-char hex — NEVER sent to server)
115
+
116
+ key = SHA-256(bytes.fromhex(secret))
117
+ ciphertext = AES-256-GCM(plaintext, key, random_12_byte_iv)
118
+ share URL = https://voidnote.net/n/{tokenId}#{secret}
119
+ ```
120
+
121
+ The `#fragment` is never transmitted to HTTP servers per spec. The server has ciphertext; you have the key.
122
+
123
+ ## Requirements
124
+
125
+ - Python 3.9+
126
+ - `cryptography >= 41.0.0` (installed automatically)
127
+
128
+ ## Links
129
+
130
+ - [voidnote.net](https://voidnote.net)
131
+ - [API reference](https://voidnote.net/docs)
132
+ - [CLI reference](https://voidnote.net/cli)
133
+ - [GitHub](https://github.com/quantum-encoding/voidnote-python)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voidnote"
7
- version = "0.2.0"
7
+ version = "0.4.1"
8
8
  description = "Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,564 @@
1
+ """
2
+ VoidNote SDK — Python
3
+
4
+ pip install voidnote
5
+
6
+ Works with Python 3.9+. Requires: cryptography
7
+
8
+ pip install voidnote cryptography
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import http.client
15
+ import os
16
+ import re
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ import json
22
+ from dataclasses import dataclass, field
23
+ from typing import Optional, Generator, Literal, Tuple
24
+
25
+ try:
26
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
27
+ _HAS_CRYPTO = True
28
+ except ImportError:
29
+ _HAS_CRYPTO = False
30
+
31
+ DEFAULT_BASE = "https://voidnote.net"
32
+ __version__ = "0.4.1"
33
+ # Cloudflare refuses urllib's default "Python-urllib/x.y" agent (error 1010).
34
+ _USER_AGENT = f"voidnote-python/{__version__}"
35
+
36
+
37
+ @dataclass
38
+ class ReadResult:
39
+ content: str
40
+ title: Optional[str]
41
+ view_count: int
42
+ max_views: int
43
+ destroyed: bool
44
+ views_remaining: Optional[int] = None # populated for v1 URLs
45
+
46
+
47
+ @dataclass
48
+ class CreateResult:
49
+ url: str # full shareable URL — includes #secret for v1 notes
50
+ token: str # 32-char tokenId
51
+ expires_at: str
52
+
53
+
54
+ @dataclass
55
+ class PeekResult:
56
+ exists: bool
57
+ type: Optional[str] = None
58
+ created_at: Optional[str] = None
59
+ expires_at: Optional[str] = None
60
+ view_limit: Optional[int] = None
61
+ views_remaining: Optional[int] = None
62
+
63
+
64
+ # --- internal helpers ---
65
+
66
+ def _parse_note_url(url_or_token: str) -> Tuple[str, Optional[Tuple[str, str]]]:
67
+ """
68
+ Returns (format, data) where:
69
+ format = 'v1' → data = (tokenId, secret)
70
+ format = 'legacy' → data = None (fullToken is the 64-char token found in url)
71
+ Raises ValueError if neither format matches.
72
+ """
73
+ # v1 format: .../n/{32-hex}#{32-hex}
74
+ if '#' in url_or_token:
75
+ before, _, secret = url_or_token.partition('#')
76
+ token_id = before.rstrip('/').rsplit('/', 1)[-1]
77
+ if (len(token_id) == 32 and re.fullmatch(r'[0-9a-f]+', token_id, re.I)
78
+ and len(secret) == 32 and re.fullmatch(r'[0-9a-f]+', secret, re.I)):
79
+ return 'v1', (token_id.lower(), secret.lower())
80
+ # Legacy: 64-char hex token
81
+ match = re.search(r'[0-9a-f]{64}', url_or_token, re.I)
82
+ if match:
83
+ return 'legacy', (match.group(0).lower(), None)
84
+ raise ValueError("Invalid VoidNote URL or token — expected .../tokenId#secret or 64-char token")
85
+
86
+
87
+ def _generate_secret() -> str:
88
+ return os.urandom(16).hex()
89
+
90
+
91
+ def _encrypt(content: str, secret: str) -> Tuple[str, str]:
92
+ if not _HAS_CRYPTO:
93
+ raise ImportError(
94
+ "The 'cryptography' package is required to create notes: "
95
+ "pip install cryptography"
96
+ )
97
+ key = hashlib.sha256(bytes.fromhex(secret)).digest()
98
+ iv = os.urandom(12)
99
+ aesgcm = AESGCM(key)
100
+ ciphertext = aesgcm.encrypt(iv, content.encode("utf-8"), None)
101
+ return ciphertext.hex(), iv.hex()
102
+
103
+
104
+ def _decrypt(enc_hex: str, iv_hex: str, secret: str) -> str:
105
+ if not _HAS_CRYPTO:
106
+ raise ImportError("pip install cryptography")
107
+ key = hashlib.sha256(bytes.fromhex(secret)).digest()
108
+ iv = bytes.fromhex(iv_hex)
109
+ ciphertext = bytes.fromhex(enc_hex)
110
+ aesgcm = AESGCM(key)
111
+ return aesgcm.decrypt(iv, ciphertext, None).decode("utf-8")
112
+
113
+
114
+ def _parse_body(raw: bytes) -> dict:
115
+ """JSON body as a dict; a non-JSON body (proxy page, plain-text error) becomes {"error": text}."""
116
+ text = raw.decode("utf-8", errors="replace")
117
+ try:
118
+ data = json.loads(text)
119
+ except json.JSONDecodeError:
120
+ return {"error": text.strip()[:300]}
121
+ return data if isinstance(data, dict) else {"data": data}
122
+
123
+
124
+ def _fetch(url: str, *, method: str = "GET", headers: dict | None = None, body: bytes | None = None):
125
+ req = urllib.request.Request(url, data=body, method=method, headers={"User-Agent": _USER_AGENT, **(headers or {})})
126
+ try:
127
+ with urllib.request.urlopen(req) as resp:
128
+ return _parse_body(resp.read()), resp.status
129
+ except urllib.error.HTTPError as e:
130
+ return _parse_body(e.read()), e.code
131
+
132
+
133
+ def _raise_for(data: dict, status: int) -> None:
134
+ if status >= 400:
135
+ raise RuntimeError(f"VoidNote: HTTP {status} — {data.get('error', 'request failed')}")
136
+
137
+
138
+ # --- public API ---
139
+
140
+ def read(url_or_token: str, base: str = DEFAULT_BASE) -> ReadResult:
141
+ """
142
+ Read and decrypt a VoidNote. Consumes one view.
143
+
144
+ Accepts both v1 URLs (https://voidnote.net/n/{tokenId}#{secret})
145
+ and legacy URLs (https://voidnote.net/note/{64-char-token}).
146
+
147
+ Example:
148
+ result = voidnote.read("https://voidnote.net/n/TOKEN#SECRET")
149
+ print(result.content)
150
+ """
151
+ fmt, data = _parse_note_url(url_or_token)
152
+
153
+ if fmt == 'v1':
154
+ token_id, secret = data
155
+ resp, status = _fetch(f"{base}/api/v1/note/{token_id}/consume/")
156
+ if status >= 400:
157
+ raise RuntimeError(resp.get("error", f"HTTP {status}"))
158
+ content = _decrypt(resp["encrypted"], resp["iv"], secret)
159
+ views_remaining = resp.get("viewsRemaining", 0)
160
+ return ReadResult(
161
+ content=content,
162
+ title=None,
163
+ view_count=1,
164
+ max_views=views_remaining + 1,
165
+ destroyed=resp.get("destroyed", False),
166
+ views_remaining=views_remaining,
167
+ )
168
+
169
+ # Legacy: server decrypts and returns plaintext
170
+ full_token = data[0]
171
+ resp, status = _fetch(f"{base}/api/note/{full_token}/")
172
+ if status >= 400:
173
+ raise RuntimeError(resp.get("error", f"HTTP {status}"))
174
+ return ReadResult(
175
+ content=resp["content"],
176
+ title=resp.get("title"),
177
+ view_count=resp["viewCount"],
178
+ max_views=resp["maxViews"],
179
+ destroyed=resp["destroyed"],
180
+ )
181
+
182
+
183
+ def peek(url_or_token: str, base: str = DEFAULT_BASE) -> PeekResult:
184
+ """
185
+ Peek at note metadata without consuming a view.
186
+
187
+ Example:
188
+ meta = voidnote.peek("https://voidnote.net/n/TOKEN#SECRET")
189
+ print(meta.views_remaining, meta.expires_at)
190
+ """
191
+ # Extract tokenId: strip fragment, get last path segment
192
+ without_fragment = url_or_token.split('#')[0].rstrip('/')
193
+ last_seg = without_fragment.rsplit('/', 1)[-1]
194
+ if len(last_seg) == 64 and re.fullmatch(r'[0-9a-f]+', last_seg, re.I):
195
+ token_id = last_seg[:32] # legacy: take first half
196
+ elif len(last_seg) == 32 and re.fullmatch(r'[0-9a-f]+', last_seg, re.I):
197
+ token_id = last_seg.lower()
198
+ else:
199
+ raise ValueError(f"Cannot extract tokenId from input: {url_or_token!r}")
200
+
201
+ resp, status = _fetch(f"{base}/api/v1/note/{token_id}/meta/")
202
+ if status >= 400:
203
+ raise RuntimeError(resp.get("error", f"HTTP {status}"))
204
+ return PeekResult(
205
+ exists=resp.get("exists", False),
206
+ type=resp.get("type"),
207
+ created_at=resp.get("createdAt"),
208
+ expires_at=resp.get("expiresAt"),
209
+ view_limit=resp.get("viewLimit"),
210
+ views_remaining=resp.get("viewsRemaining"),
211
+ )
212
+
213
+
214
+ def create(
215
+ content: str,
216
+ *,
217
+ api_key: str,
218
+ max_views: int = 1,
219
+ expires_in: int = 1,
220
+ base: str = DEFAULT_BASE,
221
+ ) -> CreateResult:
222
+ """
223
+ Create a VoidNote using the v1 API. Encrypts content client-side — server never
224
+ sees plaintext. Server generates the tokenId; client holds the secret in the URL fragment.
225
+
226
+ Args:
227
+ content: The secret text to encrypt and store
228
+ api_key: Your API key from dashboard (vn_...)
229
+ max_views: How many times the note can be read (1–100)
230
+ expires_in: Lifetime in hours (1–720, default 1)
231
+ base: Override base URL (default: https://voidnote.net)
232
+
233
+ Returns:
234
+ CreateResult with .url (includes #secret — share the full URL) and .token
235
+
236
+ Example:
237
+ result = voidnote.create("my-secret-api-key", api_key="vn_...")
238
+ print(result.url) # https://voidnote.net/n/TOKEN#SECRET
239
+ """
240
+ if not 1 <= max_views <= 100:
241
+ raise ValueError("max_views must be between 1 and 100")
242
+ if not 1 <= expires_in <= 720:
243
+ raise ValueError("expires_in must be between 1 and 720 hours")
244
+
245
+ secret = _generate_secret()
246
+ encrypted, iv = _encrypt(content, secret)
247
+
248
+ payload = {
249
+ "encrypted": encrypted,
250
+ "iv": iv,
251
+ "expiresIn": expires_in * 3600, # hours → seconds
252
+ "viewLimit": max_views,
253
+ }
254
+
255
+ body = json.dumps(payload).encode("utf-8")
256
+ headers = {
257
+ "Content-Type": "application/json",
258
+ "Authorization": f"Bearer {api_key}",
259
+ }
260
+
261
+ data, status = _fetch(f"{base}/api/v1/note/", method="POST", headers=headers, body=body)
262
+ if status >= 400:
263
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
264
+
265
+ # Server returns URL without secret — append #secret
266
+ server_url = data.get("url", f"{base}/n/{data.get('token', '')}")
267
+ return CreateResult(
268
+ url=f"{server_url}#{secret}",
269
+ token=data.get("token", ""),
270
+ expires_at=data.get("expiresAt", ""),
271
+ )
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Void Stream — live encrypted real-time channels
276
+ # ---------------------------------------------------------------------------
277
+
278
+ @dataclass
279
+ class StreamHandle:
280
+ """
281
+ A live Void Stream. Write encrypted messages, watch them appear in real time,
282
+ close to self-destruct. write() and close() authenticate with the API key
283
+ the stream was created with.
284
+ """
285
+ url: str
286
+ expires_at: str
287
+ _full_token: str = field(repr=False)
288
+ _secret: str = field(repr=False)
289
+ _base: str = field(repr=False, default=DEFAULT_BASE)
290
+ _api_key: str = field(repr=False, default="")
291
+
292
+ def _auth_headers(self) -> dict:
293
+ if not self._api_key:
294
+ raise RuntimeError("VoidNote: API key required to write to or close a broadcast stream")
295
+ return {"Content-Type": "application/json", "Authorization": f"Bearer {self._api_key}"}
296
+
297
+ def write(self, content: str) -> None:
298
+ """Encrypt and push a message to the stream."""
299
+ encrypted_content, iv = _encrypt(content, self._secret)
300
+ body = json.dumps({"encryptedContent": encrypted_content, "iv": iv}).encode()
301
+ data, status = _fetch(
302
+ f"{self._base}/api/stream/{self._full_token}/write/",
303
+ method="POST",
304
+ headers=self._auth_headers(),
305
+ body=body,
306
+ )
307
+ _raise_for(data, status)
308
+
309
+ def close(self) -> None:
310
+ """Close the stream — viewers see a closed event and all content self-destructs."""
311
+ data, status = _fetch(
312
+ f"{self._base}/api/stream/{self._full_token}/close/",
313
+ method="POST",
314
+ headers=self._auth_headers(),
315
+ body=b"{}",
316
+ )
317
+ _raise_for(data, status)
318
+
319
+ def watch(self) -> Generator[str, None, None]:
320
+ """Watch this stream locally. Yields decrypted messages."""
321
+ yield from watch(self.url, base=self._base)
322
+
323
+
324
+ def create_stream(
325
+ *,
326
+ api_key: str,
327
+ title: Optional[str] = None,
328
+ ttl: Literal[3600, 21600, 86400] = 3600,
329
+ base: str = DEFAULT_BASE,
330
+ ) -> StreamHandle:
331
+ """
332
+ Create a new Void Stream. Costs 1 credit.
333
+
334
+ Example:
335
+ stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
336
+ print(stream.url)
337
+ stream.write("Starting deployment...")
338
+ stream.close()
339
+ """
340
+ full_token = os.urandom(32).hex()
341
+ token_id = full_token[:32]
342
+ secret = full_token[32:]
343
+
344
+ payload = {"tokenId": token_id, "ttl": ttl}
345
+ if title:
346
+ payload["title"] = title
347
+
348
+ data, status = _fetch(
349
+ f"{base}/api/stream/",
350
+ method="POST",
351
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
352
+ body=json.dumps(payload).encode(),
353
+ )
354
+ _raise_for(data, status)
355
+ if not data.get("success"):
356
+ raise RuntimeError(f"VoidNote: {data.get('error', 'stream creation failed')}")
357
+
358
+ url = f"{data['siteUrl']}/stream/{full_token}"
359
+ return StreamHandle(
360
+ url=url,
361
+ expires_at=data.get("expiresAt", ""),
362
+ _full_token=full_token,
363
+ _secret=secret,
364
+ _base=base,
365
+ _api_key=api_key,
366
+ )
367
+
368
+
369
+ def _join_stream(base: str, token: str, password: Optional[str]) -> Optional[str]:
370
+ """Access token for reading a stream's events; None once the stream is closed or expired."""
371
+ payload = {} if password is None else {"password": password}
372
+ data, status = _fetch(
373
+ f"{base}/api/stream/{token}/join/",
374
+ method="POST",
375
+ headers={"Content-Type": "application/json"},
376
+ body=json.dumps(payload).encode(),
377
+ )
378
+ if status == 410:
379
+ return None
380
+ _raise_for(data, status)
381
+ access_token = data.get("accessToken")
382
+ if not access_token:
383
+ raise RuntimeError("VoidNote: join returned no access token")
384
+ return access_token
385
+
386
+
387
+ # Consecutive network failures tolerated while reconnecting before watch() gives up.
388
+ _WATCH_MAX_RETRIES = 5
389
+
390
+
391
+ def watch(
392
+ url_or_token: str,
393
+ base: str = DEFAULT_BASE,
394
+ *,
395
+ password: Optional[str] = None,
396
+ ) -> Generator[str, None, None]:
397
+ """
398
+ Watch a Void Stream. Yields decrypted messages as they arrive.
399
+ Blocks until the stream is closed or expired. Joins the stream for an
400
+ access token (re-joining when it expires) and reconnects with Last-Event-ID.
401
+ Raises RuntimeError on HTTP errors.
402
+
403
+ Example:
404
+ for message in voidnote.watch("https://voidnote.net/stream/abc123..."):
405
+ print(message)
406
+ """
407
+ token = re.search(r'[0-9a-f]{64}', url_or_token, re.I)
408
+ if not token:
409
+ raise ValueError("No valid 64-char stream token found in input")
410
+ token = token.group(0).lower()
411
+ secret = token[32:64]
412
+ key = hashlib.sha256(bytes.fromhex(secret)).digest()
413
+
414
+ access_token = _join_stream(base, token, password)
415
+ if access_token is None:
416
+ return
417
+ rejoined = False
418
+ failures = 0
419
+ last_id: Optional[str] = None
420
+
421
+ while True:
422
+ headers: dict = {
423
+ "Accept": "text/event-stream",
424
+ "Cache-Control": "no-cache",
425
+ "User-Agent": _USER_AGENT,
426
+ }
427
+ if last_id is not None:
428
+ headers["Last-Event-ID"] = last_id
429
+
430
+ query = urllib.parse.urlencode({"at": access_token})
431
+ req = urllib.request.Request(f"{base}/api/stream/{token}/events/?{query}", headers=headers)
432
+
433
+ try:
434
+ with urllib.request.urlopen(req, timeout=120) as resp:
435
+ rejoined = False
436
+ failures = 0
437
+ event_id: Optional[str] = None
438
+ event_data: Optional[str] = None
439
+
440
+ for raw_line in resp:
441
+ line = raw_line.decode("utf-8").rstrip("\r\n")
442
+
443
+ if line.startswith("id: "):
444
+ event_id = line[4:]
445
+ elif line.startswith("data: "):
446
+ event_data = line[6:]
447
+ elif line == "" and event_data is not None:
448
+ if event_id is not None:
449
+ last_id = event_id
450
+
451
+ try:
452
+ data = json.loads(event_data)
453
+ except json.JSONDecodeError:
454
+ event_data = None
455
+ event_id = None
456
+ continue
457
+
458
+ if data.get("type") in ("closed", "expired"):
459
+ return
460
+
461
+ if "enc" in data and "iv" in data:
462
+ try:
463
+ content = _decrypt_stream_msg(data["enc"], data["iv"], key)
464
+ yield content
465
+ except Exception:
466
+ pass
467
+
468
+ event_data = None
469
+ event_id = None
470
+ except urllib.error.HTTPError as e:
471
+ # Access tokens live 30 minutes: re-join once, then give up on a repeat 401.
472
+ if e.code == 401 and not rejoined:
473
+ access_token = _join_stream(base, token, password)
474
+ if access_token is None:
475
+ return
476
+ rejoined = True
477
+ continue
478
+ _raise_for(_parse_body(e.read()), e.code)
479
+ except (OSError, http.client.HTTPException):
480
+ failures += 1
481
+ if failures > _WATCH_MAX_RETRIES:
482
+ raise
483
+ time.sleep(min(2 ** failures, 30))
484
+
485
+
486
+ def _decrypt_stream_msg(enc_hex: str, iv_hex: str, key: bytes) -> str:
487
+ if not _HAS_CRYPTO:
488
+ raise ImportError("pip install cryptography")
489
+ iv = bytes.fromhex(iv_hex)
490
+ ciphertext = bytes.fromhex(enc_hex)
491
+ aesgcm = AESGCM(key)
492
+ plaintext = aesgcm.decrypt(iv, ciphertext, None)
493
+ return plaintext.decode("utf-8")
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # Buy / Credits API
498
+ # ---------------------------------------------------------------------------
499
+
500
+ @dataclass
501
+ class CryptoOrder:
502
+ order_id: str
503
+ to_address: str
504
+ chain: str
505
+ token: str
506
+ amount: str
507
+ amount_usd: float
508
+ credits: int
509
+ expires_at: str
510
+
511
+
512
+ @dataclass
513
+ class SubmitPaymentResult:
514
+ credits: int
515
+ credits_added: int
516
+
517
+
518
+ def create_crypto_order(
519
+ *,
520
+ api_key: str,
521
+ bundle_id: Literal["test", "starter", "standard", "pro"],
522
+ chain: Literal["polygon", "base", "arbitrum", "ethereum", "bitcoin", "tron"],
523
+ token: Literal["USDT", "USDC", "ETH", "BTC", "TRX"],
524
+ base: str = DEFAULT_BASE,
525
+ ) -> CryptoOrder:
526
+ """Create a crypto payment order for credits."""
527
+ body = json.dumps({"bundleId": bundle_id, "chain": chain, "token": token}).encode()
528
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
529
+ data, status = _fetch(f"{base}/api/buy/crypto/create-order/", method="POST", headers=headers, body=body)
530
+ if status >= 400:
531
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
532
+ return CryptoOrder(
533
+ order_id=data["orderId"],
534
+ to_address=data["toAddress"],
535
+ chain=data["chain"],
536
+ token=data["token"],
537
+ amount=data["amount"],
538
+ amount_usd=data["amountUsd"],
539
+ credits=data["credits"],
540
+ expires_at=data["expiresAt"],
541
+ )
542
+
543
+
544
+ def submit_crypto_payment(
545
+ *,
546
+ api_key: str,
547
+ order_id: str,
548
+ tx_hash: str,
549
+ base: str = DEFAULT_BASE,
550
+ ) -> SubmitPaymentResult:
551
+ """Submit a transaction hash to confirm a crypto payment."""
552
+ body = json.dumps({"orderId": order_id, "txHash": tx_hash}).encode()
553
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
554
+ data, status = _fetch(f"{base}/api/buy/crypto/submit-tx/", method="POST", headers=headers, body=body)
555
+ if status >= 400:
556
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
557
+ return SubmitPaymentResult(credits=data["credits"], credits_added=data["creditsAdded"])
558
+
559
+
560
+ __all__ = [
561
+ "read", "create", "peek", "ReadResult", "CreateResult", "PeekResult",
562
+ "create_stream", "watch", "StreamHandle",
563
+ "CryptoOrder", "SubmitPaymentResult", "create_crypto_order", "submit_crypto_payment",
564
+ ]
voidnote-0.2.0/PKG-INFO DELETED
@@ -1,67 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: voidnote
3
- Version: 0.2.0
4
- Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams
5
- Project-URL: Homepage, https://voidnote.net
6
- Project-URL: Repository, https://github.com/quantum-encoding/voidnote-python
7
- Project-URL: Documentation, https://voidnote.net/docs
8
- License-Expression: MIT
9
- Keywords: encryption,one-time,secrets,voidnote,zero-knowledge
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Topic :: Security :: Cryptography
15
- Requires-Python: >=3.9
16
- Requires-Dist: cryptography>=41.0.0
17
- Provides-Extra: dev
18
- Requires-Dist: mypy; extra == 'dev'
19
- Requires-Dist: pytest; extra == 'dev'
20
- Description-Content-Type: text/markdown
21
-
22
- # voidnote
23
-
24
- Official Python SDK for [VoidNote](https://voidnote.net) — zero-knowledge self-destructing notes.
25
-
26
- ## Install
27
-
28
- ```bash
29
- pip install voidnote
30
- ```
31
-
32
- ## Usage
33
-
34
- ### Read a note
35
-
36
- ```python
37
- import voidnote
38
-
39
- result = voidnote.read("https://voidnote.net/note/abc123...")
40
- print(result.content) # decrypted content
41
- print(result.destroyed) # True if view limit was reached
42
- ```
43
-
44
- ### Create a note (requires API key)
45
-
46
- ```python
47
- import voidnote
48
-
49
- result = voidnote.create(
50
- "my secret value",
51
- api_key="vn_...",
52
- max_views=1,
53
- title="Deploy key",
54
- )
55
- print(result.url) # share this link
56
- ```
57
-
58
- ## Requirements
59
-
60
- - Python 3.9+
61
- - `cryptography >= 41.0.0`
62
-
63
- ## Links
64
-
65
- - [voidnote.net](https://voidnote.net)
66
- - [How it works](https://voidnote.net/how-it-works)
67
- - [GitHub](https://github.com/quantum-encoding/voidnote-python)
voidnote-0.2.0/README.md DELETED
@@ -1,46 +0,0 @@
1
- # voidnote
2
-
3
- Official Python SDK for [VoidNote](https://voidnote.net) — zero-knowledge self-destructing notes.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install voidnote
9
- ```
10
-
11
- ## Usage
12
-
13
- ### Read a note
14
-
15
- ```python
16
- import voidnote
17
-
18
- result = voidnote.read("https://voidnote.net/note/abc123...")
19
- print(result.content) # decrypted content
20
- print(result.destroyed) # True if view limit was reached
21
- ```
22
-
23
- ### Create a note (requires API key)
24
-
25
- ```python
26
- import voidnote
27
-
28
- result = voidnote.create(
29
- "my secret value",
30
- api_key="vn_...",
31
- max_views=1,
32
- title="Deploy key",
33
- )
34
- print(result.url) # share this link
35
- ```
36
-
37
- ## Requirements
38
-
39
- - Python 3.9+
40
- - `cryptography >= 41.0.0`
41
-
42
- ## Links
43
-
44
- - [voidnote.net](https://voidnote.net)
45
- - [How it works](https://voidnote.net/how-it-works)
46
- - [GitHub](https://github.com/quantum-encoding/voidnote-python)
@@ -1,357 +0,0 @@
1
- """
2
- VoidNote SDK — Python
3
-
4
- pip install voidnote
5
-
6
- Works with Python 3.9+. Requires: cryptography
7
-
8
- pip install voidnote cryptography
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import hashlib
14
- import os
15
- import re
16
- import urllib.request
17
- import json
18
- from dataclasses import dataclass, field
19
- from typing import Optional, Generator, Literal
20
-
21
- try:
22
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
- _HAS_CRYPTO = True
24
- except ImportError:
25
- _HAS_CRYPTO = False
26
-
27
- DEFAULT_BASE = "https://voidnote.net"
28
-
29
-
30
- @dataclass
31
- class ReadResult:
32
- content: str
33
- title: Optional[str]
34
- view_count: int
35
- max_views: int
36
- destroyed: bool
37
-
38
-
39
- @dataclass
40
- class CreateResult:
41
- url: str
42
- expires_at: str
43
-
44
-
45
- # --- internal helpers ---
46
-
47
- def _extract_token(url_or_token: str) -> str:
48
- match = re.search(r"[0-9a-f]{64}", url_or_token, re.IGNORECASE)
49
- if not match:
50
- raise ValueError("No valid 64-char token found in input")
51
- return match.group(0).lower()
52
-
53
-
54
- def _generate_token() -> tuple[str, str, str]:
55
- token_bytes = os.urandom(32)
56
- full_token = token_bytes.hex()
57
- return full_token, full_token[:32], full_token[32:]
58
-
59
-
60
- def _encrypt(content: str, secret: str) -> tuple[str, str]:
61
- if not _HAS_CRYPTO:
62
- raise ImportError(
63
- "The 'cryptography' package is required to create notes: "
64
- "pip install cryptography"
65
- )
66
- secret_bytes = bytes.fromhex(secret)
67
- key = hashlib.sha256(secret_bytes).digest()
68
- iv = os.urandom(12)
69
- aesgcm = AESGCM(key)
70
- ciphertext = aesgcm.encrypt(iv, content.encode("utf-8"), None)
71
- return ciphertext.hex(), iv.hex()
72
-
73
-
74
- def _fetch(url: str, *, method: str = "GET", headers: dict | None = None, body: bytes | None = None):
75
- req = urllib.request.Request(url, data=body, method=method, headers=headers or {})
76
- with urllib.request.urlopen(req) as resp:
77
- return json.loads(resp.read().decode("utf-8")), resp.status
78
-
79
-
80
- # --- public API ---
81
-
82
- def read(url_or_token: str, base: str = DEFAULT_BASE) -> ReadResult:
83
- """
84
- Read a VoidNote. Consumes one view.
85
-
86
- Args:
87
- url_or_token: Full VoidNote URL or raw 64-char hex token
88
- base: Override base URL (default: https://voidnote.net)
89
-
90
- Returns:
91
- ReadResult with .content, .title, .view_count, .max_views, .destroyed
92
-
93
- Example:
94
- result = voidnote.read("https://voidnote.net/note/abc123...")
95
- print(result.content)
96
- """
97
- token = _extract_token(url_or_token)
98
- data, status = _fetch(f"{base}/api/note/{token}")
99
- if status >= 400:
100
- raise RuntimeError(data.get("error", f"HTTP {status}"))
101
- return ReadResult(
102
- content=data["content"],
103
- title=data.get("title"),
104
- view_count=data["viewCount"],
105
- max_views=data["maxViews"],
106
- destroyed=data["destroyed"],
107
- )
108
-
109
-
110
- def create(
111
- content: str,
112
- *,
113
- api_key: str,
114
- title: Optional[str] = None,
115
- max_views: int = 1,
116
- base: str = DEFAULT_BASE,
117
- ) -> CreateResult:
118
- """
119
- Create a VoidNote. Encrypts content client-side — the server never sees plaintext.
120
-
121
- Args:
122
- content: The secret text to encrypt and store
123
- api_key: Your API key from dashboard (vn_...)
124
- title: Optional plaintext title (keep non-sensitive)
125
- max_views: How many times the note can be read (1–100)
126
- base: Override base URL (default: https://voidnote.net)
127
-
128
- Returns:
129
- CreateResult with .url (shareable link) and .expires_at
130
-
131
- Example:
132
- result = voidnote.create("my-secret-api-key", api_key="vn_...")
133
- print(result.url)
134
- """
135
- if not 1 <= max_views <= 100:
136
- raise ValueError("max_views must be between 1 and 100")
137
-
138
- full_token, token_id, secret = _generate_token()
139
- encrypted_content, iv = _encrypt(content, secret)
140
-
141
- payload = {
142
- "tokenId": token_id,
143
- "encryptedContent": encrypted_content,
144
- "iv": iv,
145
- "maxViews": max_views,
146
- }
147
- if title:
148
- payload["title"] = title
149
-
150
- body = json.dumps(payload).encode("utf-8")
151
- headers = {
152
- "Content-Type": "application/json",
153
- "Authorization": f"Bearer {api_key}",
154
- }
155
-
156
- data, status = _fetch(f"{base}/api/notes", method="POST", headers=headers, body=body)
157
- if status >= 400 or not data.get("success"):
158
- raise RuntimeError(data.get("error", f"HTTP {status}"))
159
-
160
- return CreateResult(
161
- url=f"{data['siteUrl']}/note/{full_token}",
162
- expires_at=data["expiresAt"],
163
- )
164
-
165
-
166
- # ---------------------------------------------------------------------------
167
- # Void Stream — live encrypted real-time channels
168
- # ---------------------------------------------------------------------------
169
-
170
- @dataclass
171
- class StreamHandle:
172
- """
173
- A live Void Stream. Write encrypted messages, watch them appear in real time,
174
- close to self-destruct.
175
-
176
- Returned by create_stream(). The URL contains the decryption key — share it
177
- with viewers. The server never sees plaintext.
178
-
179
- Example:
180
- stream = voidnote.create_stream(api_key="vn_...", title="Deploy run #42")
181
- print(stream.url) # share with your viewer
182
- stream.write("Deployment starting…")
183
- stream.write("Build complete — 47/47 passed")
184
- stream.close()
185
- """
186
- url: str
187
- expires_at: str
188
- _full_token: str = field(repr=False)
189
- _secret: str = field(repr=False)
190
- _base: str = field(repr=False, default=DEFAULT_BASE)
191
-
192
- def write(self, content: str) -> None:
193
- """Encrypt and push a message to the stream."""
194
- if not _HAS_CRYPTO:
195
- raise ImportError("pip install cryptography")
196
- encrypted_content, iv = _encrypt(content, self._secret)
197
- body = json.dumps({"encryptedContent": encrypted_content, "iv": iv}).encode()
198
- data, status = _fetch(
199
- f"{self._base}/api/stream/{self._full_token}/write",
200
- method="POST",
201
- headers={"Content-Type": "application/json"},
202
- body=body,
203
- )
204
- if status >= 400 or not data.get("success"):
205
- raise RuntimeError(data.get("error", f"HTTP {status}"))
206
-
207
- def close(self) -> None:
208
- """Close the stream — viewers see a closed event and all content self-destructs."""
209
- _fetch(
210
- f"{self._base}/api/stream/{self._full_token}/close",
211
- method="POST",
212
- headers={"Content-Type": "application/json"},
213
- body=b"{}",
214
- )
215
-
216
- def watch(self) -> Generator[str, None, None]:
217
- """
218
- Watch this stream locally (writer-side). Yields decrypted messages.
219
- Useful for testing or observing your own stream.
220
- """
221
- yield from watch(self.url, base=self._base)
222
-
223
-
224
- def create_stream(
225
- *,
226
- api_key: str,
227
- title: Optional[str] = None,
228
- ttl: Literal[3600, 21600, 86400] = 3600,
229
- base: str = DEFAULT_BASE,
230
- ) -> StreamHandle:
231
- """
232
- Create a new Void Stream. Costs 1 credit.
233
-
234
- Args:
235
- api_key: Your API key (vn_...) from the dashboard
236
- title: Optional plaintext title
237
- ttl: Lifetime in seconds: 3600 (1h), 21600 (6h), or 86400 (24h)
238
- base: Override base URL
239
-
240
- Returns:
241
- StreamHandle with .url, .write(), .close(), .watch()
242
-
243
- Example:
244
- stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
245
- print(stream.url)
246
- stream.write("Starting deployment...")
247
- stream.close()
248
- """
249
- full_token, token_id, secret = _generate_token()
250
-
251
- payload = {"tokenId": token_id, "ttl": ttl}
252
- if title:
253
- payload["title"] = title
254
-
255
- data, status = _fetch(
256
- f"{base}/api/stream",
257
- method="POST",
258
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
259
- body=json.dumps(payload).encode(),
260
- )
261
- if status >= 400 or not data.get("success"):
262
- raise RuntimeError(data.get("error", f"HTTP {status}"))
263
-
264
- url = f"{data['siteUrl']}/stream/{full_token}"
265
- return StreamHandle(
266
- url=url,
267
- expires_at=data.get("expiresAt", ""),
268
- _full_token=full_token,
269
- _secret=secret,
270
- _base=base,
271
- )
272
-
273
-
274
- def watch(url_or_token: str, base: str = DEFAULT_BASE) -> Generator[str, None, None]:
275
- """
276
- Watch a Void Stream. Yields decrypted messages as they arrive.
277
- Blocks until the stream is closed or expired.
278
- Automatically reconnects on disconnect using Last-Event-ID.
279
-
280
- Args:
281
- url_or_token: Full stream URL or raw 64-char token
282
- base: Override base URL
283
-
284
- Example:
285
- for message in voidnote.watch("https://voidnote.net/stream/abc123..."):
286
- print(message)
287
- """
288
- token = _extract_token(url_or_token)
289
- secret = token[32:64]
290
- key = hashlib.sha256(bytes.fromhex(secret)).digest()
291
-
292
- last_id: Optional[str] = None
293
-
294
- while True:
295
- headers: dict = {"Accept": "text/event-stream", "Cache-Control": "no-cache"}
296
- if last_id is not None:
297
- headers["Last-Event-ID"] = last_id
298
-
299
- req = urllib.request.Request(f"{base}/api/stream/{token}/events", headers=headers)
300
-
301
- try:
302
- with urllib.request.urlopen(req, timeout=120) as resp:
303
- event_id: Optional[str] = None
304
- event_data: Optional[str] = None
305
-
306
- for raw_line in resp:
307
- line = raw_line.decode("utf-8").rstrip("\r\n")
308
-
309
- if line.startswith("id: "):
310
- event_id = line[4:]
311
- elif line.startswith("data: "):
312
- event_data = line[6:]
313
- elif line == "" and event_data is not None:
314
- if event_id is not None:
315
- last_id = event_id
316
-
317
- try:
318
- data = json.loads(event_data)
319
- except json.JSONDecodeError:
320
- event_data = None
321
- event_id = None
322
- continue
323
-
324
- if data.get("type") in ("closed", "expired"):
325
- return
326
-
327
- if "enc" in data and "iv" in data:
328
- try:
329
- content = _decrypt_stream_msg(data["enc"], data["iv"], key)
330
- yield content
331
- except Exception:
332
- pass # tampered or wrong key
333
-
334
- event_data = None
335
- event_id = None
336
- except Exception:
337
- if last_id is None:
338
- raise
339
- # Reconnect on transient failure
340
- continue
341
-
342
-
343
- def _decrypt_stream_msg(enc_hex: str, iv_hex: str, key: bytes) -> str:
344
- """Decrypt a single stream message using pre-derived AES key."""
345
- if not _HAS_CRYPTO:
346
- raise ImportError("pip install cryptography")
347
- iv = bytes.fromhex(iv_hex)
348
- ciphertext = bytes.fromhex(enc_hex)
349
- aesgcm = AESGCM(key)
350
- plaintext = aesgcm.decrypt(iv, ciphertext, None)
351
- return plaintext.decode("utf-8")
352
-
353
-
354
- __all__ = [
355
- "read", "create", "ReadResult", "CreateResult",
356
- "create_stream", "watch", "StreamHandle",
357
- ]