sibna 1.4.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibna
3
- Version: 1.4.0
3
+ Version: 1.7.0
4
4
  Summary: Standalone Ultra-Secure Communication Protocol - Python SDK
5
5
  Author-email: Sibna Security Team <security@sibna.dev>
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibna"
7
- version = "1.4.0"
7
+ version = "1.7.0"
8
8
  description = "Standalone Ultra-Secure Communication Protocol - Python SDK"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,569 @@
1
+ """
2
+ Sibna Protocol — Python SDK
3
+ ============================
4
+
5
+ Python wrapper for the Sibna Rust core via FFI (ctypes).
6
+ No external dependencies — uses only Python stdlib + the compiled Rust library.
7
+
8
+ Requirements:
9
+ - Python 3.8+
10
+ - The compiled native library from sibna-protc/core:
11
+ Windows : sibna_core.dll
12
+ Linux : libsibna_core.so
13
+ macOS : libsibna_core.dylib
14
+
15
+ Build it:
16
+ cd sibna-protc/core
17
+ cargo build --release --features ffi
18
+
19
+ Then place the file next to this sibna/ folder.
20
+
21
+ Quick start:
22
+ import sibna
23
+
24
+ if not sibna.is_available():
25
+ raise RuntimeError("Native library not found")
26
+
27
+ key = sibna.generate_key()
28
+ ct = sibna.encrypt(key, b"Hello")
29
+ pt = sibna.decrypt(key, ct)
30
+ assert pt == b"Hello"
31
+ """
32
+
33
+ __version__ = "1.0.0"
34
+ __author__ = "Sibna Security Team"
35
+ __license__ = "Apache-2.0"
36
+
37
+ import ctypes
38
+ import os
39
+ import platform
40
+ from typing import Optional, Tuple
41
+
42
+ # ── Library loading ────────────────────────────────────────────────────────────
43
+
44
+ def _find_library() -> Optional[str]:
45
+ system = platform.system()
46
+ if system == "Linux":
47
+ names = ["libsibna_core.so"]
48
+ elif system == "Darwin":
49
+ names = ["libsibna_core.dylib"]
50
+ elif system == "Windows":
51
+ names = ["sibna_core.dll"]
52
+ else:
53
+ return None
54
+
55
+ search_paths = [
56
+ os.path.dirname(os.path.dirname(__file__)), # parent of sibna/
57
+ os.path.dirname(__file__), # sibna/ itself
58
+ os.path.join(os.path.dirname(__file__),
59
+ "..", "..", "core", "target", "release"),
60
+ ]
61
+ for folder in search_paths:
62
+ for name in names:
63
+ candidate = os.path.normpath(os.path.join(folder, name))
64
+ if os.path.isfile(candidate):
65
+ return candidate
66
+ return None
67
+
68
+
69
+ def _configure(lib: ctypes.CDLL) -> None:
70
+ lib.sibna_generate_key.restype = ctypes.c_int
71
+ lib.sibna_generate_key.argtypes = [ctypes.c_char_p]
72
+
73
+ lib.sibna_random_bytes.restype = ctypes.c_int
74
+ lib.sibna_random_bytes.argtypes = [ctypes.c_size_t, ctypes.c_char_p]
75
+
76
+ lib.sibna_encrypt.restype = ctypes.c_int
77
+ lib.sibna_encrypt.argtypes = [
78
+ ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t,
79
+ ctypes.c_char_p, ctypes.c_size_t, ctypes.c_void_p,
80
+ ]
81
+ lib.sibna_decrypt.restype = ctypes.c_int
82
+ lib.sibna_decrypt.argtypes = [
83
+ ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t,
84
+ ctypes.c_char_p, ctypes.c_size_t, ctypes.c_void_p,
85
+ ]
86
+ lib.sibna_free_buffer.restype = None
87
+ lib.sibna_free_buffer.argtypes = [ctypes.c_void_p]
88
+
89
+ lib.sibna_version.restype = ctypes.c_int
90
+ lib.sibna_version.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
91
+
92
+ lib.sibna_context_create.restype = ctypes.c_int
93
+ lib.sibna_context_create.argtypes = [
94
+ ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_void_p),
95
+ ]
96
+ lib.sibna_context_destroy.restype = None
97
+ lib.sibna_context_destroy.argtypes = [ctypes.c_void_p]
98
+
99
+ lib.sibna_generate_identity.restype = ctypes.c_int
100
+ lib.sibna_generate_identity.argtypes = [
101
+ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p,
102
+ ]
103
+ lib.sibna_generate_prekey_bundle.restype = ctypes.c_int
104
+ lib.sibna_generate_prekey_bundle.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
105
+
106
+ lib.sibna_perform_handshake.restype = ctypes.c_int
107
+ lib.sibna_perform_handshake.argtypes = [
108
+ ctypes.c_void_p,
109
+ ctypes.c_char_p, ctypes.c_size_t,
110
+ ctypes.c_char_p, ctypes.c_size_t,
111
+ ctypes.c_uint8,
112
+ ]
113
+ lib.sibna_session_encrypt.restype = ctypes.c_int
114
+ lib.sibna_session_encrypt.argtypes = [
115
+ ctypes.c_void_p,
116
+ ctypes.c_char_p, ctypes.c_size_t,
117
+ ctypes.c_char_p, ctypes.c_size_t,
118
+ ctypes.c_char_p, ctypes.c_size_t,
119
+ ctypes.c_void_p,
120
+ ]
121
+ lib.sibna_session_decrypt.restype = ctypes.c_int
122
+ lib.sibna_session_decrypt.argtypes = [
123
+ ctypes.c_void_p,
124
+ ctypes.c_char_p, ctypes.c_size_t,
125
+ ctypes.c_char_p, ctypes.c_size_t,
126
+ ctypes.c_char_p, ctypes.c_size_t,
127
+ ctypes.c_void_p,
128
+ ]
129
+
130
+
131
+ def _load() -> Optional[ctypes.CDLL]:
132
+ path = _find_library()
133
+ if not path:
134
+ return None
135
+ try:
136
+ lib = ctypes.CDLL(path)
137
+ _configure(lib)
138
+ return lib
139
+ except OSError:
140
+ return None
141
+
142
+
143
+ _lib: Optional[ctypes.CDLL] = _load()
144
+
145
+
146
+ def is_available() -> bool:
147
+ """True if the native Rust library is loaded and ready."""
148
+ return _lib is not None
149
+
150
+
151
+ def library_path() -> Optional[str]:
152
+ """Full path of the loaded library, or None."""
153
+ return _find_library()
154
+
155
+
156
+ # ── ByteBuffer — mirrors Rust FFI struct ──────────────────────────────────────
157
+
158
+ class _ByteBuffer(ctypes.Structure):
159
+ _fields_ = [
160
+ ("data", ctypes.POINTER(ctypes.c_uint8)),
161
+ ("len", ctypes.c_size_t),
162
+ ("capacity", ctypes.c_size_t),
163
+ ]
164
+
165
+ def to_bytes(self) -> bytes:
166
+ if not self.data or self.len == 0:
167
+ return b""
168
+ return bytes((ctypes.c_uint8 * self.len).from_address(
169
+ ctypes.addressof(self.data.contents)
170
+ ))
171
+
172
+ def free(self) -> None:
173
+ if _lib and self.data:
174
+ _lib.sibna_free_buffer(ctypes.byref(self))
175
+
176
+
177
+ # ── Errors ────────────────────────────────────────────────────────────────────
178
+
179
+ _ERRORS = {
180
+ 1: "Invalid argument", 2: "Invalid key", 3: "Encryption failed",
181
+ 4: "Decryption failed", 5: "Out of memory", 6: "Invalid state",
182
+ 7: "Session not found", 8: "Key not found", 9: "Rate limit exceeded",
183
+ 10: "Internal error", 11: "Buffer too small", 12: "Invalid ciphertext",
184
+ 13: "Authentication failed",
185
+ }
186
+
187
+
188
+ class SibnaError(Exception):
189
+ """Error from the native library."""
190
+ def __init__(self, code: int):
191
+ self.code = code
192
+ super().__init__(f"SibnaError({code}): {_ERRORS.get(code, f'Unknown ({code})')}")
193
+
194
+
195
+ class LibraryNotFoundError(RuntimeError):
196
+ """
197
+ Raised when a function requires the native library and it is not found.
198
+
199
+ Fix:
200
+ cd sibna-protc/core
201
+ cargo build --release --features ffi
202
+ Then place sibna_core.dll / libsibna_core.so / .dylib
203
+ next to the sibna/ package folder.
204
+ """
205
+ def __init__(self):
206
+ super().__init__(
207
+ "Native library not found.\n"
208
+ "Build it from sibna-protc:\n"
209
+ " cd sibna-protc/core\n"
210
+ " cargo build --release --features ffi\n"
211
+ "Then place the output file next to this SDK."
212
+ )
213
+
214
+
215
+ def _require() -> ctypes.CDLL:
216
+ if _lib is None:
217
+ raise LibraryNotFoundError()
218
+ return _lib
219
+
220
+
221
+ def _check(code: int) -> None:
222
+ if code != 0:
223
+ raise SibnaError(code)
224
+
225
+
226
+ # ── Standalone crypto ─────────────────────────────────────────────────────────
227
+
228
+ def version() -> str:
229
+ """Protocol version string from the native library."""
230
+ lib = _require()
231
+ buf = ctypes.create_string_buffer(32)
232
+ _check(lib.sibna_version(buf, 32))
233
+ return buf.value.decode()
234
+
235
+
236
+ def generate_key() -> bytes:
237
+ """
238
+ Generate a cryptographically secure random 32-byte encryption key.
239
+
240
+ Returns:
241
+ bytes: 32-byte key for use with encrypt() / decrypt().
242
+ """
243
+ lib = _require()
244
+ buf = ctypes.create_string_buffer(32)
245
+ _check(lib.sibna_generate_key(buf))
246
+ return buf.raw
247
+
248
+
249
+ def random_bytes(length: int) -> bytes:
250
+ """
251
+ Generate cryptographically secure random bytes.
252
+
253
+ Args:
254
+ length: Number of bytes (must be > 0).
255
+ """
256
+ if length <= 0:
257
+ raise ValueError("length must be > 0")
258
+ lib = _require()
259
+ buf = ctypes.create_string_buffer(length)
260
+ _check(lib.sibna_random_bytes(length, buf))
261
+ return buf.raw
262
+
263
+
264
+ def encrypt(
265
+ key: bytes,
266
+ plaintext: bytes,
267
+ associated_data: Optional[bytes] = None,
268
+ ) -> bytes:
269
+ """
270
+ Encrypt data with ChaCha20-Poly1305.
271
+
272
+ Args:
273
+ key: 32-byte key from generate_key().
274
+ plaintext: Data to encrypt (must be non-empty).
275
+ associated_data: Optional authenticated-but-not-encrypted context.
276
+ Must pass the same value to decrypt().
277
+
278
+ Returns:
279
+ bytes: nonce (12) + ciphertext + tag (16).
280
+
281
+ Raises:
282
+ ValueError: Wrong key size or empty plaintext.
283
+ SibnaError(3): Encryption failed.
284
+ LibraryNotFoundError if library not loaded.
285
+ """
286
+ lib = _require()
287
+ if len(key) != 32:
288
+ raise ValueError(f"Key must be 32 bytes (got {len(key)})")
289
+ if not plaintext:
290
+ raise ValueError("plaintext must not be empty")
291
+ out = _ByteBuffer()
292
+ _check(lib.sibna_encrypt(
293
+ key, plaintext, len(plaintext),
294
+ associated_data, len(associated_data) if associated_data else 0,
295
+ ctypes.byref(out),
296
+ ))
297
+ try:
298
+ return out.to_bytes()
299
+ finally:
300
+ out.free()
301
+
302
+
303
+ def decrypt(
304
+ key: bytes,
305
+ ciphertext: bytes,
306
+ associated_data: Optional[bytes] = None,
307
+ ) -> bytes:
308
+ """
309
+ Decrypt data with ChaCha20-Poly1305.
310
+
311
+ Args:
312
+ key: Same 32-byte key used in encrypt().
313
+ ciphertext: Output of encrypt().
314
+ associated_data: Same value passed to encrypt() (if any).
315
+
316
+ Returns:
317
+ bytes: Original plaintext.
318
+
319
+ Raises:
320
+ SibnaError(13): Data tampered or wrong key (Authentication failed).
321
+ SibnaError(12): Ciphertext is invalid/truncated.
322
+ LibraryNotFoundError if library not loaded.
323
+ """
324
+ lib = _require()
325
+ if len(key) != 32:
326
+ raise ValueError(f"Key must be 32 bytes (got {len(key)})")
327
+ if not ciphertext:
328
+ raise ValueError("ciphertext must not be empty")
329
+ out = _ByteBuffer()
330
+ _check(lib.sibna_decrypt(
331
+ key, ciphertext, len(ciphertext),
332
+ associated_data, len(associated_data) if associated_data else 0,
333
+ ctypes.byref(out),
334
+ ))
335
+ try:
336
+ return out.to_bytes()
337
+ finally:
338
+ out.free()
339
+
340
+
341
+ # ── Context ────────────────────────────────────────────────────────────────────
342
+
343
+ class Context:
344
+ """
345
+ Manages identity keypairs and Double Ratchet sessions.
346
+
347
+ Password rules (enforced by Rust core):
348
+ Must contain uppercase + lowercase + digit, minimum 8 characters.
349
+
350
+ Usage:
351
+ ctx = Context(password=b"MyPass1!")
352
+ ed_pub, x_pub = ctx.generate_identity()
353
+ bundle = ctx.generate_prekey_bundle()
354
+
355
+ # After exchanging bundles with a peer:
356
+ ctx.perform_handshake(peer_id=peer_ed_pub, peer_bundle=peer_bundle, initiator=True)
357
+ ct = ctx.session_encrypt(peer_ed_pub, b"Hello!")
358
+ pt = ctx.session_decrypt(peer_ed_pub, ct)
359
+
360
+ ctx.close()
361
+ """
362
+
363
+ def __init__(self, password: Optional[bytes] = None):
364
+ """
365
+ Args:
366
+ password: Optional master password for the local keystore.
367
+ If None, a random storage key is generated.
368
+
369
+ Raises:
370
+ SibnaError(10): Password too weak.
371
+ LibraryNotFoundError: Library not loaded.
372
+ """
373
+ lib = _require()
374
+ handle = ctypes.c_void_p()
375
+ _check(lib.sibna_context_create(
376
+ password, len(password) if password else 0,
377
+ ctypes.byref(handle),
378
+ ))
379
+ self._handle = handle
380
+ self._lib = lib
381
+ self._closed = False
382
+
383
+ def __enter__(self):
384
+ return self
385
+
386
+ def __exit__(self, *_):
387
+ self.close()
388
+
389
+ def generate_identity(self) -> Tuple[bytes, bytes]:
390
+ """
391
+ Generate an Ed25519 + X25519 identity keypair stored in the keystore.
392
+
393
+ Returns:
394
+ (ed25519_public_key, x25519_public_key) — both 32 bytes.
395
+ - ed25519_public_key: for signing / identity verification
396
+ - x25519_public_key: for X3DH key agreement
397
+
398
+ Raises:
399
+ SibnaError: on failure.
400
+ """
401
+ self._check_open()
402
+ ed_buf = ctypes.create_string_buffer(32)
403
+ x_buf = ctypes.create_string_buffer(32)
404
+ _check(self._lib.sibna_generate_identity(self._handle, ed_buf, x_buf))
405
+ return ed_buf.raw, x_buf.raw
406
+
407
+ def generate_prekey_bundle(self) -> bytes:
408
+ """
409
+ Generate a PreKey Bundle to upload to the prekey server.
410
+
411
+ Must call generate_identity() first.
412
+
413
+ Returns:
414
+ bytes: Serialized bundle — pass directly to your server
415
+ or to SibnaClient.upload_prekey(bundle.hex()).
416
+
417
+ Raises:
418
+ SibnaError(8): No identity generated yet (Key not found).
419
+ """
420
+ self._check_open()
421
+ out = _ByteBuffer()
422
+ _check(self._lib.sibna_generate_prekey_bundle(
423
+ self._handle, ctypes.byref(out),
424
+ ))
425
+ try:
426
+ return out.to_bytes()
427
+ finally:
428
+ out.free()
429
+
430
+ def perform_handshake(
431
+ self,
432
+ peer_id: bytes,
433
+ peer_bundle: bytes,
434
+ initiator: bool,
435
+ ) -> None:
436
+ """
437
+ Run X3DH handshake and create a Double Ratchet session.
438
+
439
+ After success, use session_encrypt() / session_decrypt() with peer_id.
440
+
441
+ Args:
442
+ peer_id: Peer's Ed25519 public key (used as session identifier).
443
+ peer_bundle: Peer's PreKey Bundle bytes (from prekey server).
444
+ initiator: True if you start the conversation, False if responding.
445
+
446
+ Raises:
447
+ SibnaError(2): Invalid bundle or bad signature.
448
+ SibnaError(6): Context in invalid state.
449
+ """
450
+ self._check_open()
451
+ if not peer_id:
452
+ raise ValueError("peer_id must not be empty")
453
+ if not peer_bundle:
454
+ raise ValueError("peer_bundle must not be empty")
455
+ _check(self._lib.sibna_perform_handshake(
456
+ self._handle,
457
+ peer_bundle, len(peer_bundle),
458
+ peer_id, len(peer_id),
459
+ 1 if initiator else 0,
460
+ ))
461
+
462
+ def session_encrypt(
463
+ self,
464
+ peer_id: bytes,
465
+ plaintext: bytes,
466
+ associated_data: Optional[bytes] = None,
467
+ ) -> bytes:
468
+ """
469
+ Encrypt a message over an existing Double Ratchet session.
470
+
471
+ The ratchet key advances with every message — forward secrecy
472
+ is automatic.
473
+
474
+ Args:
475
+ peer_id: Same bytes used in perform_handshake().
476
+ plaintext: Message to encrypt (non-empty).
477
+ associated_data: Optional authenticated context (e.g. message ID).
478
+
479
+ Returns:
480
+ bytes: Encrypted message. Pass to peer's session_decrypt().
481
+
482
+ Raises:
483
+ SibnaError(7): No session for this peer. Call perform_handshake() first.
484
+ SibnaError(9): Rate limit exceeded.
485
+ """
486
+ self._check_open()
487
+ if not peer_id:
488
+ raise ValueError("peer_id must not be empty")
489
+ if not plaintext:
490
+ raise ValueError("plaintext must not be empty")
491
+ out = _ByteBuffer()
492
+ _check(self._lib.sibna_session_encrypt(
493
+ self._handle,
494
+ peer_id, len(peer_id),
495
+ plaintext, len(plaintext),
496
+ associated_data, len(associated_data) if associated_data else 0,
497
+ ctypes.byref(out),
498
+ ))
499
+ try:
500
+ return out.to_bytes()
501
+ finally:
502
+ out.free()
503
+
504
+ def session_decrypt(
505
+ self,
506
+ peer_id: bytes,
507
+ ciphertext: bytes,
508
+ associated_data: Optional[bytes] = None,
509
+ ) -> bytes:
510
+ """
511
+ Decrypt a message from a Double Ratchet session.
512
+
513
+ Args:
514
+ peer_id: Peer identifier (same as used in perform_handshake).
515
+ ciphertext: Encrypted message from the peer's session_encrypt().
516
+ associated_data: Same value passed during encryption (if any).
517
+
518
+ Returns:
519
+ bytes: Original plaintext.
520
+
521
+ Raises:
522
+ SibnaError(13): Message tampered or wrong session.
523
+ SibnaError(7): Session not found.
524
+ SibnaError(12): Ciphertext too short / corrupted.
525
+ """
526
+ self._check_open()
527
+ if not peer_id:
528
+ raise ValueError("peer_id must not be empty")
529
+ if not ciphertext:
530
+ raise ValueError("ciphertext must not be empty")
531
+ out = _ByteBuffer()
532
+ _check(self._lib.sibna_session_decrypt(
533
+ self._handle,
534
+ peer_id, len(peer_id),
535
+ ciphertext, len(ciphertext),
536
+ associated_data, len(associated_data) if associated_data else 0,
537
+ ctypes.byref(out),
538
+ ))
539
+ try:
540
+ return out.to_bytes()
541
+ finally:
542
+ out.free()
543
+
544
+ def close(self) -> None:
545
+ """Free native memory and zero all keys. Call when done."""
546
+ if not self._closed and self._handle:
547
+ self._lib.sibna_context_destroy(self._handle)
548
+ self._closed = True
549
+
550
+ def _check_open(self) -> None:
551
+ if self._closed:
552
+ raise SibnaError(6) # invalid state
553
+
554
+ def __del__(self):
555
+ self.close()
556
+
557
+ def __repr__(self) -> str:
558
+ return f"<sibna.Context closed={self._closed}>"
559
+
560
+
561
+ # ── Exports ────────────────────────────────────────────────────────────────────
562
+
563
+ __all__ = [
564
+ "is_available", "library_path", "version",
565
+ "SibnaError", "LibraryNotFoundError",
566
+ "generate_key", "random_bytes", "encrypt", "decrypt",
567
+ "Context",
568
+ "__version__",
569
+ ]