voidnote 0.1.0__tar.gz → 0.2.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.2.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.2.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,357 @@
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
+ ]
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