voidnote 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidnote
3
- Version: 0.1.0
4
- Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes
3
+ Version: 0.3.0
4
+ Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams
5
5
  Project-URL: Homepage, https://voidnote.net
6
6
  Project-URL: Repository, https://github.com/quantum-encoding/voidnote-python
7
7
  Project-URL: Documentation, https://voidnote.net/docs
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voidnote"
7
- version = "0.1.0"
8
- description = "Official SDK for VoidNote — zero-knowledge self-destructing notes"
7
+ version = "0.3.0"
8
+ description = "Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
@@ -0,0 +1,461 @@
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
+ expires_in: int = 24,
117
+ note_type: Literal["secure", "pipe"] = "secure",
118
+ base: str = DEFAULT_BASE,
119
+ ) -> CreateResult:
120
+ """
121
+ Create a VoidNote. Encrypts content client-side — the server never sees plaintext.
122
+
123
+ Args:
124
+ content: The secret text to encrypt and store
125
+ api_key: Your API key from dashboard (vn_...)
126
+ title: Optional plaintext title (keep non-sensitive)
127
+ max_views: How many times the note can be read (1–100)
128
+ expires_in: Lifetime in hours — one of: 1, 6, 24, 72, 168, 720
129
+ note_type: "secure" (default) or "pipe"
130
+ base: Override base URL (default: https://voidnote.net)
131
+
132
+ Returns:
133
+ CreateResult with .url (shareable link) and .expires_at
134
+
135
+ Example:
136
+ result = voidnote.create("my-secret-api-key", api_key="vn_...")
137
+ print(result.url)
138
+ """
139
+ if not 1 <= max_views <= 100:
140
+ raise ValueError("max_views must be between 1 and 100")
141
+ if expires_in not in (1, 6, 24, 72, 168, 720):
142
+ raise ValueError("expires_in must be one of: 1, 6, 24, 72, 168, 720")
143
+
144
+ full_token, token_id, secret = _generate_token()
145
+ encrypted_content, iv = _encrypt(content, secret)
146
+
147
+ payload = {
148
+ "tokenId": token_id,
149
+ "encryptedContent": encrypted_content,
150
+ "iv": iv,
151
+ "maxViews": max_views,
152
+ "expiresIn": expires_in,
153
+ "noteType": note_type,
154
+ }
155
+ if title:
156
+ payload["title"] = title
157
+
158
+ body = json.dumps(payload).encode("utf-8")
159
+ headers = {
160
+ "Content-Type": "application/json",
161
+ "Authorization": f"Bearer {api_key}",
162
+ }
163
+
164
+ data, status = _fetch(f"{base}/api/notes", method="POST", headers=headers, body=body)
165
+ if status >= 400 or not data.get("success"):
166
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
167
+
168
+ return CreateResult(
169
+ url=f"{data['siteUrl']}/note/{full_token}",
170
+ expires_at=data["expiresAt"],
171
+ )
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Void Stream — live encrypted real-time channels
176
+ # ---------------------------------------------------------------------------
177
+
178
+ @dataclass
179
+ class StreamHandle:
180
+ """
181
+ A live Void Stream. Write encrypted messages, watch them appear in real time,
182
+ close to self-destruct.
183
+
184
+ Returned by create_stream(). The URL contains the decryption key — share it
185
+ with viewers. The server never sees plaintext.
186
+
187
+ Example:
188
+ stream = voidnote.create_stream(api_key="vn_...", title="Deploy run #42")
189
+ print(stream.url) # share with your viewer
190
+ stream.write("Deployment starting…")
191
+ stream.write("Build complete — 47/47 passed")
192
+ stream.close()
193
+ """
194
+ url: str
195
+ expires_at: str
196
+ _full_token: str = field(repr=False)
197
+ _secret: str = field(repr=False)
198
+ _base: str = field(repr=False, default=DEFAULT_BASE)
199
+
200
+ def write(self, content: str) -> None:
201
+ """Encrypt and push a message to the stream."""
202
+ if not _HAS_CRYPTO:
203
+ raise ImportError("pip install cryptography")
204
+ encrypted_content, iv = _encrypt(content, self._secret)
205
+ body = json.dumps({"encryptedContent": encrypted_content, "iv": iv}).encode()
206
+ data, status = _fetch(
207
+ f"{self._base}/api/stream/{self._full_token}/write",
208
+ method="POST",
209
+ headers={"Content-Type": "application/json"},
210
+ body=body,
211
+ )
212
+ if status >= 400 or not data.get("success"):
213
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
214
+
215
+ def close(self) -> None:
216
+ """Close the stream — viewers see a closed event and all content self-destructs."""
217
+ _fetch(
218
+ f"{self._base}/api/stream/{self._full_token}/close",
219
+ method="POST",
220
+ headers={"Content-Type": "application/json"},
221
+ body=b"{}",
222
+ )
223
+
224
+ def watch(self) -> Generator[str, None, None]:
225
+ """
226
+ Watch this stream locally (writer-side). Yields decrypted messages.
227
+ Useful for testing or observing your own stream.
228
+ """
229
+ yield from watch(self.url, base=self._base)
230
+
231
+
232
+ def create_stream(
233
+ *,
234
+ api_key: str,
235
+ title: Optional[str] = None,
236
+ ttl: Literal[3600, 21600, 86400] = 3600,
237
+ base: str = DEFAULT_BASE,
238
+ ) -> StreamHandle:
239
+ """
240
+ Create a new Void Stream. Costs 1 credit.
241
+
242
+ Args:
243
+ api_key: Your API key (vn_...) from the dashboard
244
+ title: Optional plaintext title
245
+ ttl: Lifetime in seconds: 3600 (1h), 21600 (6h), or 86400 (24h)
246
+ base: Override base URL
247
+
248
+ Returns:
249
+ StreamHandle with .url, .write(), .close(), .watch()
250
+
251
+ Example:
252
+ stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
253
+ print(stream.url)
254
+ stream.write("Starting deployment...")
255
+ stream.close()
256
+ """
257
+ full_token, token_id, secret = _generate_token()
258
+
259
+ payload = {"tokenId": token_id, "ttl": ttl}
260
+ if title:
261
+ payload["title"] = title
262
+
263
+ data, status = _fetch(
264
+ f"{base}/api/stream",
265
+ method="POST",
266
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
267
+ body=json.dumps(payload).encode(),
268
+ )
269
+ if status >= 400 or not data.get("success"):
270
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
271
+
272
+ url = f"{data['siteUrl']}/stream/{full_token}"
273
+ return StreamHandle(
274
+ url=url,
275
+ expires_at=data.get("expiresAt", ""),
276
+ _full_token=full_token,
277
+ _secret=secret,
278
+ _base=base,
279
+ )
280
+
281
+
282
+ def watch(url_or_token: str, base: str = DEFAULT_BASE) -> Generator[str, None, None]:
283
+ """
284
+ Watch a Void Stream. Yields decrypted messages as they arrive.
285
+ Blocks until the stream is closed or expired.
286
+ Automatically reconnects on disconnect using Last-Event-ID.
287
+
288
+ Args:
289
+ url_or_token: Full stream URL or raw 64-char token
290
+ base: Override base URL
291
+
292
+ Example:
293
+ for message in voidnote.watch("https://voidnote.net/stream/abc123..."):
294
+ print(message)
295
+ """
296
+ token = _extract_token(url_or_token)
297
+ secret = token[32:64]
298
+ key = hashlib.sha256(bytes.fromhex(secret)).digest()
299
+
300
+ last_id: Optional[str] = None
301
+
302
+ while True:
303
+ headers: dict = {"Accept": "text/event-stream", "Cache-Control": "no-cache"}
304
+ if last_id is not None:
305
+ headers["Last-Event-ID"] = last_id
306
+
307
+ req = urllib.request.Request(f"{base}/api/stream/{token}/events", headers=headers)
308
+
309
+ try:
310
+ with urllib.request.urlopen(req, timeout=120) as resp:
311
+ event_id: Optional[str] = None
312
+ event_data: Optional[str] = None
313
+
314
+ for raw_line in resp:
315
+ line = raw_line.decode("utf-8").rstrip("\r\n")
316
+
317
+ if line.startswith("id: "):
318
+ event_id = line[4:]
319
+ elif line.startswith("data: "):
320
+ event_data = line[6:]
321
+ elif line == "" and event_data is not None:
322
+ if event_id is not None:
323
+ last_id = event_id
324
+
325
+ try:
326
+ data = json.loads(event_data)
327
+ except json.JSONDecodeError:
328
+ event_data = None
329
+ event_id = None
330
+ continue
331
+
332
+ if data.get("type") in ("closed", "expired"):
333
+ return
334
+
335
+ if "enc" in data and "iv" in data:
336
+ try:
337
+ content = _decrypt_stream_msg(data["enc"], data["iv"], key)
338
+ yield content
339
+ except Exception:
340
+ pass # tampered or wrong key
341
+
342
+ event_data = None
343
+ event_id = None
344
+ except Exception:
345
+ if last_id is None:
346
+ raise
347
+ # Reconnect on transient failure
348
+ continue
349
+
350
+
351
+ def _decrypt_stream_msg(enc_hex: str, iv_hex: str, key: bytes) -> str:
352
+ """Decrypt a single stream message using pre-derived AES key."""
353
+ if not _HAS_CRYPTO:
354
+ raise ImportError("pip install cryptography")
355
+ iv = bytes.fromhex(iv_hex)
356
+ ciphertext = bytes.fromhex(enc_hex)
357
+ aesgcm = AESGCM(key)
358
+ plaintext = aesgcm.decrypt(iv, ciphertext, None)
359
+ return plaintext.decode("utf-8")
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Buy / Credits API
364
+ # ---------------------------------------------------------------------------
365
+
366
+ @dataclass
367
+ class CryptoOrder:
368
+ order_id: str
369
+ to_address: str
370
+ chain: str
371
+ token: str
372
+ amount: str # human-readable, e.g. "5.000000"
373
+ amount_usd: float
374
+ credits: int
375
+ expires_at: str
376
+
377
+
378
+ @dataclass
379
+ class SubmitPaymentResult:
380
+ credits: int
381
+ credits_added: int
382
+
383
+
384
+ def create_crypto_order(
385
+ *,
386
+ api_key: str,
387
+ bundle_id: Literal["test", "starter", "standard", "pro"],
388
+ chain: Literal["polygon", "base", "arbitrum", "ethereum", "bitcoin", "tron"],
389
+ token: Literal["USDT", "USDC", "ETH", "BTC", "TRX"],
390
+ base: str = DEFAULT_BASE,
391
+ ) -> CryptoOrder:
392
+ """
393
+ Create a crypto payment order for credits.
394
+ Returns an address and exact amount to send — valid for 1 hour.
395
+
396
+ Args:
397
+ api_key: Your API key (vn_...) from the dashboard
398
+ bundle_id: "test" ($1/20), "starter" ($5/100), "standard" ($20/500), "pro" ($35/1000)
399
+ chain: Payment network
400
+ token: Token to send
401
+ base: Override base URL
402
+
403
+ Example:
404
+ order = voidnote.create_crypto_order(
405
+ api_key="vn_...", bundle_id="starter", chain="polygon", token="USDT"
406
+ )
407
+ print(f"Send {order.amount} {order.token} to {order.to_address}")
408
+ """
409
+ body = json.dumps({"bundleId": bundle_id, "chain": chain, "token": token}).encode()
410
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
411
+ data, status = _fetch(f"{base}/api/buy/crypto/create-order", method="POST", headers=headers, body=body)
412
+ if status >= 400:
413
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
414
+ return CryptoOrder(
415
+ order_id=data["orderId"],
416
+ to_address=data["toAddress"],
417
+ chain=data["chain"],
418
+ token=data["token"],
419
+ amount=data["amount"],
420
+ amount_usd=data["amountUsd"],
421
+ credits=data["credits"],
422
+ expires_at=data["expiresAt"],
423
+ )
424
+
425
+
426
+ def submit_crypto_payment(
427
+ *,
428
+ api_key: str,
429
+ order_id: str,
430
+ tx_hash: str,
431
+ base: str = DEFAULT_BASE,
432
+ ) -> SubmitPaymentResult:
433
+ """
434
+ Submit a transaction hash to confirm a crypto payment.
435
+ Verifies on-chain and credits your account instantly.
436
+
437
+ Args:
438
+ api_key: Your API key (vn_...) from the dashboard
439
+ order_id: Order ID from create_crypto_order()
440
+ tx_hash: Transaction hash from your wallet
441
+ base: Override base URL
442
+
443
+ Example:
444
+ result = voidnote.submit_crypto_payment(
445
+ api_key="vn_...", order_id=order.order_id, tx_hash="0x..."
446
+ )
447
+ print(f"New balance: {result.credits} credits")
448
+ """
449
+ body = json.dumps({"orderId": order_id, "txHash": tx_hash}).encode()
450
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
451
+ data, status = _fetch(f"{base}/api/buy/crypto/submit-tx", method="POST", headers=headers, body=body)
452
+ if status >= 400:
453
+ raise RuntimeError(data.get("error", f"HTTP {status}"))
454
+ return SubmitPaymentResult(credits=data["credits"], credits_added=data["creditsAdded"])
455
+
456
+
457
+ __all__ = [
458
+ "read", "create", "ReadResult", "CreateResult",
459
+ "create_stream", "watch", "StreamHandle",
460
+ "CryptoOrder", "SubmitPaymentResult", "create_crypto_order", "submit_crypto_payment",
461
+ ]
voidnote-0.1.0/.gitignore DELETED
@@ -1,26 +0,0 @@
1
- # Dependencies
2
- node_modules/
3
-
4
- # Claude
5
- .claude/
6
- # Build output
7
- dist/
8
-
9
- # Astro
10
- .astro/
11
-
12
- # Wrangler
13
- .wrangler/
14
-
15
- # Environment
16
- .env
17
- .env.*
18
- .dev.vars
19
-
20
- # OS
21
- .DS_Store
22
-
23
- # IDE
24
- .vscode/
25
- .idea/
26
- node_modules
@@ -1,166 +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
19
- from typing import Optional
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
- __all__ = ["read", "create", "ReadResult", "CreateResult"]
File without changes