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.
- voidnote-0.4.1/.gitignore +11 -0
- voidnote-0.4.1/PKG-INFO +154 -0
- voidnote-0.4.1/README.md +133 -0
- {voidnote-0.2.0 → voidnote-0.4.1}/pyproject.toml +1 -1
- voidnote-0.4.1/voidnote/__init__.py +564 -0
- voidnote-0.2.0/PKG-INFO +0 -67
- voidnote-0.2.0/README.md +0 -46
- voidnote-0.2.0/voidnote/__init__.py +0 -357
voidnote-0.4.1/PKG-INFO
ADDED
|
@@ -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)
|
voidnote-0.4.1/README.md
ADDED
|
@@ -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)
|
|
@@ -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
|
-
]
|