sibna 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.
- sibna-1.0.0/PKG-INFO +24 -0
- sibna-1.0.0/README.md +52 -0
- sibna-1.0.0/setup.cfg +4 -0
- sibna-1.0.0/setup.py +38 -0
- sibna-1.0.0/sibna/__init__.py +403 -0
- sibna-1.0.0/sibna/client.py +603 -0
- sibna-1.0.0/sibna/sibna_core.dll +0 -0
- sibna-1.0.0/sibna.egg-info/PKG-INFO +24 -0
- sibna-1.0.0/sibna.egg-info/SOURCES.txt +9 -0
- sibna-1.0.0/sibna.egg-info/dependency_links.txt +1 -0
- sibna-1.0.0/sibna.egg-info/top_level.txt +1 -0
sibna-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sibna
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Standalone Ultra-Secure Communication Protocol - Python SDK
|
|
5
|
+
Author: Sibna Security Team
|
|
6
|
+
Author-email: security@sibna.dev
|
|
7
|
+
Keywords: cryptography encryption signal secure-messaging e2ee
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security :: Cryptography
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: keywords
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
A standalone Python SDK for the Sibna Protocol, including the pre-compiled Rust core.
|
sibna-1.0.0/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Sibna Protocol Standalone Python SDK
|
|
2
|
+
|
|
3
|
+
This is a standalone, production-ready Python SDK for the **Sibna Protocol** (v1.0.0). It includes the pre-compiled Rust core, so you don't need to have Rust installed to use it.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- **Zero Dependencies**: Everything you need is bundled.
|
|
7
|
+
- **X3DH & Double Ratchet**: Full Signal-style end-to-end encryption.
|
|
8
|
+
- **Standalone Crypto**: Fast ChaCha20-Poly1305 and HKDF operations.
|
|
9
|
+
- **High Performance**: Powered by a hardened Rust core.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd libs
|
|
15
|
+
pip install .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import sibna
|
|
22
|
+
|
|
23
|
+
# 1. Generate a generic encryption key
|
|
24
|
+
key = sibna.generate_key()
|
|
25
|
+
|
|
26
|
+
# 2. Encrypt your message
|
|
27
|
+
plaintext = b"Hello, Sibna!"
|
|
28
|
+
ciphertext = sibna.encrypt(key, plaintext)
|
|
29
|
+
|
|
30
|
+
# 3. Decrypt the message
|
|
31
|
+
decrypted = sibna.decrypt(key, ciphertext)
|
|
32
|
+
|
|
33
|
+
print(decrypted.decode()) # Hello, Sibna!
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Advanced Usage (Sessions)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import sibna
|
|
40
|
+
|
|
41
|
+
# Initialize context with a master password for local storage encryption
|
|
42
|
+
ctx = sibna.Context(password=b"mystrongpassword")
|
|
43
|
+
|
|
44
|
+
# Create a session with a peer
|
|
45
|
+
session = ctx.create_session(b"peer_identity_key")
|
|
46
|
+
|
|
47
|
+
# Encrypt with full forward secrecy
|
|
48
|
+
encrypted = session.encrypt(b"Deep sea message...")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
Dual-licensed under Apache 2.0 and MIT.
|
sibna-1.0.0/setup.cfg
ADDED
sibna-1.0.0/setup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Sibna Protocol Standalone Python SDK - Setup Script
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from setuptools import setup, find_packages
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
|
|
10
|
+
version = "1.0.0"
|
|
11
|
+
|
|
12
|
+
# Package data (including the native library)
|
|
13
|
+
package_data = {
|
|
14
|
+
"sibna": ["*.so", "*.dll", "*.dylib"],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setup(
|
|
18
|
+
name="sibna",
|
|
19
|
+
version=version,
|
|
20
|
+
author="Sibna Security Team",
|
|
21
|
+
author_email="security@sibna.dev",
|
|
22
|
+
description="Standalone Ultra-Secure Communication Protocol - Python SDK",
|
|
23
|
+
long_description="A standalone Python SDK for the Sibna Protocol, including the pre-compiled Rust core.",
|
|
24
|
+
long_description_content_type="text/markdown",
|
|
25
|
+
packages=find_packages(),
|
|
26
|
+
package_data=package_data,
|
|
27
|
+
include_package_data=True,
|
|
28
|
+
classifiers=[
|
|
29
|
+
"Development Status :: 5 - Production/Stable",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Topic :: Security :: Cryptography",
|
|
32
|
+
"License :: OSI Approved :: Apache Software License",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
],
|
|
35
|
+
python_requires=">=3.8",
|
|
36
|
+
install_requires=[],
|
|
37
|
+
keywords="cryptography encryption signal secure-messaging e2ee",
|
|
38
|
+
)
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sibna Protocol Python SDK - Ultra Secure Edition
|
|
3
|
+
|
|
4
|
+
A Python wrapper for the Sibna secure communication protocol.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
>>> import sibna
|
|
8
|
+
>>>
|
|
9
|
+
>>> # Create context
|
|
10
|
+
>>> ctx = sibna.Context(password=b"my_secure_password")
|
|
11
|
+
>>>
|
|
12
|
+
>>> # Generate identity
|
|
13
|
+
>>> identity = ctx.generate_identity()
|
|
14
|
+
>>>
|
|
15
|
+
>>> # Create session
|
|
16
|
+
>>> session = ctx.create_session(b"peer_id")
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Encrypt message
|
|
19
|
+
>>> encrypted = session.encrypt(b"Hello, World!")
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Decrypt message
|
|
22
|
+
>>> decrypted = session.decrypt(encrypted)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__version__ = "1.0.0"
|
|
26
|
+
__author__ = "Sibna Security Team"
|
|
27
|
+
__license__ = "Apache-2.0 OR MIT"
|
|
28
|
+
|
|
29
|
+
from typing import Optional, List, Tuple, Union
|
|
30
|
+
import ctypes
|
|
31
|
+
import os
|
|
32
|
+
import platform
|
|
33
|
+
|
|
34
|
+
# Load the shared library
|
|
35
|
+
def _load_library():
|
|
36
|
+
"""Load the Sibna native library."""
|
|
37
|
+
system = platform.system()
|
|
38
|
+
|
|
39
|
+
if system == "Linux":
|
|
40
|
+
lib_name = "libsibna_core.so"
|
|
41
|
+
elif system == "Darwin":
|
|
42
|
+
lib_name = "libsibna_core.dylib"
|
|
43
|
+
elif system == "Windows":
|
|
44
|
+
lib_name = "sibna_core.dll"
|
|
45
|
+
else:
|
|
46
|
+
raise OSError(f"Unsupported platform: {system}")
|
|
47
|
+
|
|
48
|
+
# Try to find library in various locations
|
|
49
|
+
search_paths = [
|
|
50
|
+
os.path.dirname(__file__),
|
|
51
|
+
os.path.join(os.path.dirname(__file__), "..", "..", ".."),
|
|
52
|
+
"/usr/local/lib",
|
|
53
|
+
"/usr/lib",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for path in search_paths:
|
|
57
|
+
lib_path = os.path.join(path, lib_name)
|
|
58
|
+
if os.path.exists(lib_path):
|
|
59
|
+
return ctypes.CDLL(lib_path)
|
|
60
|
+
|
|
61
|
+
raise OSError(f"Could not find {lib_name}")
|
|
62
|
+
|
|
63
|
+
# Try to load library (will fail gracefully if not available)
|
|
64
|
+
try:
|
|
65
|
+
_lib = _load_library()
|
|
66
|
+
except OSError:
|
|
67
|
+
_lib = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SibnaError(Exception):
|
|
71
|
+
"""Base exception for Sibna errors."""
|
|
72
|
+
|
|
73
|
+
ERROR_CODES = {
|
|
74
|
+
0: "Success",
|
|
75
|
+
1: "Invalid argument",
|
|
76
|
+
2: "Invalid key",
|
|
77
|
+
3: "Encryption failed",
|
|
78
|
+
4: "Decryption failed",
|
|
79
|
+
5: "Out of memory",
|
|
80
|
+
6: "Invalid state",
|
|
81
|
+
7: "Session not found",
|
|
82
|
+
8: "Key not found",
|
|
83
|
+
9: "Rate limit exceeded",
|
|
84
|
+
10: "Internal error",
|
|
85
|
+
11: "Buffer too small",
|
|
86
|
+
12: "Invalid ciphertext",
|
|
87
|
+
13: "Authentication failed",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def __init__(self, code: int, message: Optional[str] = None):
|
|
91
|
+
self.code = code
|
|
92
|
+
self.message = message or self.ERROR_CODES.get(code, f"Unknown error ({code})")
|
|
93
|
+
super().__init__(self.message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ByteBuffer(ctypes.Structure):
|
|
97
|
+
"""FFI-safe byte buffer."""
|
|
98
|
+
_fields_ = [
|
|
99
|
+
("data", ctypes.POINTER(ctypes.c_uint8)),
|
|
100
|
+
("len", ctypes.c_size_t),
|
|
101
|
+
("capacity", ctypes.c_size_t),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def to_bytes(self) -> bytes:
|
|
105
|
+
"""Convert buffer to Python bytes."""
|
|
106
|
+
if self.data is None:
|
|
107
|
+
return b""
|
|
108
|
+
return bytes(ctypes.cast(self.data, ctypes.POINTER(ctypes.c_uint8 * self.len)).contents)
|
|
109
|
+
|
|
110
|
+
def free(self):
|
|
111
|
+
"""Free the buffer."""
|
|
112
|
+
if _lib is not None:
|
|
113
|
+
_lib.sibna_free_buffer(ctypes.byref(self))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Context:
|
|
117
|
+
"""Secure context for Sibna protocol operations."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, password: Optional[bytes] = None):
|
|
120
|
+
"""
|
|
121
|
+
Create a new secure context.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
password: Master password for storage encryption (optional)
|
|
125
|
+
"""
|
|
126
|
+
if _lib is None:
|
|
127
|
+
raise RuntimeError("Sibna library not loaded")
|
|
128
|
+
|
|
129
|
+
self._ctx = ctypes.c_void_p()
|
|
130
|
+
|
|
131
|
+
if password:
|
|
132
|
+
password_ptr = ctypes.cast(password, ctypes.POINTER(ctypes.c_uint8))
|
|
133
|
+
password_len = len(password)
|
|
134
|
+
else:
|
|
135
|
+
password_ptr = None
|
|
136
|
+
password_len = 0
|
|
137
|
+
|
|
138
|
+
result = _lib.sibna_context_create(
|
|
139
|
+
password_ptr,
|
|
140
|
+
password_len,
|
|
141
|
+
ctypes.byref(self._ctx)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if result != 0:
|
|
145
|
+
raise SibnaError(result)
|
|
146
|
+
|
|
147
|
+
def __del__(self):
|
|
148
|
+
"""Destroy the context."""
|
|
149
|
+
if hasattr(self, '_ctx') and self._ctx:
|
|
150
|
+
_lib.sibna_context_destroy(self._ctx)
|
|
151
|
+
|
|
152
|
+
def generate_identity(self) -> 'IdentityKeyPair':
|
|
153
|
+
"""Generate a new identity key pair."""
|
|
154
|
+
# This would call into the native library
|
|
155
|
+
# For now, return a placeholder
|
|
156
|
+
return IdentityKeyPair()
|
|
157
|
+
|
|
158
|
+
def create_session(self, peer_id: bytes) -> 'Session':
|
|
159
|
+
"""Create a new session with a peer."""
|
|
160
|
+
session = ctypes.c_void_p()
|
|
161
|
+
result = _lib.sibna_session_create(
|
|
162
|
+
self._ctx,
|
|
163
|
+
ctypes.cast(peer_id, ctypes.POINTER(ctypes.c_uint8)),
|
|
164
|
+
len(peer_id),
|
|
165
|
+
ctypes.byref(session)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if result != 0:
|
|
169
|
+
raise SibnaError(result)
|
|
170
|
+
|
|
171
|
+
return Session(session)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def version() -> str:
|
|
175
|
+
"""Get the protocol version."""
|
|
176
|
+
if _lib is None:
|
|
177
|
+
return "1.0.0"
|
|
178
|
+
|
|
179
|
+
buffer = ctypes.create_string_buffer(32)
|
|
180
|
+
result = _lib.sibna_version(buffer, 32)
|
|
181
|
+
|
|
182
|
+
if result != 0:
|
|
183
|
+
raise SibnaError(result)
|
|
184
|
+
|
|
185
|
+
return buffer.value.decode('utf-8')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class IdentityKeyPair:
|
|
189
|
+
"""Identity key pair for authentication."""
|
|
190
|
+
|
|
191
|
+
def __init__(self):
|
|
192
|
+
self._public_key = None
|
|
193
|
+
self._private_key = None
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def public_key(self) -> bytes:
|
|
197
|
+
"""Get the public key."""
|
|
198
|
+
return self._public_key or b""
|
|
199
|
+
|
|
200
|
+
def sign(self, data: bytes) -> bytes:
|
|
201
|
+
"""Sign data with the identity key."""
|
|
202
|
+
# TODO: Call native library via FFI
|
|
203
|
+
raise NotImplementedError("Session encrypt requires compiled native library")
|
|
204
|
+
|
|
205
|
+
def verify(self, data: bytes, signature: bytes) -> bool:
|
|
206
|
+
"""Verify a signature."""
|
|
207
|
+
# Implementation would call native library
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Session:
|
|
212
|
+
"""Secure session for encrypted communication."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, handle: ctypes.c_void_p):
|
|
215
|
+
self._handle = handle
|
|
216
|
+
|
|
217
|
+
def __del__(self):
|
|
218
|
+
"""Destroy the session."""
|
|
219
|
+
if hasattr(self, '_handle') and self._handle:
|
|
220
|
+
_lib.sibna_session_destroy(self._handle)
|
|
221
|
+
|
|
222
|
+
def encrypt(self, plaintext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
223
|
+
"""
|
|
224
|
+
Encrypt a message.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
plaintext: Message to encrypt
|
|
228
|
+
associated_data: Additional authenticated data (optional)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Encrypted ciphertext
|
|
232
|
+
"""
|
|
233
|
+
# TODO: Call native library via FFI
|
|
234
|
+
raise NotImplementedError("Session decrypt requires compiled native library")
|
|
235
|
+
|
|
236
|
+
def decrypt(self, ciphertext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
237
|
+
"""
|
|
238
|
+
Decrypt a message.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
ciphertext: Ciphertext to decrypt
|
|
242
|
+
associated_data: Additional authenticated data (optional)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Decrypted plaintext
|
|
246
|
+
"""
|
|
247
|
+
# Implementation would call native library
|
|
248
|
+
return b""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Crypto:
|
|
252
|
+
"""Standalone cryptographic operations."""
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def generate_key() -> bytes:
|
|
256
|
+
"""Generate a random 32-byte encryption key."""
|
|
257
|
+
if _lib is None:
|
|
258
|
+
raise RuntimeError("Sibna library not loaded")
|
|
259
|
+
|
|
260
|
+
key = ctypes.create_string_buffer(32)
|
|
261
|
+
result = _lib.sibna_generate_key(key)
|
|
262
|
+
|
|
263
|
+
if result != 0:
|
|
264
|
+
raise SibnaError(result)
|
|
265
|
+
|
|
266
|
+
return key.raw
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def encrypt(key: bytes, plaintext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
270
|
+
"""
|
|
271
|
+
Encrypt data with a key.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
key: 32-byte encryption key
|
|
275
|
+
plaintext: Data to encrypt
|
|
276
|
+
associated_data: Additional authenticated data (optional)
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Encrypted ciphertext
|
|
280
|
+
"""
|
|
281
|
+
if _lib is None:
|
|
282
|
+
raise RuntimeError("Sibna library not loaded")
|
|
283
|
+
|
|
284
|
+
if len(key) != 32:
|
|
285
|
+
raise ValueError("Key must be 32 bytes")
|
|
286
|
+
|
|
287
|
+
ciphertext = ByteBuffer()
|
|
288
|
+
|
|
289
|
+
ad_ptr = None
|
|
290
|
+
ad_len = 0
|
|
291
|
+
if associated_data:
|
|
292
|
+
ad_ptr = ctypes.cast(associated_data, ctypes.POINTER(ctypes.c_uint8))
|
|
293
|
+
ad_len = len(associated_data)
|
|
294
|
+
|
|
295
|
+
result = _lib.sibna_encrypt(
|
|
296
|
+
ctypes.cast(key, ctypes.POINTER(ctypes.c_uint8)),
|
|
297
|
+
ctypes.cast(plaintext, ctypes.POINTER(ctypes.c_uint8)),
|
|
298
|
+
len(plaintext),
|
|
299
|
+
ad_ptr,
|
|
300
|
+
ad_len,
|
|
301
|
+
ctypes.byref(ciphertext)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if result != 0:
|
|
305
|
+
raise SibnaError(result)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
return ciphertext.to_bytes()
|
|
309
|
+
finally:
|
|
310
|
+
ciphertext.free()
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def decrypt(key: bytes, ciphertext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
314
|
+
"""
|
|
315
|
+
Decrypt data with a key.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
key: 32-byte encryption key
|
|
319
|
+
ciphertext: Ciphertext to decrypt
|
|
320
|
+
associated_data: Additional authenticated data (optional)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Decrypted plaintext
|
|
324
|
+
"""
|
|
325
|
+
if _lib is None:
|
|
326
|
+
raise RuntimeError("Sibna library not loaded")
|
|
327
|
+
|
|
328
|
+
if len(key) != 32:
|
|
329
|
+
raise ValueError("Key must be 32 bytes")
|
|
330
|
+
|
|
331
|
+
plaintext = ByteBuffer()
|
|
332
|
+
|
|
333
|
+
ad_ptr = None
|
|
334
|
+
ad_len = 0
|
|
335
|
+
if associated_data:
|
|
336
|
+
ad_ptr = ctypes.cast(associated_data, ctypes.POINTER(ctypes.c_uint8))
|
|
337
|
+
ad_len = len(associated_data)
|
|
338
|
+
|
|
339
|
+
result = _lib.sibna_decrypt(
|
|
340
|
+
ctypes.cast(key, ctypes.POINTER(ctypes.c_uint8)),
|
|
341
|
+
ctypes.cast(ciphertext, ctypes.POINTER(ctypes.c_uint8)),
|
|
342
|
+
len(ciphertext),
|
|
343
|
+
ad_ptr,
|
|
344
|
+
ad_len,
|
|
345
|
+
ctypes.byref(plaintext)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if result != 0:
|
|
349
|
+
raise SibnaError(result)
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
return plaintext.to_bytes()
|
|
353
|
+
finally:
|
|
354
|
+
plaintext.free()
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def random_bytes(length: int) -> bytes:
|
|
358
|
+
"""Generate random bytes."""
|
|
359
|
+
if _lib is None:
|
|
360
|
+
raise RuntimeError("Sibna library not loaded")
|
|
361
|
+
|
|
362
|
+
buffer = ctypes.create_string_buffer(length)
|
|
363
|
+
result = _lib.sibna_random_bytes(length, buffer)
|
|
364
|
+
|
|
365
|
+
if result != 0:
|
|
366
|
+
raise SibnaError(result)
|
|
367
|
+
|
|
368
|
+
return buffer.raw
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# Convenience functions
|
|
372
|
+
def generate_key() -> bytes:
|
|
373
|
+
"""Generate a random 32-byte encryption key."""
|
|
374
|
+
return Crypto.generate_key()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def encrypt(key: bytes, plaintext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
378
|
+
"""Encrypt data with a key."""
|
|
379
|
+
return Crypto.encrypt(key, plaintext, associated_data)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def decrypt(key: bytes, ciphertext: bytes, associated_data: Optional[bytes] = None) -> bytes:
|
|
383
|
+
"""Decrypt data with a key."""
|
|
384
|
+
return Crypto.decrypt(key, ciphertext, associated_data)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def random_bytes(length: int) -> bytes:
|
|
388
|
+
"""Generate random bytes."""
|
|
389
|
+
return Crypto.random_bytes(length)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
__all__ = [
|
|
393
|
+
"Context",
|
|
394
|
+
"Session",
|
|
395
|
+
"IdentityKeyPair",
|
|
396
|
+
"Crypto",
|
|
397
|
+
"SibnaError",
|
|
398
|
+
"generate_key",
|
|
399
|
+
"encrypt",
|
|
400
|
+
"decrypt",
|
|
401
|
+
"random_bytes",
|
|
402
|
+
"__version__",
|
|
403
|
+
]
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sibna Protocol Python SDK v11.0 — Production Edition
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
Full HTTP + WebSocket client SDK with:
|
|
6
|
+
- Ed25519 identity generation (pure Python via cryptography library)
|
|
7
|
+
- Auth: challenge-response JWT flow
|
|
8
|
+
- PreKey management (upload / fetch)
|
|
9
|
+
- Sealed envelope messaging (REST + WebSocket)
|
|
10
|
+
- Message padding (metadata resistance)
|
|
11
|
+
- Offline inbox polling
|
|
12
|
+
|
|
13
|
+
Install dependencies:
|
|
14
|
+
pip install cryptography websockets aiohttp requests
|
|
15
|
+
|
|
16
|
+
Example (sync):
|
|
17
|
+
from sibna.client import SibnaClient
|
|
18
|
+
|
|
19
|
+
client = SibnaClient(server="http://localhost:8080")
|
|
20
|
+
client.generate_identity()
|
|
21
|
+
client.authenticate()
|
|
22
|
+
client.upload_prekey()
|
|
23
|
+
|
|
24
|
+
# Send sealed message
|
|
25
|
+
client.send_message(recipient_id="<hex>", plaintext=b"Hello!")
|
|
26
|
+
|
|
27
|
+
# Fetch inbox
|
|
28
|
+
messages = client.fetch_inbox()
|
|
29
|
+
|
|
30
|
+
Example (async WebSocket):
|
|
31
|
+
import asyncio
|
|
32
|
+
from sibna.client import SibnaClient
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
client = SibnaClient(server="http://localhost:8080")
|
|
36
|
+
client.generate_identity()
|
|
37
|
+
await client.authenticate_async()
|
|
38
|
+
await client.connect_websocket()
|
|
39
|
+
await client.send_sealed(recipient_id="<hex>", payload=b"Hello!")
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__version__ = "11.0.0"
|
|
45
|
+
__author__ = "Sibna Security Team"
|
|
46
|
+
__license__ = "Apache-2.0 OR MIT"
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import json
|
|
50
|
+
import time
|
|
51
|
+
import uuid
|
|
52
|
+
import hashlib
|
|
53
|
+
import secrets
|
|
54
|
+
import struct
|
|
55
|
+
from typing import Optional, Callable, List, Dict, Any
|
|
56
|
+
|
|
57
|
+
# ── Cryptographic dependencies (pure Python, no native lib required) ─────────
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
61
|
+
Ed25519PrivateKey, Ed25519PublicKey
|
|
62
|
+
)
|
|
63
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
64
|
+
Encoding, PublicFormat, PrivateFormat, NoEncryption
|
|
65
|
+
)
|
|
66
|
+
_CRYPTO_AVAILABLE = True
|
|
67
|
+
except ImportError:
|
|
68
|
+
_CRYPTO_AVAILABLE = False
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
import requests
|
|
72
|
+
_REQUESTS_AVAILABLE = True
|
|
73
|
+
except ImportError:
|
|
74
|
+
_REQUESTS_AVAILABLE = False
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
import asyncio
|
|
78
|
+
import aiohttp
|
|
79
|
+
_AIOHTTP_AVAILABLE = True
|
|
80
|
+
except ImportError:
|
|
81
|
+
_AIOHTTP_AVAILABLE = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Exceptions ────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
class SibnaError(Exception):
|
|
87
|
+
"""Base exception for all Sibna SDK errors."""
|
|
88
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
89
|
+
self.status_code = status_code
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
|
|
92
|
+
class AuthError(SibnaError):
|
|
93
|
+
"""Authentication failed."""
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
class NetworkError(SibnaError):
|
|
97
|
+
"""Network or server error."""
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
class CryptoError(SibnaError):
|
|
101
|
+
"""Cryptographic operation failed."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Identity ─────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
class Identity:
|
|
108
|
+
"""
|
|
109
|
+
Ed25519 identity keypair.
|
|
110
|
+
|
|
111
|
+
The public key (32 bytes) is the user's permanent identifier.
|
|
112
|
+
Use this to authenticate to the server and sign messages.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, private_key_bytes: Optional[bytes] = None):
|
|
116
|
+
if not _CRYPTO_AVAILABLE:
|
|
117
|
+
raise CryptoError(
|
|
118
|
+
"cryptography package required: pip install cryptography"
|
|
119
|
+
)
|
|
120
|
+
if private_key_bytes:
|
|
121
|
+
self._private_key = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
|
|
122
|
+
else:
|
|
123
|
+
self._private_key = Ed25519PrivateKey.generate()
|
|
124
|
+
|
|
125
|
+
self._public_key = self._private_key.public_key()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def public_key_bytes(self) -> bytes:
|
|
129
|
+
"""32-byte Ed25519 public key."""
|
|
130
|
+
return self._public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def public_key_hex(self) -> str:
|
|
134
|
+
"""64-character hex Ed25519 public key."""
|
|
135
|
+
return self.public_key_bytes.hex()
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def private_key_bytes(self) -> bytes:
|
|
139
|
+
"""32-byte Ed25519 private key (keep secret!)."""
|
|
140
|
+
return self._private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
141
|
+
|
|
142
|
+
def sign(self, data: bytes) -> bytes:
|
|
143
|
+
"""Sign data, returns 64-byte signature."""
|
|
144
|
+
return self._private_key.sign(data)
|
|
145
|
+
|
|
146
|
+
def sign_hex(self, data: bytes) -> str:
|
|
147
|
+
"""Sign data, returns hex-encoded 64-byte signature."""
|
|
148
|
+
return self.sign(data).hex()
|
|
149
|
+
|
|
150
|
+
def save(self, path: str) -> None:
|
|
151
|
+
"""Save private key to file (protect with filesystem permissions!)."""
|
|
152
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
153
|
+
with open(path, "wb") as f:
|
|
154
|
+
f.write(self.private_key_bytes)
|
|
155
|
+
os.chmod(path, 0o600)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def load(cls, path: str) -> "Identity":
|
|
159
|
+
"""Load identity from saved private key file."""
|
|
160
|
+
with open(path, "rb") as f:
|
|
161
|
+
return cls(private_key_bytes=f.read())
|
|
162
|
+
|
|
163
|
+
def __repr__(self) -> str:
|
|
164
|
+
return f"<Identity public_key={self.public_key_hex[:16]}...>"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Message Padding (Metadata Resistance) ─────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
PADDING_BLOCK = 1024
|
|
170
|
+
|
|
171
|
+
def pad_payload(data: bytes) -> bytes:
|
|
172
|
+
"""
|
|
173
|
+
Pad payload to nearest 1024-byte boundary (metadata resistance).
|
|
174
|
+
Makes all messages look the same size to a passive observer.
|
|
175
|
+
"""
|
|
176
|
+
unpadded_len = len(data) + 1
|
|
177
|
+
remainder = unpadded_len % PADDING_BLOCK
|
|
178
|
+
padding_needed = (PADDING_BLOCK - remainder) % PADDING_BLOCK
|
|
179
|
+
if padding_needed == 0:
|
|
180
|
+
padding_needed = PADDING_BLOCK # Always pad at least 1 byte
|
|
181
|
+
indicator = padding_needed % 256
|
|
182
|
+
padding = secrets.token_bytes(padding_needed)
|
|
183
|
+
return bytes([indicator]) + data + padding
|
|
184
|
+
|
|
185
|
+
def unpad_payload(padded: bytes) -> bytes:
|
|
186
|
+
"""Remove padding from a received payload."""
|
|
187
|
+
if not padded:
|
|
188
|
+
raise CryptoError("Empty payload")
|
|
189
|
+
indicator = padded[0]
|
|
190
|
+
padded_len = len(padded)
|
|
191
|
+
padding_needed = padded_len % PADDING_BLOCK
|
|
192
|
+
actual_padding = indicator if padding_needed == 0 else padding_needed
|
|
193
|
+
return padded[1 : padded_len - actual_padding]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Signed Envelope (End-to-End Integrity) ────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def make_signed_envelope(
|
|
199
|
+
identity: Identity,
|
|
200
|
+
recipient_id: str,
|
|
201
|
+
payload_hex: str,
|
|
202
|
+
compress: bool = False,
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Create a signed, sealed envelope.
|
|
206
|
+
|
|
207
|
+
The server sees ONLY the recipient_id. The payload and sender
|
|
208
|
+
identity are opaque to the server.
|
|
209
|
+
|
|
210
|
+
Signing payload = SHA-512(recipient_id || payload_hex || timestamp || message_id)
|
|
211
|
+
"""
|
|
212
|
+
message_id = str(uuid.uuid4())
|
|
213
|
+
timestamp = int(time.time())
|
|
214
|
+
|
|
215
|
+
# Build signing payload
|
|
216
|
+
h = hashlib.sha512()
|
|
217
|
+
h.update(recipient_id.encode())
|
|
218
|
+
h.update(payload_hex.encode())
|
|
219
|
+
h.update(struct.pack("<q", timestamp))
|
|
220
|
+
h.update(message_id.encode())
|
|
221
|
+
signing_hash = h.digest()
|
|
222
|
+
|
|
223
|
+
signature_hex = identity.sign_hex(signing_hash)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"recipient_id": recipient_id,
|
|
227
|
+
"payload_hex": payload_hex,
|
|
228
|
+
"sender_id": identity.public_key_hex,
|
|
229
|
+
"timestamp": timestamp,
|
|
230
|
+
"message_id": message_id,
|
|
231
|
+
"signature_hex": signature_hex,
|
|
232
|
+
"compressed": compress,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def verify_signed_envelope(envelope: Dict[str, Any]) -> bool:
|
|
236
|
+
"""
|
|
237
|
+
Verify a received envelope's Ed25519 signature.
|
|
238
|
+
|
|
239
|
+
Always call this before processing a message!
|
|
240
|
+
Returns True if valid, False otherwise.
|
|
241
|
+
"""
|
|
242
|
+
if not _CRYPTO_AVAILABLE:
|
|
243
|
+
raise CryptoError("cryptography package required")
|
|
244
|
+
try:
|
|
245
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
246
|
+
from cryptography.exceptions import InvalidSignature
|
|
247
|
+
|
|
248
|
+
key_bytes = bytes.fromhex(envelope["sender_id"])
|
|
249
|
+
sig_bytes = bytes.fromhex(envelope["signature_hex"])
|
|
250
|
+
|
|
251
|
+
h = hashlib.sha512()
|
|
252
|
+
h.update(envelope["recipient_id"].encode())
|
|
253
|
+
h.update(envelope["payload_hex"].encode())
|
|
254
|
+
h.update(struct.pack("<q", envelope["timestamp"]))
|
|
255
|
+
h.update(envelope["message_id"].encode())
|
|
256
|
+
signing_hash = h.digest()
|
|
257
|
+
|
|
258
|
+
vk = Ed25519PublicKey.from_public_bytes(key_bytes)
|
|
259
|
+
vk.verify(sig_bytes, signing_hash)
|
|
260
|
+
|
|
261
|
+
# Check freshness (max 5 minutes)
|
|
262
|
+
age = abs(int(time.time()) - envelope["timestamp"])
|
|
263
|
+
if age > 300:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
return True
|
|
267
|
+
except Exception:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── HTTP Sync Client ──────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
class SibnaClient:
|
|
274
|
+
"""
|
|
275
|
+
Synchronous Sibna Protocol client.
|
|
276
|
+
|
|
277
|
+
Wraps the full v11.0 server API:
|
|
278
|
+
- Authentication (Ed25519 challenge-response → JWT)
|
|
279
|
+
- PreKey management
|
|
280
|
+
- Sealed envelope messaging (REST fallback)
|
|
281
|
+
- Inbox polling for offline messages
|
|
282
|
+
|
|
283
|
+
Usage:
|
|
284
|
+
client = SibnaClient(server="http://localhost:8080")
|
|
285
|
+
client.generate_identity()
|
|
286
|
+
client.authenticate()
|
|
287
|
+
client.upload_prekey()
|
|
288
|
+
client.send_message(recipient_id="...", plaintext=b"Hello!")
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(self, server: str = "http://localhost:8080"):
|
|
292
|
+
if not _REQUESTS_AVAILABLE:
|
|
293
|
+
raise NetworkError("requests package required: pip install requests")
|
|
294
|
+
self.server = server.rstrip("/")
|
|
295
|
+
self.identity: Optional[Identity] = None
|
|
296
|
+
self.jwt_token: Optional[str] = None
|
|
297
|
+
self._session = requests.Session()
|
|
298
|
+
|
|
299
|
+
def generate_identity(self, private_key_bytes: Optional[bytes] = None) -> Identity:
|
|
300
|
+
"""Generate (or load) an Ed25519 identity keypair."""
|
|
301
|
+
self.identity = Identity(private_key_bytes)
|
|
302
|
+
return self.identity
|
|
303
|
+
|
|
304
|
+
def authenticate(self) -> str:
|
|
305
|
+
"""
|
|
306
|
+
Full Ed25519 challenge-response authentication.
|
|
307
|
+
|
|
308
|
+
Returns the JWT token (also stored in self.jwt_token).
|
|
309
|
+
Tokens expire in 24h.
|
|
310
|
+
"""
|
|
311
|
+
if not self.identity:
|
|
312
|
+
raise AuthError("No identity loaded. Call generate_identity() first.")
|
|
313
|
+
|
|
314
|
+
# Step 1: Request challenge
|
|
315
|
+
r = self._session.post(f"{self.server}/v1/auth/challenge", json={
|
|
316
|
+
"identity_key_hex": self.identity.public_key_hex
|
|
317
|
+
})
|
|
318
|
+
self._check_response(r, "auth/challenge")
|
|
319
|
+
challenge_hex = r.json()["challenge_hex"]
|
|
320
|
+
|
|
321
|
+
# Step 2: Sign the challenge
|
|
322
|
+
challenge_bytes = bytes.fromhex(challenge_hex)
|
|
323
|
+
signature_hex = self.identity.sign_hex(challenge_bytes)
|
|
324
|
+
|
|
325
|
+
# Step 3: Prove
|
|
326
|
+
r = self._session.post(f"{self.server}/v1/auth/prove", json={
|
|
327
|
+
"identity_key_hex": self.identity.public_key_hex,
|
|
328
|
+
"challenge_hex": challenge_hex,
|
|
329
|
+
"signature_hex": signature_hex,
|
|
330
|
+
})
|
|
331
|
+
self._check_response(r, "auth/prove")
|
|
332
|
+
self.jwt_token = r.json()["token"]
|
|
333
|
+
return self.jwt_token
|
|
334
|
+
|
|
335
|
+
def health(self) -> Dict[str, Any]:
|
|
336
|
+
"""Check server health."""
|
|
337
|
+
r = self._session.get(f"{self.server}/health")
|
|
338
|
+
self._check_response(r, "health")
|
|
339
|
+
return r.json()
|
|
340
|
+
|
|
341
|
+
def upload_prekey(self, bundle_hex: str) -> None:
|
|
342
|
+
"""
|
|
343
|
+
Upload a signed PreKeyBundle to the server.
|
|
344
|
+
|
|
345
|
+
bundle_hex is produced by the Rust core library via FFI/WASM:
|
|
346
|
+
bundle = sibna_generate_prekey_bundle(ctx)
|
|
347
|
+
"""
|
|
348
|
+
r = self._session.post(f"{self.server}/v1/prekeys/upload", json={
|
|
349
|
+
"bundle_hex": bundle_hex
|
|
350
|
+
})
|
|
351
|
+
self._check_response(r, "prekeys/upload")
|
|
352
|
+
|
|
353
|
+
def fetch_prekeys(self, root_id_hex: str) -> List[str]:
|
|
354
|
+
"""
|
|
355
|
+
Fetch a peer's PreKeyBundles for X3DH initiation.
|
|
356
|
+
|
|
357
|
+
Returns a list of bundle_hex (one for each linked device). Note: bundles are deleted from server after fetch.
|
|
358
|
+
"""
|
|
359
|
+
r = self._session.get(f"{self.server}/v1/prekeys/{root_id_hex}")
|
|
360
|
+
self._check_response(r, "prekeys/fetch")
|
|
361
|
+
return r.json()["bundles_hex"]
|
|
362
|
+
|
|
363
|
+
def send_message(
|
|
364
|
+
self,
|
|
365
|
+
recipient_id: str,
|
|
366
|
+
payload_hex: str,
|
|
367
|
+
sign: bool = True,
|
|
368
|
+
compress: bool = False,
|
|
369
|
+
) -> int:
|
|
370
|
+
"""
|
|
371
|
+
Send a sealed envelope via REST (HTTP fallback for IoT/offline).
|
|
372
|
+
|
|
373
|
+
payload_hex: the Double Ratchet ciphertext (already encrypted by core).
|
|
374
|
+
sign: if True, adds Ed25519 signature for end-to-end integrity.
|
|
375
|
+
|
|
376
|
+
Returns HTTP status code (200 = delivered live, 202 = queued offline).
|
|
377
|
+
"""
|
|
378
|
+
if sign and self.identity:
|
|
379
|
+
body = make_signed_envelope(self.identity, recipient_id, payload_hex, compress)
|
|
380
|
+
else:
|
|
381
|
+
body = {
|
|
382
|
+
"recipient_id": recipient_id,
|
|
383
|
+
"payload_hex": payload_hex,
|
|
384
|
+
"compressed": compress,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
r = self._session.post(f"{self.server}/v1/messages/send", json=body)
|
|
388
|
+
self._check_response(r, "messages/send")
|
|
389
|
+
return r.status_code
|
|
390
|
+
|
|
391
|
+
def send_message_multi(
|
|
392
|
+
self,
|
|
393
|
+
encrypted_messages: Dict[str, str],
|
|
394
|
+
sign: bool = True,
|
|
395
|
+
compress: bool = False,
|
|
396
|
+
) -> Dict[str, int]:
|
|
397
|
+
"""
|
|
398
|
+
Fan-out send: Transmits sealed envelopes to multiple associated devices.
|
|
399
|
+
encrypted_messages: dict mapping `recipient_device_id_hex` -> `payload_hex`.
|
|
400
|
+
Returns a dict of recipient_device_id_hex -> HTTP status code.
|
|
401
|
+
"""
|
|
402
|
+
results = {}
|
|
403
|
+
for rcpt_id, payload in encrypted_messages.items():
|
|
404
|
+
results[rcpt_id] = self.send_message(rcpt_id, payload, sign, compress)
|
|
405
|
+
return results
|
|
406
|
+
|
|
407
|
+
def fetch_inbox(self) -> List[Dict[str, Any]]:
|
|
408
|
+
"""
|
|
409
|
+
Fetch queued offline messages from the server inbox.
|
|
410
|
+
|
|
411
|
+
Messages are deleted from the server after delivery.
|
|
412
|
+
Always verify each envelope's signature before processing!
|
|
413
|
+
"""
|
|
414
|
+
if not self.identity or not self.jwt_token:
|
|
415
|
+
raise AuthError("Must authenticate before fetching inbox.")
|
|
416
|
+
|
|
417
|
+
r = self._session.get(f"{self.server}/v1/messages/inbox", params={
|
|
418
|
+
"identity_key_hex": self.identity.public_key_hex,
|
|
419
|
+
"token": self.jwt_token,
|
|
420
|
+
})
|
|
421
|
+
self._check_response(r, "messages/inbox")
|
|
422
|
+
messages = r.json().get("messages", [])
|
|
423
|
+
|
|
424
|
+
# Verify each envelope's signature
|
|
425
|
+
verified = []
|
|
426
|
+
for msg in messages:
|
|
427
|
+
if verify_signed_envelope(msg):
|
|
428
|
+
verified.append(msg)
|
|
429
|
+
else:
|
|
430
|
+
print(f"âš WARNING: Dropped message with invalid signature: {msg.get('message_id')}")
|
|
431
|
+
|
|
432
|
+
return verified
|
|
433
|
+
|
|
434
|
+
def _check_response(self, r: "requests.Response", endpoint: str) -> None:
|
|
435
|
+
if r.status_code == 429:
|
|
436
|
+
raise NetworkError(f"Rate limited on {endpoint}", 429)
|
|
437
|
+
if r.status_code == 401:
|
|
438
|
+
raise AuthError(f"Unauthorized on {endpoint}", 401)
|
|
439
|
+
if r.status_code >= 400:
|
|
440
|
+
raise NetworkError(
|
|
441
|
+
f"{endpoint} failed: HTTP {r.status_code} — {r.text[:200]}", r.status_code
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def __repr__(self) -> str:
|
|
445
|
+
identity_str = self.identity.public_key_hex[:16] if self.identity else "None"
|
|
446
|
+
return f"<SibnaClient server={self.server} identity={identity_str}...>"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ── Async WebSocket Client ─────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
class AsyncSibnaClient:
|
|
452
|
+
"""
|
|
453
|
+
Async Sibna Protocol client with WebSocket real-time relay.
|
|
454
|
+
|
|
455
|
+
Usage:
|
|
456
|
+
client = AsyncSibnaClient(server="http://localhost:8080")
|
|
457
|
+
await client.generate_identity()
|
|
458
|
+
await client.authenticate()
|
|
459
|
+
await client.connect(on_message=my_handler)
|
|
460
|
+
await client.send("recipient_hex", b"Hello!")
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, server: str = "http://localhost:8080"):
|
|
464
|
+
self.server = server.rstrip("/")
|
|
465
|
+
self.ws_server = server.replace("http://", "ws://").replace("https://", "wss://")
|
|
466
|
+
self.identity: Optional[Identity] = None
|
|
467
|
+
self.jwt_token: Optional[str] = None
|
|
468
|
+
self._ws = None
|
|
469
|
+
self._on_message: Optional[Callable] = None
|
|
470
|
+
|
|
471
|
+
def generate_identity(self, private_key_bytes: Optional[bytes] = None) -> Identity:
|
|
472
|
+
self.identity = Identity(private_key_bytes)
|
|
473
|
+
return self.identity
|
|
474
|
+
|
|
475
|
+
async def authenticate(self) -> str:
|
|
476
|
+
"""Async Ed25519 challenge-response flow."""
|
|
477
|
+
if not _AIOHTTP_AVAILABLE:
|
|
478
|
+
raise NetworkError("aiohttp required: pip install aiohttp")
|
|
479
|
+
if not self.identity:
|
|
480
|
+
raise AuthError("No identity loaded.")
|
|
481
|
+
|
|
482
|
+
async with aiohttp.ClientSession() as session:
|
|
483
|
+
# Challenge
|
|
484
|
+
async with session.post(
|
|
485
|
+
f"{self.server}/v1/auth/challenge",
|
|
486
|
+
json={"identity_key_hex": self.identity.public_key_hex}
|
|
487
|
+
) as r:
|
|
488
|
+
if r.status != 200:
|
|
489
|
+
raise AuthError(f"Challenge failed: {r.status}")
|
|
490
|
+
data = await r.json()
|
|
491
|
+
challenge_hex = data["challenge_hex"]
|
|
492
|
+
|
|
493
|
+
# Prove
|
|
494
|
+
signature_hex = self.identity.sign_hex(bytes.fromhex(challenge_hex))
|
|
495
|
+
async with session.post(
|
|
496
|
+
f"{self.server}/v1/auth/prove",
|
|
497
|
+
json={
|
|
498
|
+
"identity_key_hex": self.identity.public_key_hex,
|
|
499
|
+
"challenge_hex": challenge_hex,
|
|
500
|
+
"signature_hex": signature_hex,
|
|
501
|
+
}
|
|
502
|
+
) as r:
|
|
503
|
+
if r.status != 200:
|
|
504
|
+
raise AuthError(f"Prove failed: {r.status}")
|
|
505
|
+
data = await r.json()
|
|
506
|
+
self.jwt_token = data["token"]
|
|
507
|
+
return self.jwt_token
|
|
508
|
+
|
|
509
|
+
async def connect(self, on_message: Optional[Callable] = None) -> None:
|
|
510
|
+
"""
|
|
511
|
+
Connect to WebSocket relay.
|
|
512
|
+
|
|
513
|
+
on_message: async callback(envelope: dict) called for each received message.
|
|
514
|
+
"""
|
|
515
|
+
if not self.jwt_token:
|
|
516
|
+
raise AuthError("Must authenticate before connecting.")
|
|
517
|
+
if not _AIOHTTP_AVAILABLE:
|
|
518
|
+
raise NetworkError("aiohttp required: pip install aiohttp")
|
|
519
|
+
|
|
520
|
+
self._on_message = on_message
|
|
521
|
+
ws_url = f"{self.ws_server}/ws?token={self.jwt_token}"
|
|
522
|
+
|
|
523
|
+
async with aiohttp.ClientSession() as session:
|
|
524
|
+
async with session.ws_connect(ws_url) as ws:
|
|
525
|
+
self._ws = ws
|
|
526
|
+
print(f"🟢 WebSocket connected to {ws_url[:40]}...")
|
|
527
|
+
async for msg in ws:
|
|
528
|
+
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
529
|
+
try:
|
|
530
|
+
envelope = json.loads(msg.data)
|
|
531
|
+
if verify_signed_envelope(envelope):
|
|
532
|
+
if self._on_message:
|
|
533
|
+
await self._on_message(envelope)
|
|
534
|
+
else:
|
|
535
|
+
print(f"âš Invalid signature on message {envelope.get('message_id')}")
|
|
536
|
+
except Exception as e:
|
|
537
|
+
print(f"âš Failed to parse message: {e}")
|
|
538
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
539
|
+
raise NetworkError(f"WebSocket error: {ws.exception()}")
|
|
540
|
+
|
|
541
|
+
async def send(
|
|
542
|
+
self,
|
|
543
|
+
recipient_id: str,
|
|
544
|
+
payload_hex: str,
|
|
545
|
+
sign: bool = True,
|
|
546
|
+
compress: bool = False,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""
|
|
549
|
+
Send a sealed envelope over WebSocket.
|
|
550
|
+
"""
|
|
551
|
+
if not self._ws:
|
|
552
|
+
raise NetworkError("Not connected. Call connect() first.")
|
|
553
|
+
|
|
554
|
+
if sign and self.identity:
|
|
555
|
+
envelope = make_signed_envelope(self.identity, recipient_id, payload_hex, compress)
|
|
556
|
+
else:
|
|
557
|
+
envelope = {
|
|
558
|
+
"recipient_id": recipient_id,
|
|
559
|
+
"payload_hex": payload_hex,
|
|
560
|
+
"compressed": compress,
|
|
561
|
+
"message_id": str(uuid.uuid4()),
|
|
562
|
+
"timestamp": int(time.time()),
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
await self._ws.send_bytes(json.dumps(envelope).encode())
|
|
566
|
+
|
|
567
|
+
async def send_multi(
|
|
568
|
+
self,
|
|
569
|
+
encrypted_messages: Dict[str, str],
|
|
570
|
+
sign: bool = True,
|
|
571
|
+
compress: bool = False,
|
|
572
|
+
) -> None:
|
|
573
|
+
"""
|
|
574
|
+
Fan-out send async: Send multiple sealed envelopes over WebSocket.
|
|
575
|
+
encrypted_messages: dict mapping `recipient_device_id_hex` -> `payload_hex`.
|
|
576
|
+
"""
|
|
577
|
+
if getattr(asyncio, "TaskGroup", None):
|
|
578
|
+
# Python 3.11+ async optimization for concurrent Fan-out via WS
|
|
579
|
+
async with asyncio.TaskGroup() as tg:
|
|
580
|
+
for rcpt_id, payload in encrypted_messages.items():
|
|
581
|
+
tg.create_task(self.send(rcpt_id, payload, sign, compress))
|
|
582
|
+
else:
|
|
583
|
+
# Fallback for Python < 3.11
|
|
584
|
+
aws = [self.send(rcpt_id, payload, sign, compress) for rcpt_id, payload in encrypted_messages.items()]
|
|
585
|
+
await asyncio.gather(*aws)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# ── Re-exports ────────────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
__all__ = [
|
|
591
|
+
"SibnaClient",
|
|
592
|
+
"AsyncSibnaClient",
|
|
593
|
+
"Identity",
|
|
594
|
+
"SibnaError",
|
|
595
|
+
"AuthError",
|
|
596
|
+
"NetworkError",
|
|
597
|
+
"CryptoError",
|
|
598
|
+
"pad_payload",
|
|
599
|
+
"unpad_payload",
|
|
600
|
+
"make_signed_envelope",
|
|
601
|
+
"verify_signed_envelope",
|
|
602
|
+
"__version__",
|
|
603
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sibna
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Standalone Ultra-Secure Communication Protocol - Python SDK
|
|
5
|
+
Author: Sibna Security Team
|
|
6
|
+
Author-email: security@sibna.dev
|
|
7
|
+
Keywords: cryptography encryption signal secure-messaging e2ee
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security :: Cryptography
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: keywords
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
A standalone Python SDK for the Sibna Protocol, including the pre-compiled Rust core.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sibna
|