paramant-sdk 1.0.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.
- paramant_sdk-1.0.0/PKG-INFO +87 -0
- paramant_sdk-1.0.0/README.md +62 -0
- paramant_sdk-1.0.0/paramant_sdk/__init__.py +545 -0
- paramant_sdk-1.0.0/paramant_sdk.egg-info/PKG-INFO +87 -0
- paramant_sdk-1.0.0/paramant_sdk.egg-info/SOURCES.txt +8 -0
- paramant_sdk-1.0.0/paramant_sdk.egg-info/dependency_links.txt +1 -0
- paramant_sdk-1.0.0/paramant_sdk.egg-info/requires.txt +4 -0
- paramant_sdk-1.0.0/paramant_sdk.egg-info/top_level.txt +1 -0
- paramant_sdk-1.0.0/pyproject.toml +39 -0
- paramant_sdk-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paramant-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: PARAMANT Ghost Pipe SDK — post-quantum burn-on-read secure transport
|
|
5
|
+
Author-email: PARAMANT <hello@paramant.app>
|
|
6
|
+
License: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://paramant.app
|
|
8
|
+
Project-URL: Documentation, https://paramant.app/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/paramant/sdk-py
|
|
10
|
+
Keywords: paramant,ghost-pipe,post-quantum,ml-kem,burn-on-read,secure-messaging,e2ee
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=42.0
|
|
23
|
+
Provides-Extra: mlkem
|
|
24
|
+
Requires-Dist: pqcrypto; extra == "mlkem"
|
|
25
|
+
|
|
26
|
+
# paramant-sdk
|
|
27
|
+
|
|
28
|
+
PARAMANT Ghost Pipe SDK for Python. Post-quantum burn-on-read secure transport.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install paramant-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from paramant_sdk import GhostPipe
|
|
40
|
+
|
|
41
|
+
# Sender
|
|
42
|
+
sender = GhostPipe(api_key='pk_live_...', device='sender-001')
|
|
43
|
+
hash_ = sender.send(b'confidential payload')
|
|
44
|
+
print('hash:', hash_)
|
|
45
|
+
|
|
46
|
+
# Receiver
|
|
47
|
+
receiver = GhostPipe(api_key='pk_live_...', device='receiver-001')
|
|
48
|
+
data = receiver.receive(hash_)
|
|
49
|
+
print('received:', data)
|
|
50
|
+
# blob is burned after receive
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
gp = GhostPipe(api_key, device, relay='', sector='health')
|
|
57
|
+
|
|
58
|
+
hash_ = gp.send(data: bytes, ttl=300) -> str
|
|
59
|
+
data = gp.receive(hash_: str) -> bytes
|
|
60
|
+
status = gp.status(hash_: str) -> dict
|
|
61
|
+
entries = gp.audit(limit=100) -> list
|
|
62
|
+
info = gp.health() -> dict
|
|
63
|
+
gp.listen(on_receive: callable, interval=3)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Multi-relay cluster
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from paramant_sdk import GhostPipeCluster
|
|
70
|
+
|
|
71
|
+
cluster = GhostPipeCluster(
|
|
72
|
+
api_key='pk_live_...',
|
|
73
|
+
device='device-001',
|
|
74
|
+
relays=['https://health.paramant.app', 'https://relay.paramant.app'],
|
|
75
|
+
)
|
|
76
|
+
hash_ = cluster.send(data)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Protocol
|
|
80
|
+
|
|
81
|
+
- **Encryption**: ML-KEM-768 + ECDH P-256 + AES-256-GCM (hybrid PQC)
|
|
82
|
+
- **Burn-on-read**: blob deleted after first `receive()`
|
|
83
|
+
- **Sectors**: EU/DE Hetzner + Fly.io anycast
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
BUSL-1.1 — [paramant.app](https://paramant.app)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# paramant-sdk
|
|
2
|
+
|
|
3
|
+
PARAMANT Ghost Pipe SDK for Python. Post-quantum burn-on-read secure transport.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install paramant-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from paramant_sdk import GhostPipe
|
|
15
|
+
|
|
16
|
+
# Sender
|
|
17
|
+
sender = GhostPipe(api_key='pk_live_...', device='sender-001')
|
|
18
|
+
hash_ = sender.send(b'confidential payload')
|
|
19
|
+
print('hash:', hash_)
|
|
20
|
+
|
|
21
|
+
# Receiver
|
|
22
|
+
receiver = GhostPipe(api_key='pk_live_...', device='receiver-001')
|
|
23
|
+
data = receiver.receive(hash_)
|
|
24
|
+
print('received:', data)
|
|
25
|
+
# blob is burned after receive
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
gp = GhostPipe(api_key, device, relay='', sector='health')
|
|
32
|
+
|
|
33
|
+
hash_ = gp.send(data: bytes, ttl=300) -> str
|
|
34
|
+
data = gp.receive(hash_: str) -> bytes
|
|
35
|
+
status = gp.status(hash_: str) -> dict
|
|
36
|
+
entries = gp.audit(limit=100) -> list
|
|
37
|
+
info = gp.health() -> dict
|
|
38
|
+
gp.listen(on_receive: callable, interval=3)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Multi-relay cluster
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from paramant_sdk import GhostPipeCluster
|
|
45
|
+
|
|
46
|
+
cluster = GhostPipeCluster(
|
|
47
|
+
api_key='pk_live_...',
|
|
48
|
+
device='device-001',
|
|
49
|
+
relays=['https://health.paramant.app', 'https://relay.paramant.app'],
|
|
50
|
+
)
|
|
51
|
+
hash_ = cluster.send(data)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Protocol
|
|
55
|
+
|
|
56
|
+
- **Encryption**: ML-KEM-768 + ECDH P-256 + AES-256-GCM (hybrid PQC)
|
|
57
|
+
- **Burn-on-read**: blob deleted after first `receive()`
|
|
58
|
+
- **Sectors**: EU/DE Hetzner + Fly.io anycast
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
BUSL-1.1 — [paramant.app](https://paramant.app)
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PARAMANT Ghost Pipe SDK v1.0.0
|
|
3
|
+
Python SDK voor quantum-safe datatransport
|
|
4
|
+
|
|
5
|
+
pip install cryptography
|
|
6
|
+
|
|
7
|
+
Gebruik:
|
|
8
|
+
from paramant import GhostPipe
|
|
9
|
+
|
|
10
|
+
# Zender
|
|
11
|
+
gp = GhostPipe(api_key='pgp_xxx', device='mri-001')
|
|
12
|
+
hash = gp.send(open('scan.dcm','rb').read())
|
|
13
|
+
print(f'Hash voor ontvanger: {hash}')
|
|
14
|
+
|
|
15
|
+
# Ontvanger
|
|
16
|
+
gp = GhostPipe(api_key='pgp_xxx', device='mri-001')
|
|
17
|
+
gp.listen(on_receive=lambda data, meta: save(data))
|
|
18
|
+
"""
|
|
19
|
+
import base64, hashlib, json, os, struct, time
|
|
20
|
+
import urllib.request, urllib.error
|
|
21
|
+
from typing import Callable, Optional
|
|
22
|
+
|
|
23
|
+
__version__ = '1.0.0'
|
|
24
|
+
|
|
25
|
+
SECTOR_RELAYS = {
|
|
26
|
+
'health': 'https://health.paramant.app',
|
|
27
|
+
'iot': 'https://iot.paramant.app',
|
|
28
|
+
'legal': 'https://legal.paramant.app',
|
|
29
|
+
'finance': 'https://finance.paramant.app',
|
|
30
|
+
'relay': 'https://relay.paramant.app',
|
|
31
|
+
}
|
|
32
|
+
BLOCK = 5 * 1024 * 1024
|
|
33
|
+
UA = f'paramant-sdk/{__version__}'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GhostPipeError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GhostPipe:
|
|
41
|
+
"""
|
|
42
|
+
PARAMANT Ghost Pipe client.
|
|
43
|
+
|
|
44
|
+
Quantum-safe end-to-end encrypted datatransport.
|
|
45
|
+
Relay ziet NOOIT plaintext. Burn-on-read na ophalen.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: pgp_... API key van paramant.app/dashboard
|
|
49
|
+
device: Uniek apparaat-ID (zender en ontvanger gebruiken hetzelfde)
|
|
50
|
+
relay: Relay URL (automatisch gedetecteerd op basis van key)
|
|
51
|
+
secret: Extra geheim voor encryptie (optioneel, default=api_key)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, api_key: str, device: str, relay: str = '', secret: str = ''):
|
|
55
|
+
if not api_key.startswith('pgp_'):
|
|
56
|
+
raise GhostPipeError('API key moet beginnen met pgp_')
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.device = device
|
|
59
|
+
self.secret = secret or api_key
|
|
60
|
+
self.relay = relay or self._detect_relay()
|
|
61
|
+
if not self.relay:
|
|
62
|
+
raise GhostPipeError('Geen relay bereikbaar. Controleer API key.')
|
|
63
|
+
self._keypair = None
|
|
64
|
+
|
|
65
|
+
# ── Relay detectie ────────────────────────────────────────────────────────
|
|
66
|
+
def _detect_relay(self) -> Optional[str]:
|
|
67
|
+
for relay in SECTOR_RELAYS.values():
|
|
68
|
+
try:
|
|
69
|
+
r = urllib.request.urlopen(
|
|
70
|
+
urllib.request.Request(f'{relay}/v2/check-key?k={self.api_key}',
|
|
71
|
+
headers={'User-Agent': UA}), timeout=4)
|
|
72
|
+
if json.loads(r.read()).get('valid'):
|
|
73
|
+
return relay
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# ── HTTP helpers ──────────────────────────────────────────────────────────
|
|
79
|
+
def _get(self, path: str, params: dict = None):
|
|
80
|
+
import urllib.parse
|
|
81
|
+
url = self.relay + path
|
|
82
|
+
if params: url += '?' + urllib.parse.urlencode(params)
|
|
83
|
+
req = urllib.request.Request(url, headers={'User-Agent': UA, 'X-Api-Key': self.api_key})
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
86
|
+
return r.status, r.read()
|
|
87
|
+
except urllib.error.HTTPError as e:
|
|
88
|
+
return e.code, e.read()
|
|
89
|
+
|
|
90
|
+
def _post(self, path: str, body: bytes, content_type: str = 'application/json'):
|
|
91
|
+
req = urllib.request.Request(
|
|
92
|
+
self.relay + path, data=body, method='POST',
|
|
93
|
+
headers={'Content-Type': content_type, 'X-Api-Key': self.api_key, 'User-Agent': UA})
|
|
94
|
+
try:
|
|
95
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
96
|
+
return r.status, r.read()
|
|
97
|
+
except urllib.error.HTTPError as e:
|
|
98
|
+
return e.code, e.read()
|
|
99
|
+
|
|
100
|
+
# ── Crypto ────────────────────────────────────────────────────────────────
|
|
101
|
+
def _get_crypto(self):
|
|
102
|
+
try:
|
|
103
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
104
|
+
from cryptography.hazmat.primitives import hashes
|
|
105
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
106
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
107
|
+
generate_private_key, ECDH, SECP256R1)
|
|
108
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
109
|
+
Encoding, PublicFormat, PrivateFormat, NoEncryption,
|
|
110
|
+
load_der_public_key, load_der_private_key)
|
|
111
|
+
from cryptography.hazmat.backends import default_backend
|
|
112
|
+
return dict(HKDF=HKDF, hsh=hashes, AES=AESGCM, gen=generate_private_key,
|
|
113
|
+
ECDH=ECDH, curve=SECP256R1, Enc=Encoding, Pub=PublicFormat,
|
|
114
|
+
Priv=PrivateFormat, NoEnc=NoEncryption,
|
|
115
|
+
lpub=load_der_public_key, lpriv=load_der_private_key,
|
|
116
|
+
be=default_backend)
|
|
117
|
+
except ImportError:
|
|
118
|
+
raise GhostPipeError('pip install cryptography')
|
|
119
|
+
|
|
120
|
+
def _try_kyber(self):
|
|
121
|
+
try:
|
|
122
|
+
from kyber import Kyber768
|
|
123
|
+
return Kyber768
|
|
124
|
+
except ImportError:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _load_keypair(self):
|
|
128
|
+
"""Laad of genereer keypair voor dit device."""
|
|
129
|
+
if self._keypair:
|
|
130
|
+
return self._keypair
|
|
131
|
+
state_dir = os.path.expanduser('~/.paramant')
|
|
132
|
+
path = os.path.join(state_dir, self.device.replace('/','_') + '.keypair.json')
|
|
133
|
+
if os.path.exists(path):
|
|
134
|
+
self._keypair = json.load(open(path))
|
|
135
|
+
return self._keypair
|
|
136
|
+
c = self._get_crypto(); K = self._try_kyber(); be = c['be']()
|
|
137
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
138
|
+
priv = c['gen'](c['curve'](), be)
|
|
139
|
+
pub = priv.public_key()
|
|
140
|
+
pd = priv.private_bytes(c['Enc'].DER, c['Priv'].PKCS8, c['NoEnc']())
|
|
141
|
+
pubd = pub.public_bytes(c['Enc'].DER, c['Pub'].SubjectPublicKeyInfo)
|
|
142
|
+
kpub = b''; kpriv = b''
|
|
143
|
+
if K:
|
|
144
|
+
kpub, kpriv = K.keygen()
|
|
145
|
+
kp = {'device': self.device, 'ecdh_priv': pd.hex(), 'ecdh_pub': pubd.hex(),
|
|
146
|
+
'kyber_pub': kpub.hex() if kpub else '', 'kyber_priv': kpriv.hex() if kpriv else ''}
|
|
147
|
+
with open(path, 'w') as f: json.dump(kp, f)
|
|
148
|
+
os.chmod(path, 0o600)
|
|
149
|
+
self._keypair = kp
|
|
150
|
+
return kp
|
|
151
|
+
|
|
152
|
+
def _register_pubkeys(self):
|
|
153
|
+
"""Registreer pubkeys bij relay."""
|
|
154
|
+
kp = self._load_keypair()
|
|
155
|
+
body = json.dumps({'device_id': self.device, 'ecdh_pub': kp['ecdh_pub'],
|
|
156
|
+
'kyber_pub': kp.get('kyber_pub', '')}).encode()
|
|
157
|
+
status, resp = self._post('/v2/pubkey', body)
|
|
158
|
+
if status != 200:
|
|
159
|
+
raise GhostPipeError(f'Pubkey registratie mislukt: {resp.decode()[:100]}')
|
|
160
|
+
|
|
161
|
+
def _fetch_receiver_pubkeys(self):
|
|
162
|
+
"""Haal pubkeys op van relay (voor encryptie)."""
|
|
163
|
+
status, body = self._get(f'/v2/pubkey/{self.device}')
|
|
164
|
+
if status == 404:
|
|
165
|
+
raise GhostPipeError('Geen pubkeys voor dit device. Start ontvanger eerst met receive_setup().')
|
|
166
|
+
if status != 200:
|
|
167
|
+
raise GhostPipeError(f'Pubkeys ophalen mislukt: HTTP {status}')
|
|
168
|
+
d = json.loads(body)
|
|
169
|
+
c = self._get_crypto(); be = c['be']()
|
|
170
|
+
ecdh_pub = c['lpub'](bytes.fromhex(d['ecdh_pub']), be)
|
|
171
|
+
kyber_pub = bytes.fromhex(d['kyber_pub']) if d.get('kyber_pub') else None
|
|
172
|
+
return ecdh_pub, kyber_pub
|
|
173
|
+
|
|
174
|
+
def _encrypt(self, data: bytes, ecdh_pub, kyber_pub) -> tuple:
|
|
175
|
+
"""ML-KEM-768 + ECDH + AES-256-GCM + 5MB padding."""
|
|
176
|
+
c = self._get_crypto(); K = self._try_kyber(); be = c['be']()
|
|
177
|
+
# ECDH
|
|
178
|
+
eph = c['gen'](c['curve'](), be)
|
|
179
|
+
ecdh_ss = eph.exchange(c['ECDH'](), ecdh_pub)
|
|
180
|
+
eph_b = eph.public_key().public_bytes(c['Enc'].DER, c['Pub'].SubjectPublicKeyInfo)
|
|
181
|
+
# ML-KEM
|
|
182
|
+
kct = b''; kss = b''
|
|
183
|
+
if K and kyber_pub:
|
|
184
|
+
try: kct, kss = K.enc(kyber_pub)
|
|
185
|
+
except Exception: pass
|
|
186
|
+
# HKDF
|
|
187
|
+
ikm = ecdh_ss + kss
|
|
188
|
+
ss = c['HKDF'](algorithm=c['hsh'].SHA256(), length=32,
|
|
189
|
+
salt=b'paramant-gp-v1', info=b'aes-key', backend=be).derive(ikm)
|
|
190
|
+
# AES-256-GCM
|
|
191
|
+
nonce = os.urandom(12)
|
|
192
|
+
ct = c['AES'](ss).encrypt(nonce, data, None)
|
|
193
|
+
bundle = struct.pack('>I', len(eph_b)) + eph_b + struct.pack('>I', len(kct)) + kct
|
|
194
|
+
packet = struct.pack('>I', len(bundle)) + bundle + nonce + struct.pack('>I', len(ct)) + ct
|
|
195
|
+
if len(packet) > BLOCK:
|
|
196
|
+
raise GhostPipeError(f'Data te groot: {len(data)} bytes (max ~4.9MB per blok)')
|
|
197
|
+
blob = packet + os.urandom(BLOCK - len(packet))
|
|
198
|
+
return blob, hashlib.sha256(blob).hexdigest(), bool(kct)
|
|
199
|
+
|
|
200
|
+
def _decrypt(self, blob: bytes) -> bytes:
|
|
201
|
+
"""Ontsleutel Ghost Pipe blob."""
|
|
202
|
+
c = self._get_crypto(); K = self._try_kyber(); be = c['be']()
|
|
203
|
+
kp = self._load_keypair()
|
|
204
|
+
o = 0
|
|
205
|
+
blen = struct.unpack('>I', blob[o:o+4])[0]; o += 4
|
|
206
|
+
bun = blob[o:o+blen]; o += blen
|
|
207
|
+
bo = 0
|
|
208
|
+
eplen = struct.unpack('>I', bun[bo:bo+4])[0]; bo += 4
|
|
209
|
+
epb = bun[bo:bo+eplen]; bo += eplen
|
|
210
|
+
klen = struct.unpack('>I', bun[bo:bo+4])[0]; bo += 4
|
|
211
|
+
kct = bun[bo:bo+klen]
|
|
212
|
+
nonce = blob[o:o+12]; o += 12
|
|
213
|
+
ctlen = struct.unpack('>I', blob[o:o+4])[0]; o += 4
|
|
214
|
+
ct = blob[o:o+ctlen]
|
|
215
|
+
priv = c['lpriv'](bytes.fromhex(kp['ecdh_priv']), None, be)
|
|
216
|
+
ecdh_ss = priv.exchange(c['ECDH'](), c['lpub'](epb, be))
|
|
217
|
+
kss = b''
|
|
218
|
+
if K and kct and kp.get('kyber_priv'):
|
|
219
|
+
try: kss = K.dec(bytes.fromhex(kp['kyber_priv']), kct)
|
|
220
|
+
except Exception: pass
|
|
221
|
+
ikm = ecdh_ss + kss
|
|
222
|
+
ss = c['HKDF'](algorithm=c['hsh'].SHA256(), length=32,
|
|
223
|
+
salt=b'paramant-gp-v1', info=b'aes-key', backend=be).derive(ikm)
|
|
224
|
+
return c['AES'](ss).decrypt(nonce, ct, None)
|
|
225
|
+
|
|
226
|
+
# ── Publieke API ──────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def send(self, data: bytes, ttl: int = 300) -> str:
|
|
229
|
+
"""
|
|
230
|
+
Verstuur data via Ghost Pipe.
|
|
231
|
+
|
|
232
|
+
Data wordt versleuteld met ML-KEM-768 + ECDH + AES-256-GCM,
|
|
233
|
+
gepad naar exact 5MB en geüpload naar de relay.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
data: Bytes om te versturen (max ~4.9MB)
|
|
237
|
+
ttl: Seconden beschikbaar op relay (default 300)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
hash: SHA-256 hash — geef dit aan de ontvanger
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
GhostPipeError: Bij versleuteling of upload fouten
|
|
244
|
+
"""
|
|
245
|
+
ecdh_pub, kyber_pub = self._fetch_receiver_pubkeys()
|
|
246
|
+
blob, h, used_kyber = self._encrypt(data, ecdh_pub, kyber_pub)
|
|
247
|
+
body = json.dumps({
|
|
248
|
+
'hash': h,
|
|
249
|
+
'payload': base64.b64encode(blob).decode(),
|
|
250
|
+
'ttl_ms': ttl * 1000,
|
|
251
|
+
'meta': {'device_id': self.device},
|
|
252
|
+
}).encode()
|
|
253
|
+
status, resp = self._post('/v2/inbound', body)
|
|
254
|
+
if status != 200:
|
|
255
|
+
raise GhostPipeError(f'Upload mislukt: HTTP {status}: {resp.decode()[:100]}')
|
|
256
|
+
alg = 'ML-KEM-768+ECDH+AES-GCM' if used_kyber else 'ECDH+AES-GCM'
|
|
257
|
+
return h
|
|
258
|
+
|
|
259
|
+
def receive(self, hash_: str) -> bytes:
|
|
260
|
+
"""
|
|
261
|
+
Ontvang data van relay via hash.
|
|
262
|
+
Relay vernietigt het blok direct na dit verzoek (burn-on-read).
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
hash_: SHA-256 hash van het blok
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Ontsleutelde data
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
GhostPipeError: Blok niet gevonden, verlopen of al opgehaald
|
|
272
|
+
"""
|
|
273
|
+
status, raw = self._get(f'/v2/outbound/{hash_}')
|
|
274
|
+
if status == 404:
|
|
275
|
+
raise GhostPipeError('Blok niet gevonden. Verlopen, al opgehaald, of nooit opgeslagen.')
|
|
276
|
+
if status != 200:
|
|
277
|
+
raise GhostPipeError(f'Download mislukt: HTTP {status}')
|
|
278
|
+
blob = base64.b64decode(raw) if len(raw) < BLOCK * 2 else raw
|
|
279
|
+
return self._decrypt(blob)
|
|
280
|
+
|
|
281
|
+
def status(self, hash_: str) -> dict:
|
|
282
|
+
"""
|
|
283
|
+
Check of een blok beschikbaar is op de relay.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
{'available': bool, 'ttl_remaining_ms': int, 'bytes': int}
|
|
287
|
+
"""
|
|
288
|
+
_, body = self._get(f'/v2/status/{hash_}')
|
|
289
|
+
return json.loads(body)
|
|
290
|
+
|
|
291
|
+
def receive_setup(self):
|
|
292
|
+
"""
|
|
293
|
+
Initialiseer ontvanger: genereer keypair en registreer pubkeys bij relay.
|
|
294
|
+
Roep dit aan voordat de zender kan versturen.
|
|
295
|
+
"""
|
|
296
|
+
self._load_keypair()
|
|
297
|
+
self._register_pubkeys()
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
def register_webhook(self, callback_url: str, secret: str = ''):
|
|
301
|
+
"""
|
|
302
|
+
Registreer webhook voor push notificaties.
|
|
303
|
+
Relay POSTt naar callback_url zodra een blok klaarstaat.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
callback_url: URL die de relay aanroept bij nieuw blok
|
|
307
|
+
secret: Optioneel HMAC-SHA256 secret voor verificatie
|
|
308
|
+
"""
|
|
309
|
+
body = json.dumps({'device_id': self.device, 'url': callback_url, 'secret': secret}).encode()
|
|
310
|
+
status, resp = self._post('/v2/webhook', body)
|
|
311
|
+
if status != 200:
|
|
312
|
+
raise GhostPipeError(f'Webhook registratie mislukt: {resp.decode()[:100]}')
|
|
313
|
+
|
|
314
|
+
def listen(self, on_receive: Callable, interval: int = 3):
|
|
315
|
+
"""
|
|
316
|
+
Luister continu op nieuwe blokken (polling).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
on_receive: callback(data: bytes, meta: dict) — aangeroepen bij elk blok
|
|
320
|
+
interval: Poll interval in seconden
|
|
321
|
+
"""
|
|
322
|
+
self.receive_setup()
|
|
323
|
+
seq = self._load_seq()
|
|
324
|
+
while True:
|
|
325
|
+
try:
|
|
326
|
+
_, body = self._get('/v2/stream-next', {'device': self.device, 'seq': seq})
|
|
327
|
+
d = json.loads(body)
|
|
328
|
+
if d.get('available'):
|
|
329
|
+
next_seq = d.get('seq', seq + 1)
|
|
330
|
+
try:
|
|
331
|
+
data = self.receive(d['hash'])
|
|
332
|
+
seq = next_seq
|
|
333
|
+
self._save_seq(seq)
|
|
334
|
+
on_receive(data, {'seq': seq, 'hash': d['hash']})
|
|
335
|
+
continue
|
|
336
|
+
except GhostPipeError:
|
|
337
|
+
seq = next_seq
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
time.sleep(interval)
|
|
341
|
+
|
|
342
|
+
def audit(self, limit: int = 100) -> list:
|
|
343
|
+
"""Haal audit log op voor deze API key."""
|
|
344
|
+
_, body = self._get('/v2/audit', {'limit': limit})
|
|
345
|
+
return json.loads(body).get('entries', [])
|
|
346
|
+
|
|
347
|
+
def health(self) -> dict:
|
|
348
|
+
"""Relay health check."""
|
|
349
|
+
_, body = self._get('/health')
|
|
350
|
+
return json.loads(body)
|
|
351
|
+
|
|
352
|
+
def _load_seq(self) -> int:
|
|
353
|
+
try:
|
|
354
|
+
p = os.path.join(os.path.expanduser('~/.paramant'), self.device.replace('/','_') + '.sdk_seq')
|
|
355
|
+
return int(open(p).read())
|
|
356
|
+
except Exception:
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
def _save_seq(self, seq: int):
|
|
360
|
+
d = os.path.expanduser('~/.paramant')
|
|
361
|
+
os.makedirs(d, exist_ok=True)
|
|
362
|
+
p = os.path.join(d, self.device.replace('/','_') + '.sdk_seq')
|
|
363
|
+
open(p + '.tmp', 'w').write(str(seq))
|
|
364
|
+
os.replace(p + '.tmp', p)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ── Gebruik als script ────────────────────────────────────────────────────────
|
|
368
|
+
if __name__ == '__main__':
|
|
369
|
+
import sys, argparse
|
|
370
|
+
|
|
371
|
+
p = argparse.ArgumentParser(description=f'PARAMANT Ghost Pipe SDK v{__version__}')
|
|
372
|
+
p.add_argument('action', choices=['send','receive','status','listen','health','audit'])
|
|
373
|
+
p.add_argument('--key', required=True)
|
|
374
|
+
p.add_argument('--device', required=True)
|
|
375
|
+
p.add_argument('--relay', default='')
|
|
376
|
+
p.add_argument('--hash', default='')
|
|
377
|
+
p.add_argument('--file', default='')
|
|
378
|
+
p.add_argument('--ttl', type=int, default=300)
|
|
379
|
+
p.add_argument('--output', default='')
|
|
380
|
+
p.add_argument('--webhook',default='')
|
|
381
|
+
a = p.parse_args()
|
|
382
|
+
|
|
383
|
+
gp = GhostPipe(a.key, a.device, relay=a.relay)
|
|
384
|
+
|
|
385
|
+
if a.action == 'send':
|
|
386
|
+
data = open(a.file, 'rb').read() if a.file else sys.stdin.buffer.read()
|
|
387
|
+
gp.receive_setup()
|
|
388
|
+
h = gp.send(data, ttl=a.ttl)
|
|
389
|
+
print(f'OK hash={h}')
|
|
390
|
+
|
|
391
|
+
elif a.action == 'receive':
|
|
392
|
+
if not a.hash: sys.exit('--hash vereist')
|
|
393
|
+
gp.receive_setup()
|
|
394
|
+
data = gp.receive(a.hash)
|
|
395
|
+
if a.output:
|
|
396
|
+
with open(a.output, 'wb') as f: f.write(data)
|
|
397
|
+
print(f'OK opgeslagen in {a.output} ({len(data)} bytes)')
|
|
398
|
+
else:
|
|
399
|
+
sys.stdout.buffer.write(data)
|
|
400
|
+
|
|
401
|
+
elif a.action == 'status':
|
|
402
|
+
if not a.hash: sys.exit('--hash vereist')
|
|
403
|
+
print(json.dumps(gp.status(a.hash), indent=2))
|
|
404
|
+
|
|
405
|
+
elif a.action == 'listen':
|
|
406
|
+
def on_receive(data, meta):
|
|
407
|
+
if a.output:
|
|
408
|
+
path = os.path.join(a.output, f'block_{meta["seq"]:06d}.bin')
|
|
409
|
+
os.makedirs(a.output, exist_ok=True)
|
|
410
|
+
open(path, 'wb').write(data)
|
|
411
|
+
print(f'[RECV] seq={meta["seq"]} {len(data)}B → {path}')
|
|
412
|
+
else:
|
|
413
|
+
print(f'[RECV] seq={meta["seq"]} {len(data)}B')
|
|
414
|
+
if a.webhook:
|
|
415
|
+
gp.receive_setup()
|
|
416
|
+
gp.register_webhook(a.webhook)
|
|
417
|
+
print(f'Webhook geregistreerd: {a.webhook}')
|
|
418
|
+
gp.listen(on_receive)
|
|
419
|
+
|
|
420
|
+
elif a.action == 'health':
|
|
421
|
+
print(json.dumps(gp.health(), indent=2))
|
|
422
|
+
|
|
423
|
+
elif a.action == 'audit':
|
|
424
|
+
for e in gp.audit():
|
|
425
|
+
print(f"{e['ts']} {e['event']:<20} {e.get('hash',''):<20} {e.get('bytes',0)}B")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── Multi-relay failover (gossip light) ───────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
class GhostPipeCluster:
|
|
431
|
+
"""
|
|
432
|
+
Multi-relay client met automatische failover.
|
|
433
|
+
SDK vindt automatisch de dichtstbijzijnde gezonde node.
|
|
434
|
+
|
|
435
|
+
Equivalent van "gossip protocol light" — SDK pollt health
|
|
436
|
+
en schakelt automatisch over bij uitval.
|
|
437
|
+
|
|
438
|
+
Gebruik:
|
|
439
|
+
cluster = GhostPipeCluster(
|
|
440
|
+
api_key='pgp_xxx',
|
|
441
|
+
device='mri-001',
|
|
442
|
+
relays=[
|
|
443
|
+
'https://health.paramant.app',
|
|
444
|
+
'https://health-fra.paramant.app', # Frankfurt backup
|
|
445
|
+
'https://health-sin.paramant.app', # Singapore backup
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
hash = cluster.send(data)
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
def __init__(self, api_key: str, device: str, relays: list,
|
|
452
|
+
health_interval: int = 30):
|
|
453
|
+
self.api_key = api_key
|
|
454
|
+
self.device = device
|
|
455
|
+
self.relays = relays
|
|
456
|
+
self._healthy = {} # relay → last_health
|
|
457
|
+
self._active = None
|
|
458
|
+
self._lock = __import__('threading').Lock()
|
|
459
|
+
# Start health monitor
|
|
460
|
+
import threading
|
|
461
|
+
t = threading.Thread(target=self._monitor, daemon=True)
|
|
462
|
+
t.start()
|
|
463
|
+
# Wacht op eerste health check
|
|
464
|
+
time.sleep(2)
|
|
465
|
+
|
|
466
|
+
def _check_health(self, relay: str) -> dict:
|
|
467
|
+
try:
|
|
468
|
+
r = urllib.request.urlopen(
|
|
469
|
+
urllib.request.Request(f'{relay}/health', headers={'User-Agent': UA}),
|
|
470
|
+
timeout=5)
|
|
471
|
+
d = json.loads(r.read())
|
|
472
|
+
if d.get('ok'):
|
|
473
|
+
return {'ok': True, 'relay': relay, 'blobs': d.get('blobs', 0),
|
|
474
|
+
'version': d.get('version'), 'latency_ms': 0}
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
return {'ok': False, 'relay': relay}
|
|
478
|
+
|
|
479
|
+
def _monitor(self):
|
|
480
|
+
"""Achtergrond health monitor — pollt alle relays."""
|
|
481
|
+
import time as t
|
|
482
|
+
while True:
|
|
483
|
+
for relay in self.relays:
|
|
484
|
+
health = self._check_health(relay)
|
|
485
|
+
with self._lock:
|
|
486
|
+
self._healthy[relay] = health
|
|
487
|
+
# Selecteer beste (eerste gezonde)
|
|
488
|
+
for relay in self.relays:
|
|
489
|
+
if self._healthy.get(relay, {}).get('ok'):
|
|
490
|
+
with self._lock:
|
|
491
|
+
if self._active != relay:
|
|
492
|
+
self._active = relay
|
|
493
|
+
break
|
|
494
|
+
t.sleep(30)
|
|
495
|
+
|
|
496
|
+
def _get_client(self) -> 'GhostPipe':
|
|
497
|
+
"""Geef GhostPipe client voor actieve relay."""
|
|
498
|
+
with self._lock:
|
|
499
|
+
relay = self._active
|
|
500
|
+
if not relay:
|
|
501
|
+
raise GhostPipeError('Geen gezonde relay beschikbaar')
|
|
502
|
+
return GhostPipe(self.api_key, self.device, relay=relay)
|
|
503
|
+
|
|
504
|
+
def send(self, data: bytes, ttl: int = 300) -> str:
|
|
505
|
+
"""Verstuur via eerste gezonde relay. Automatische failover."""
|
|
506
|
+
errors = []
|
|
507
|
+
for relay in self.relays:
|
|
508
|
+
if not self._healthy.get(relay, {}).get('ok'):
|
|
509
|
+
continue
|
|
510
|
+
try:
|
|
511
|
+
gp = GhostPipe(self.api_key, self.device, relay=relay)
|
|
512
|
+
return gp.send(data, ttl=ttl)
|
|
513
|
+
except GhostPipeError as e:
|
|
514
|
+
errors.append(f'{relay}: {e}')
|
|
515
|
+
# Markeer als ongezond
|
|
516
|
+
with self._lock:
|
|
517
|
+
self._healthy[relay] = {'ok': False}
|
|
518
|
+
raise GhostPipeError(f'Alle relays mislukt: {errors}')
|
|
519
|
+
|
|
520
|
+
def receive(self, hash_: str) -> bytes:
|
|
521
|
+
"""Ontvang van eerste relay die het blok heeft."""
|
|
522
|
+
for relay in self.relays:
|
|
523
|
+
try:
|
|
524
|
+
gp = GhostPipe(self.api_key, self.device, relay=relay)
|
|
525
|
+
status = gp.status(hash_)
|
|
526
|
+
if status.get('available'):
|
|
527
|
+
return gp.receive(hash_)
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
raise GhostPipeError('Blok niet gevonden op een van de relays')
|
|
531
|
+
|
|
532
|
+
def health(self) -> dict:
|
|
533
|
+
"""Status van alle nodes in de cluster."""
|
|
534
|
+
with self._lock:
|
|
535
|
+
return {'active': self._active, 'nodes': dict(self._healthy)}
|
|
536
|
+
|
|
537
|
+
def receive_setup(self):
|
|
538
|
+
"""Setup op alle gezonde relays."""
|
|
539
|
+
for relay in self.relays:
|
|
540
|
+
if self._healthy.get(relay, {}).get('ok'):
|
|
541
|
+
try:
|
|
542
|
+
GhostPipe(self.api_key, self.device, relay=relay).receive_setup()
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
return self
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paramant-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: PARAMANT Ghost Pipe SDK — post-quantum burn-on-read secure transport
|
|
5
|
+
Author-email: PARAMANT <hello@paramant.app>
|
|
6
|
+
License: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://paramant.app
|
|
8
|
+
Project-URL: Documentation, https://paramant.app/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/paramant/sdk-py
|
|
10
|
+
Keywords: paramant,ghost-pipe,post-quantum,ml-kem,burn-on-read,secure-messaging,e2ee
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=42.0
|
|
23
|
+
Provides-Extra: mlkem
|
|
24
|
+
Requires-Dist: pqcrypto; extra == "mlkem"
|
|
25
|
+
|
|
26
|
+
# paramant-sdk
|
|
27
|
+
|
|
28
|
+
PARAMANT Ghost Pipe SDK for Python. Post-quantum burn-on-read secure transport.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install paramant-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from paramant_sdk import GhostPipe
|
|
40
|
+
|
|
41
|
+
# Sender
|
|
42
|
+
sender = GhostPipe(api_key='pk_live_...', device='sender-001')
|
|
43
|
+
hash_ = sender.send(b'confidential payload')
|
|
44
|
+
print('hash:', hash_)
|
|
45
|
+
|
|
46
|
+
# Receiver
|
|
47
|
+
receiver = GhostPipe(api_key='pk_live_...', device='receiver-001')
|
|
48
|
+
data = receiver.receive(hash_)
|
|
49
|
+
print('received:', data)
|
|
50
|
+
# blob is burned after receive
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
gp = GhostPipe(api_key, device, relay='', sector='health')
|
|
57
|
+
|
|
58
|
+
hash_ = gp.send(data: bytes, ttl=300) -> str
|
|
59
|
+
data = gp.receive(hash_: str) -> bytes
|
|
60
|
+
status = gp.status(hash_: str) -> dict
|
|
61
|
+
entries = gp.audit(limit=100) -> list
|
|
62
|
+
info = gp.health() -> dict
|
|
63
|
+
gp.listen(on_receive: callable, interval=3)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Multi-relay cluster
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from paramant_sdk import GhostPipeCluster
|
|
70
|
+
|
|
71
|
+
cluster = GhostPipeCluster(
|
|
72
|
+
api_key='pk_live_...',
|
|
73
|
+
device='device-001',
|
|
74
|
+
relays=['https://health.paramant.app', 'https://relay.paramant.app'],
|
|
75
|
+
)
|
|
76
|
+
hash_ = cluster.send(data)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Protocol
|
|
80
|
+
|
|
81
|
+
- **Encryption**: ML-KEM-768 + ECDH P-256 + AES-256-GCM (hybrid PQC)
|
|
82
|
+
- **Burn-on-read**: blob deleted after first `receive()`
|
|
83
|
+
- **Sectors**: EU/DE Hetzner + Fly.io anycast
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
BUSL-1.1 — [paramant.app](https://paramant.app)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paramant_sdk
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "paramant-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "PARAMANT Ghost Pipe SDK — post-quantum burn-on-read secure transport"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "BUSL-1.1" }
|
|
11
|
+
authors = [{ name = "PARAMANT", email = "hello@paramant.app" }]
|
|
12
|
+
keywords = ["paramant", "ghost-pipe", "post-quantum", "ml-kem", "burn-on-read", "secure-messaging", "e2ee"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
"Topic :: Communications",
|
|
23
|
+
]
|
|
24
|
+
requires-python = ">=3.9"
|
|
25
|
+
dependencies = [
|
|
26
|
+
"cryptography>=42.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
mlkem = ["pqcrypto"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://paramant.app"
|
|
34
|
+
Documentation = "https://paramant.app/docs"
|
|
35
|
+
Repository = "https://github.com/paramant/sdk-py"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["."]
|
|
39
|
+
include = ["paramant_sdk*"]
|