gbp-stack 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gbp_stack-0.2.0/PKG-INFO +79 -0
- gbp_stack-0.2.0/README.md +55 -0
- gbp_stack-0.2.0/gbp_stack/__init__.py +40 -0
- gbp_stack-0.2.0/gbp_stack/_native.py +189 -0
- gbp_stack-0.2.0/gbp_stack/gap_client.py +106 -0
- gbp_stack-0.2.0/gbp_stack/gbp_node.py +256 -0
- gbp_stack-0.2.0/gbp_stack/gsp_client.py +123 -0
- gbp_stack-0.2.0/gbp_stack/gtp_client.py +104 -0
- gbp_stack-0.2.0/gbp_stack/mls_context.py +93 -0
- gbp_stack-0.2.0/gbp_stack.egg-info/PKG-INFO +79 -0
- gbp_stack-0.2.0/gbp_stack.egg-info/SOURCES.txt +14 -0
- gbp_stack-0.2.0/gbp_stack.egg-info/dependency_links.txt +1 -0
- gbp_stack-0.2.0/gbp_stack.egg-info/top_level.txt +1 -0
- gbp_stack-0.2.0/pyproject.toml +45 -0
- gbp_stack-0.2.0/setup.cfg +4 -0
- gbp_stack-0.2.0/setup.py +22 -0
gbp_stack-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gbp-stack
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python bindings for the Group Protocol Stack: a layered, end-to-end encrypted group-messaging protocol family built on top of MLS (RFC 9420).
|
|
5
|
+
Author: Group Protocol Stack contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/F000NKKK/Group-Protocol-Stack
|
|
8
|
+
Project-URL: Repository, https://github.com/F000NKKK/Group-Protocol-Stack
|
|
9
|
+
Project-URL: Documentation, https://github.com/F000NKKK/Group-Protocol-Stack#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/F000NKKK/Group-Protocol-Stack/issues
|
|
11
|
+
Keywords: mls,rfc9420,e2ee,group-messaging,chat,voice,signaling,cryptography,ffi
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# gbp-stack — Python bindings for the Group Protocol Stack
|
|
26
|
+
|
|
27
|
+
[](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE)
|
|
28
|
+
|
|
29
|
+
Python bindings for the [Group Protocol Stack](https://github.com/F000NKKK/Group-Protocol-Stack):
|
|
30
|
+
a layered, end-to-end encrypted group-messaging protocol family built on top
|
|
31
|
+
of [MLS (RFC 9420)](https://www.rfc-editor.org/rfc/rfc9420).
|
|
32
|
+
|
|
33
|
+
This package wraps the native `gbp_stack` shared library through `ctypes`.
|
|
34
|
+
The wheel for each supported platform bundles the appropriate native binary
|
|
35
|
+
under `gbp_stack/_native/<rid>/`.
|
|
36
|
+
|
|
37
|
+
## Layers
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌── application ──────────────────────────────────────────────────────┐
|
|
41
|
+
│ GtpClient · GapClient · GspClient (TCP / UDP / SCTP-like) │
|
|
42
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
43
|
+
│ GroupNode (GBP — IP-like base) │
|
|
44
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
45
|
+
│ MlsContext (RFC 9420) │
|
|
46
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from gbp_stack import MlsContext, GroupNode, GtpClient
|
|
53
|
+
|
|
54
|
+
with MlsContext.create("alice") as alice_mls, \
|
|
55
|
+
MlsContext.create("bob") as bob_mls:
|
|
56
|
+
|
|
57
|
+
bob_kp = bob_mls.export_key_package()
|
|
58
|
+
welcome = alice_mls.invite(bob_kp)
|
|
59
|
+
bob_mls.accept_welcome(welcome)
|
|
60
|
+
|
|
61
|
+
group_id = alice_mls.group_id
|
|
62
|
+
with GroupNode.create(member_id=1, group_id=group_id) as alice, \
|
|
63
|
+
GroupNode.create(member_id=2, group_id=group_id) as bob, \
|
|
64
|
+
GtpClient.create() as gtp_alice, \
|
|
65
|
+
GtpClient.create() as gtp_bob:
|
|
66
|
+
|
|
67
|
+
alice.bootstrap_as_creator(alice_mls.epoch)
|
|
68
|
+
bob.bootstrap_as_joiner(bob_mls.epoch)
|
|
69
|
+
|
|
70
|
+
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
71
|
+
message_id=0xCAFE_F00D, text="hello")
|
|
72
|
+
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
73
|
+
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
74
|
+
print(gtp_bob.accept(ev.plaintext).text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
Licensed under [Apache License, Version 2.0](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# gbp-stack — Python bindings for the Group Protocol Stack
|
|
2
|
+
|
|
3
|
+
[](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
Python bindings for the [Group Protocol Stack](https://github.com/F000NKKK/Group-Protocol-Stack):
|
|
6
|
+
a layered, end-to-end encrypted group-messaging protocol family built on top
|
|
7
|
+
of [MLS (RFC 9420)](https://www.rfc-editor.org/rfc/rfc9420).
|
|
8
|
+
|
|
9
|
+
This package wraps the native `gbp_stack` shared library through `ctypes`.
|
|
10
|
+
The wheel for each supported platform bundles the appropriate native binary
|
|
11
|
+
under `gbp_stack/_native/<rid>/`.
|
|
12
|
+
|
|
13
|
+
## Layers
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌── application ──────────────────────────────────────────────────────┐
|
|
17
|
+
│ GtpClient · GapClient · GspClient (TCP / UDP / SCTP-like) │
|
|
18
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
19
|
+
│ GroupNode (GBP — IP-like base) │
|
|
20
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
21
|
+
│ MlsContext (RFC 9420) │
|
|
22
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from gbp_stack import MlsContext, GroupNode, GtpClient
|
|
29
|
+
|
|
30
|
+
with MlsContext.create("alice") as alice_mls, \
|
|
31
|
+
MlsContext.create("bob") as bob_mls:
|
|
32
|
+
|
|
33
|
+
bob_kp = bob_mls.export_key_package()
|
|
34
|
+
welcome = alice_mls.invite(bob_kp)
|
|
35
|
+
bob_mls.accept_welcome(welcome)
|
|
36
|
+
|
|
37
|
+
group_id = alice_mls.group_id
|
|
38
|
+
with GroupNode.create(member_id=1, group_id=group_id) as alice, \
|
|
39
|
+
GroupNode.create(member_id=2, group_id=group_id) as bob, \
|
|
40
|
+
GtpClient.create() as gtp_alice, \
|
|
41
|
+
GtpClient.create() as gtp_bob:
|
|
42
|
+
|
|
43
|
+
alice.bootstrap_as_creator(alice_mls.epoch)
|
|
44
|
+
bob.bootstrap_as_joiner(bob_mls.epoch)
|
|
45
|
+
|
|
46
|
+
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
47
|
+
message_id=0xCAFE_F00D, text="hello")
|
|
48
|
+
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
49
|
+
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
50
|
+
print(gtp_bob.accept(ev.plaintext).text)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
Licensed under [Apache License, Version 2.0](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Python bindings for the Group Protocol Stack.
|
|
2
|
+
|
|
3
|
+
This package exposes a high-level Python API on top of the native
|
|
4
|
+
``gbp_stack`` shared library:
|
|
5
|
+
|
|
6
|
+
* :class:`MlsContext` — RFC 9420 MLS context.
|
|
7
|
+
* :class:`GroupNode` — GBP-layer group node (the IP-like base).
|
|
8
|
+
* :class:`GtpClient`, :class:`GapClient`, :class:`GspClient` — sub-protocol
|
|
9
|
+
clients (text, audio, signalling).
|
|
10
|
+
|
|
11
|
+
See :func:`version` for the underlying library version and the project
|
|
12
|
+
README for a worked example.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .gbp_node import GroupNode, NodeEvent, NodeState, OutboundFrame, StreamType
|
|
16
|
+
from .gap_client import GapAcceptResult, GapClient
|
|
17
|
+
from .gsp_client import GspAcceptResult, GspClient, SignalType
|
|
18
|
+
from .gtp_client import GtpAcceptResult, GtpClient
|
|
19
|
+
from .mls_context import MlsContext
|
|
20
|
+
from ._native import last_error, version
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"GapAcceptResult",
|
|
24
|
+
"GapClient",
|
|
25
|
+
"GroupNode",
|
|
26
|
+
"GspAcceptResult",
|
|
27
|
+
"GspClient",
|
|
28
|
+
"GtpAcceptResult",
|
|
29
|
+
"GtpClient",
|
|
30
|
+
"MlsContext",
|
|
31
|
+
"NodeEvent",
|
|
32
|
+
"NodeState",
|
|
33
|
+
"OutboundFrame",
|
|
34
|
+
"SignalType",
|
|
35
|
+
"StreamType",
|
|
36
|
+
"last_error",
|
|
37
|
+
"version",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Low-level ctypes bindings to the native ``gbp_stack`` shared library.
|
|
2
|
+
|
|
3
|
+
The library is loaded from a platform-specific subdirectory of
|
|
4
|
+
``gbp_stack/_native/`` (so it can be packaged inside the wheel), with a
|
|
5
|
+
fallback to the OS loader path. Call sites should not depend on the symbols
|
|
6
|
+
in this module directly — use the high-level wrappers in :mod:`gbp_stack`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
from ctypes import (
|
|
15
|
+
Structure,
|
|
16
|
+
c_bool,
|
|
17
|
+
c_char_p,
|
|
18
|
+
c_int32,
|
|
19
|
+
c_size_t,
|
|
20
|
+
c_uint8,
|
|
21
|
+
c_uint16,
|
|
22
|
+
c_uint32,
|
|
23
|
+
c_uint64,
|
|
24
|
+
c_void_p,
|
|
25
|
+
)
|
|
26
|
+
from typing import Iterable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _candidate_paths() -> Iterable[str]:
|
|
30
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
system = platform.system().lower()
|
|
32
|
+
machine = platform.machine().lower()
|
|
33
|
+
if system == "windows":
|
|
34
|
+
rid = "win-x64" if machine in ("amd64", "x86_64") else f"win-{machine}"
|
|
35
|
+
name = "gbp_stack.dll"
|
|
36
|
+
elif system == "darwin":
|
|
37
|
+
rid = "osx-arm64" if machine == "arm64" else "osx-x64"
|
|
38
|
+
name = "libgbp_stack.dylib"
|
|
39
|
+
else:
|
|
40
|
+
rid = "linux-x64" if machine in ("x86_64", "amd64") else f"linux-{machine}"
|
|
41
|
+
name = "libgbp_stack.so"
|
|
42
|
+
yield os.path.join(here, "_native", rid, name)
|
|
43
|
+
yield os.path.join(here, "_native", name)
|
|
44
|
+
yield name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load() -> ctypes.CDLL:
|
|
48
|
+
last_err: OSError | None = None
|
|
49
|
+
for path in _candidate_paths():
|
|
50
|
+
try:
|
|
51
|
+
return ctypes.CDLL(path)
|
|
52
|
+
except OSError as e:
|
|
53
|
+
last_err = e
|
|
54
|
+
raise OSError(
|
|
55
|
+
"failed to load native gbp_stack library; tried: "
|
|
56
|
+
+ ", ".join(_candidate_paths())
|
|
57
|
+
+ (f"; last error: {last_err}" if last_err else "")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_lib = _load()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GbpBuffer(Structure):
|
|
65
|
+
"""``(ptr, len, cap)`` triple matching the FFI ``GbpBuffer`` struct."""
|
|
66
|
+
|
|
67
|
+
_fields_ = [
|
|
68
|
+
("ptr", c_void_p),
|
|
69
|
+
("len", c_size_t),
|
|
70
|
+
("cap", c_size_t),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bind(name: str, restype, argtypes):
|
|
75
|
+
fn = getattr(_lib, name)
|
|
76
|
+
fn.restype = restype
|
|
77
|
+
fn.argtypes = argtypes
|
|
78
|
+
return fn
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
gbp_buffer_free = _bind("gbp_buffer_free", None, [GbpBuffer])
|
|
82
|
+
gbp_string_free = _bind("gbp_string_free", None, [c_void_p])
|
|
83
|
+
_gbp_last_error = _bind("gbp_last_error", c_void_p, [])
|
|
84
|
+
_gbp_version = _bind("gbp_version", c_void_p, [])
|
|
85
|
+
|
|
86
|
+
gbp_mls_create = _bind("gbp_mls_create", c_int32, [c_void_p, c_size_t])
|
|
87
|
+
gbp_mls_destroy = _bind("gbp_mls_destroy", None, [c_int32])
|
|
88
|
+
gbp_mls_epoch = _bind("gbp_mls_epoch", c_uint64, [c_int32])
|
|
89
|
+
gbp_mls_group_id = _bind("gbp_mls_group_id", c_bool, [c_int32, c_void_p])
|
|
90
|
+
gbp_mls_export_key_package = _bind("gbp_mls_export_key_package", GbpBuffer, [c_int32])
|
|
91
|
+
gbp_mls_invite = _bind("gbp_mls_invite", GbpBuffer, [c_int32, c_void_p, c_size_t])
|
|
92
|
+
gbp_mls_accept_welcome = _bind(
|
|
93
|
+
"gbp_mls_accept_welcome", c_bool, [c_int32, c_void_p, c_size_t]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
gbp_node_create = _bind("gbp_node_create", c_int32, [c_uint32, c_void_p])
|
|
97
|
+
gbp_node_destroy = _bind("gbp_node_destroy", None, [c_int32])
|
|
98
|
+
gbp_node_bootstrap_creator = _bind("gbp_node_bootstrap_creator", c_bool, [c_int32, c_uint64])
|
|
99
|
+
gbp_node_bootstrap_joiner = _bind("gbp_node_bootstrap_joiner", c_bool, [c_int32, c_uint64])
|
|
100
|
+
gbp_node_state = _bind("gbp_node_state", c_uint32, [c_int32])
|
|
101
|
+
gbp_node_epoch = _bind("gbp_node_epoch", c_uint64, [c_int32])
|
|
102
|
+
gbp_node_last_transition_id = _bind("gbp_node_last_transition_id", c_uint32, [c_int32])
|
|
103
|
+
gbp_node_set_epoch = _bind("gbp_node_set_epoch", c_bool, [c_int32, c_uint64])
|
|
104
|
+
gbp_node_apply_transition = _bind("gbp_node_apply_transition", c_bool, [c_int32, c_uint32])
|
|
105
|
+
gbp_node_send_control = _bind(
|
|
106
|
+
"gbp_node_send_control",
|
|
107
|
+
GbpBuffer,
|
|
108
|
+
[c_int32, c_int32, c_uint32, c_uint16, c_uint32, c_uint32, c_void_p, c_size_t],
|
|
109
|
+
)
|
|
110
|
+
gbp_node_on_wire = _bind(
|
|
111
|
+
"gbp_node_on_wire", c_void_p, [c_int32, c_int32, c_void_p, c_size_t]
|
|
112
|
+
)
|
|
113
|
+
gbp_node_drain_events = _bind("gbp_node_drain_events", c_void_p, [c_int32])
|
|
114
|
+
|
|
115
|
+
gtp_client_create = _bind("gtp_client_create", c_int32, [])
|
|
116
|
+
gtp_client_destroy = _bind("gtp_client_destroy", None, [c_int32])
|
|
117
|
+
gtp_client_reset = _bind("gtp_client_reset", None, [c_int32])
|
|
118
|
+
gtp_client_send = _bind(
|
|
119
|
+
"gtp_client_send",
|
|
120
|
+
GbpBuffer,
|
|
121
|
+
[c_int32, c_int32, c_int32, c_uint32, c_uint64, c_void_p, c_size_t],
|
|
122
|
+
)
|
|
123
|
+
gtp_client_accept = _bind("gtp_client_accept", c_void_p, [c_int32, c_void_p, c_size_t])
|
|
124
|
+
|
|
125
|
+
gap_client_create = _bind("gap_client_create", c_int32, [])
|
|
126
|
+
gap_client_destroy = _bind("gap_client_destroy", None, [c_int32])
|
|
127
|
+
gap_client_reset = _bind("gap_client_reset", None, [c_int32])
|
|
128
|
+
gap_client_send = _bind(
|
|
129
|
+
"gap_client_send",
|
|
130
|
+
GbpBuffer,
|
|
131
|
+
[c_int32, c_int32, c_int32, c_uint32, c_uint32, c_uint64, c_void_p, c_size_t],
|
|
132
|
+
)
|
|
133
|
+
gap_client_accept = _bind(
|
|
134
|
+
"gap_client_accept", c_void_p, [c_int32, c_uint64, c_void_p, c_size_t]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
gsp_client_create = _bind("gsp_client_create", c_int32, [])
|
|
138
|
+
gsp_client_destroy = _bind("gsp_client_destroy", None, [c_int32])
|
|
139
|
+
gsp_client_reset = _bind("gsp_client_reset", None, [c_int32])
|
|
140
|
+
gsp_client_send = _bind(
|
|
141
|
+
"gsp_client_send",
|
|
142
|
+
GbpBuffer,
|
|
143
|
+
[c_int32, c_int32, c_int32, c_uint32, c_uint32, c_uint32, c_uint32],
|
|
144
|
+
)
|
|
145
|
+
gsp_client_accept = _bind("gsp_client_accept", c_void_p, [c_int32, c_void_p, c_size_t])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def take_buffer(buf: GbpBuffer) -> bytes:
|
|
149
|
+
"""Copy a returned :class:`GbpBuffer` into a ``bytes`` object and free it."""
|
|
150
|
+
if not buf.ptr or buf.len == 0:
|
|
151
|
+
gbp_buffer_free(buf)
|
|
152
|
+
return b""
|
|
153
|
+
raw = ctypes.string_at(buf.ptr, buf.len)
|
|
154
|
+
gbp_buffer_free(buf)
|
|
155
|
+
return raw
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def take_cstring(ptr: int) -> str:
|
|
159
|
+
"""Copy a returned C string into a ``str`` and free it."""
|
|
160
|
+
if not ptr:
|
|
161
|
+
return ""
|
|
162
|
+
try:
|
|
163
|
+
raw = ctypes.cast(ptr, c_char_p).value
|
|
164
|
+
return raw.decode("utf-8") if raw is not None else ""
|
|
165
|
+
finally:
|
|
166
|
+
gbp_string_free(ptr)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def last_error() -> str:
|
|
170
|
+
"""Return the text of the last FFI error on this thread."""
|
|
171
|
+
return take_cstring(_gbp_last_error())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def version() -> str:
|
|
175
|
+
"""Return the native library version string."""
|
|
176
|
+
return take_cstring(_gbp_version())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def call_with_bytes(data: bytes, fn):
|
|
180
|
+
"""Invoke ``fn(ptr, length)`` with a temporary ctypes buffer.
|
|
181
|
+
|
|
182
|
+
The buffer's lifetime is bounded to this call; ``fn`` MUST NOT retain
|
|
183
|
+
``ptr`` past its return.
|
|
184
|
+
"""
|
|
185
|
+
length = len(data)
|
|
186
|
+
if length == 0:
|
|
187
|
+
return fn(None, 0)
|
|
188
|
+
arr = (c_uint8 * length).from_buffer_copy(data)
|
|
189
|
+
return fn(ctypes.cast(arr, c_void_p), length)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Group Audio Protocol client wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from . import _native as _n
|
|
10
|
+
from .gbp_node import GroupNode, OutboundFrame, _unpack
|
|
11
|
+
from .mls_context import MlsContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GapAcceptResult:
|
|
16
|
+
"""Outcome of :meth:`GapClient.accept`."""
|
|
17
|
+
|
|
18
|
+
status: str
|
|
19
|
+
source: Optional[int] = None
|
|
20
|
+
seq: Optional[int] = None
|
|
21
|
+
bytes_: Optional[int] = None
|
|
22
|
+
reason: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _parse(cls, json_text: str) -> "GapAcceptResult":
|
|
26
|
+
d = json.loads(json_text) if json_text else {}
|
|
27
|
+
return cls(
|
|
28
|
+
status=d.get("status", "?"),
|
|
29
|
+
source=d.get("source"),
|
|
30
|
+
seq=d.get("seq"),
|
|
31
|
+
bytes_=d.get("bytes"),
|
|
32
|
+
reason=d.get("reason"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GapClient:
|
|
37
|
+
"""Group Audio Protocol client.
|
|
38
|
+
|
|
39
|
+
Maintains a per-source ``rtp_sequence`` window and validates ``key_phase``
|
|
40
|
+
against the current group epoch.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("_handle",)
|
|
44
|
+
|
|
45
|
+
def __init__(self, handle: int) -> None:
|
|
46
|
+
self._handle = handle
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def create(cls) -> "GapClient":
|
|
50
|
+
"""Create a fresh GAP client."""
|
|
51
|
+
h = _n.gap_client_create()
|
|
52
|
+
if h <= 0:
|
|
53
|
+
raise OSError("gap_client_create")
|
|
54
|
+
return cls(h)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def handle(self) -> int:
|
|
58
|
+
"""Native handle (i32)."""
|
|
59
|
+
return self._handle
|
|
60
|
+
|
|
61
|
+
def send(
|
|
62
|
+
self,
|
|
63
|
+
node: GroupNode,
|
|
64
|
+
mls: MlsContext,
|
|
65
|
+
target: int,
|
|
66
|
+
media_source_id: int,
|
|
67
|
+
rtp_timestamp: int,
|
|
68
|
+
opus: bytes,
|
|
69
|
+
) -> OutboundFrame:
|
|
70
|
+
"""Send an Opus audio frame."""
|
|
71
|
+
def call(ptr, length):
|
|
72
|
+
return _n.gap_client_send(
|
|
73
|
+
self._handle, node.handle, mls.handle, target,
|
|
74
|
+
media_source_id, rtp_timestamp, ptr, length,
|
|
75
|
+
)
|
|
76
|
+
buf = _n.call_with_bytes(opus, call)
|
|
77
|
+
return _unpack(buf, "gap_client_send")
|
|
78
|
+
|
|
79
|
+
def accept(self, plaintext: bytes, current_epoch: int) -> GapAcceptResult:
|
|
80
|
+
"""Accept a plaintext payload delivered by the GBP layer."""
|
|
81
|
+
def call(ptr, length):
|
|
82
|
+
return _n.gap_client_accept(self._handle, current_epoch, ptr, length)
|
|
83
|
+
ptr = _n.call_with_bytes(plaintext, call)
|
|
84
|
+
return GapAcceptResult._parse(_n.take_cstring(ptr))
|
|
85
|
+
|
|
86
|
+
def reset(self) -> None:
|
|
87
|
+
"""Clear the replay window. Intended for use after an epoch change."""
|
|
88
|
+
_n.gap_client_reset(self._handle)
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
"""Release the native handle. Idempotent."""
|
|
92
|
+
if self._handle:
|
|
93
|
+
_n.gap_client_destroy(self._handle)
|
|
94
|
+
self._handle = 0
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> "GapClient":
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def __del__(self) -> None:
|
|
103
|
+
try:
|
|
104
|
+
self.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""GBP-layer group node wrapper (the IP-like base)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import ctypes
|
|
7
|
+
import enum
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from . import _native as _n
|
|
13
|
+
from .mls_context import MlsContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StreamType(enum.IntEnum):
|
|
17
|
+
"""Stream class."""
|
|
18
|
+
|
|
19
|
+
CONTROL = 0
|
|
20
|
+
AUDIO = 1
|
|
21
|
+
TEXT = 2
|
|
22
|
+
SIGNAL = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NodeState(enum.IntEnum):
|
|
26
|
+
"""Node FSM state."""
|
|
27
|
+
|
|
28
|
+
IDLE = 0
|
|
29
|
+
CONNECTING = 1
|
|
30
|
+
ESTABLISHING_GROUP = 2
|
|
31
|
+
ACTIVE = 3
|
|
32
|
+
RESYNCING = 4
|
|
33
|
+
FAILED = 5
|
|
34
|
+
CLOSED = 6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ControlOpcode(enum.IntEnum):
|
|
38
|
+
"""Control plane opcode."""
|
|
39
|
+
|
|
40
|
+
PREPARE_TRANSITION = 0x0001
|
|
41
|
+
READY_FOR_TRANSITION = 0x0002
|
|
42
|
+
EXECUTE_TRANSITION = 0x0003
|
|
43
|
+
ABORT_TRANSITION = 0x0004
|
|
44
|
+
GROUP_STATE_DIGEST_REQUEST = 0x0005
|
|
45
|
+
GROUP_STATE_DIGEST_RESPONSE = 0x0006
|
|
46
|
+
REPORT_INVALID_COMMIT = 0x0007
|
|
47
|
+
CAPABILITIES_ADVERTISE = 0x0008
|
|
48
|
+
ACK = 0x0009
|
|
49
|
+
NACK = 0x000A
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class OutboundFrame:
|
|
54
|
+
"""Wire frame produced by the native library."""
|
|
55
|
+
|
|
56
|
+
target: int
|
|
57
|
+
wire: bytes
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class NodeEvent:
|
|
62
|
+
"""Event surfaced by the GBP layer.
|
|
63
|
+
|
|
64
|
+
``kind`` tells which fields are populated; common kinds are:
|
|
65
|
+
|
|
66
|
+
* ``state_changed`` — populates ``from_state`` and ``to_state``;
|
|
67
|
+
* ``payload_received`` — populates ``stream_type``, ``stream_id``,
|
|
68
|
+
``sequence_no``, ``flags`` and ``plaintext``;
|
|
69
|
+
* ``control`` — populates ``sender``, ``opcode`` and ``transition_id``;
|
|
70
|
+
* ``error`` — populates ``code``, ``code_hex``, ``class_``, ``retryable``,
|
|
71
|
+
``fatal`` and ``reason``;
|
|
72
|
+
* ``epoch_advanced`` — populates ``epoch`` and ``transition_id``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
kind: str
|
|
76
|
+
from_state: Optional[str] = None
|
|
77
|
+
to_state: Optional[str] = None
|
|
78
|
+
stream_type_name: Optional[str] = None
|
|
79
|
+
stream_type: Optional[StreamType] = None
|
|
80
|
+
stream_id: Optional[int] = None
|
|
81
|
+
sequence_no: Optional[int] = None
|
|
82
|
+
flags: Optional[int] = None
|
|
83
|
+
plaintext: Optional[bytes] = None
|
|
84
|
+
sender: Optional[int] = None
|
|
85
|
+
opcode: Optional[str] = None
|
|
86
|
+
opcode_code: Optional[int] = None
|
|
87
|
+
transition_id: Optional[int] = None
|
|
88
|
+
code: Optional[int] = None
|
|
89
|
+
code_hex: Optional[str] = None
|
|
90
|
+
class_: Optional[int] = None
|
|
91
|
+
retryable: Optional[bool] = None
|
|
92
|
+
fatal: Optional[bool] = None
|
|
93
|
+
reason: Optional[str] = None
|
|
94
|
+
epoch: Optional[int] = None
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _from_dict(cls, d: dict) -> "NodeEvent":
|
|
98
|
+
st_code = d.get("stream_type_code")
|
|
99
|
+
plaintext_b64 = d.get("plaintext_b64")
|
|
100
|
+
return cls(
|
|
101
|
+
kind=d["kind"],
|
|
102
|
+
from_state=d.get("from"),
|
|
103
|
+
to_state=d.get("to"),
|
|
104
|
+
stream_type_name=d.get("stream_type"),
|
|
105
|
+
stream_type=StreamType(st_code) if st_code is not None else None,
|
|
106
|
+
stream_id=d.get("stream_id"),
|
|
107
|
+
sequence_no=d.get("sequence_no"),
|
|
108
|
+
flags=d.get("flags"),
|
|
109
|
+
plaintext=base64.b64decode(plaintext_b64) if plaintext_b64 else None,
|
|
110
|
+
sender=d.get("from") if isinstance(d.get("from"), int) else None,
|
|
111
|
+
opcode=d.get("opcode"),
|
|
112
|
+
opcode_code=d.get("opcode_code"),
|
|
113
|
+
transition_id=d.get("transition_id"),
|
|
114
|
+
code=d.get("code"),
|
|
115
|
+
code_hex=d.get("code_hex"),
|
|
116
|
+
class_=d.get("class"),
|
|
117
|
+
retryable=d.get("retryable"),
|
|
118
|
+
fatal=d.get("fatal"),
|
|
119
|
+
reason=d.get("reason"),
|
|
120
|
+
epoch=d.get("epoch"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_events(json_text: str) -> List[NodeEvent]:
|
|
125
|
+
if not json_text or json_text == "[]":
|
|
126
|
+
return []
|
|
127
|
+
return [NodeEvent._from_dict(d) for d in json.loads(json_text)]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _unpack(buf: _n.GbpBuffer, what: str) -> OutboundFrame:
|
|
131
|
+
raw = _n.take_buffer(buf)
|
|
132
|
+
if not raw:
|
|
133
|
+
raise OSError(f"{what}: {_n.last_error()}")
|
|
134
|
+
if len(raw) < 4:
|
|
135
|
+
raise OSError(f"{what}: buffer too short")
|
|
136
|
+
target = int.from_bytes(raw[:4], byteorder="little", signed=False)
|
|
137
|
+
return OutboundFrame(target=target, wire=raw[4:])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class GroupNode:
|
|
141
|
+
"""GBP-layer group node.
|
|
142
|
+
|
|
143
|
+
Owns the framing, AEAD, replay window, FSM and control plane.
|
|
144
|
+
Sub-protocol semantics live in :class:`gbp_stack.GtpClient`,
|
|
145
|
+
:class:`gbp_stack.GapClient` and :class:`gbp_stack.GspClient`.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
__slots__ = ("_handle", "member_id", "_group_id")
|
|
149
|
+
|
|
150
|
+
def __init__(self, handle: int, member_id: int, group_id: bytes) -> None:
|
|
151
|
+
self._handle = handle
|
|
152
|
+
self.member_id = member_id
|
|
153
|
+
self._group_id = bytes(group_id)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def create(cls, member_id: int, group_id: bytes) -> "GroupNode":
|
|
157
|
+
"""Create a node bound to ``group_id`` (which MUST be 16 bytes)."""
|
|
158
|
+
if len(group_id) != 16:
|
|
159
|
+
raise ValueError("group_id must be 16 bytes")
|
|
160
|
+
gid = (ctypes.c_uint8 * 16).from_buffer_copy(group_id)
|
|
161
|
+
handle = _n.gbp_node_create(member_id, ctypes.cast(gid, ctypes.c_void_p))
|
|
162
|
+
if handle <= 0:
|
|
163
|
+
raise OSError(f"node_create: {_n.last_error()}")
|
|
164
|
+
return cls(handle, member_id, group_id)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def handle(self) -> int:
|
|
168
|
+
"""Native handle (i32)."""
|
|
169
|
+
return self._handle
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def state(self) -> NodeState:
|
|
173
|
+
"""Current FSM state."""
|
|
174
|
+
return NodeState(_n.gbp_node_state(self._handle))
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def epoch(self) -> int:
|
|
178
|
+
"""Current node epoch."""
|
|
179
|
+
return int(_n.gbp_node_epoch(self._handle))
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def last_transition_id(self) -> int:
|
|
183
|
+
"""Last applied ``transition_id``."""
|
|
184
|
+
return int(_n.gbp_node_last_transition_id(self._handle))
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def group_id(self) -> bytes:
|
|
188
|
+
"""16-byte group identifier."""
|
|
189
|
+
return self._group_id
|
|
190
|
+
|
|
191
|
+
def bootstrap_as_creator(self, epoch: int) -> None:
|
|
192
|
+
"""Drive the node from ``IDLE`` to ``ACTIVE`` as a creator."""
|
|
193
|
+
if not _n.gbp_node_bootstrap_creator(self._handle, epoch):
|
|
194
|
+
raise OSError(_n.last_error())
|
|
195
|
+
|
|
196
|
+
def bootstrap_as_joiner(self, epoch: int) -> None:
|
|
197
|
+
"""Drive the node from ``IDLE`` to ``ACTIVE`` as a joiner."""
|
|
198
|
+
if not _n.gbp_node_bootstrap_joiner(self._handle, epoch):
|
|
199
|
+
raise OSError(_n.last_error())
|
|
200
|
+
|
|
201
|
+
def set_epoch_for_testing(self, epoch: int) -> None:
|
|
202
|
+
"""Forcibly override ``current_epoch`` (intended for tests of late peers)."""
|
|
203
|
+
if not _n.gbp_node_set_epoch(self._handle, epoch):
|
|
204
|
+
raise OSError(_n.last_error())
|
|
205
|
+
|
|
206
|
+
def apply_transition(self, transition_id: int) -> None:
|
|
207
|
+
"""Apply an epoch transition locally."""
|
|
208
|
+
if not _n.gbp_node_apply_transition(self._handle, transition_id):
|
|
209
|
+
raise OSError(_n.last_error())
|
|
210
|
+
|
|
211
|
+
def send_control(
|
|
212
|
+
self,
|
|
213
|
+
mls: MlsContext,
|
|
214
|
+
target: int,
|
|
215
|
+
opcode: ControlOpcode,
|
|
216
|
+
transition_id: int,
|
|
217
|
+
request_id: int,
|
|
218
|
+
args: bytes = b"",
|
|
219
|
+
) -> OutboundFrame:
|
|
220
|
+
"""Send a control plane message on Stream 0."""
|
|
221
|
+
def call(ptr, length):
|
|
222
|
+
return _n.gbp_node_send_control(
|
|
223
|
+
self._handle, mls.handle, target, int(opcode),
|
|
224
|
+
transition_id, request_id, ptr, length,
|
|
225
|
+
)
|
|
226
|
+
buf = _n.call_with_bytes(args, call)
|
|
227
|
+
return _unpack(buf, "send_control")
|
|
228
|
+
|
|
229
|
+
def on_wire(self, mls: MlsContext, wire: bytes) -> List[NodeEvent]:
|
|
230
|
+
"""Feed wire bytes to the node and return the resulting events."""
|
|
231
|
+
def call(ptr, length):
|
|
232
|
+
return _n.gbp_node_on_wire(self._handle, mls.handle, ptr, length)
|
|
233
|
+
ptr = _n.call_with_bytes(wire, call)
|
|
234
|
+
return _parse_events(_n.take_cstring(ptr))
|
|
235
|
+
|
|
236
|
+
def drain_events(self) -> List[NodeEvent]:
|
|
237
|
+
"""Drain queued events without consuming any wire bytes."""
|
|
238
|
+
return _parse_events(_n.take_cstring(_n.gbp_node_drain_events(self._handle)))
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
"""Release the native handle. Idempotent."""
|
|
242
|
+
if self._handle:
|
|
243
|
+
_n.gbp_node_destroy(self._handle)
|
|
244
|
+
self._handle = 0
|
|
245
|
+
|
|
246
|
+
def __enter__(self) -> "GroupNode":
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
250
|
+
self.close()
|
|
251
|
+
|
|
252
|
+
def __del__(self) -> None:
|
|
253
|
+
try:
|
|
254
|
+
self.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Group Signaling Protocol client wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import _native as _n
|
|
11
|
+
from .gbp_node import GroupNode, OutboundFrame, _unpack
|
|
12
|
+
from .mls_context import MlsContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SignalType(enum.IntEnum):
|
|
16
|
+
"""Signal opcode registry."""
|
|
17
|
+
|
|
18
|
+
JOIN = 100
|
|
19
|
+
LEAVE = 101
|
|
20
|
+
ROLE_CHANGE = 102
|
|
21
|
+
MUTE = 200
|
|
22
|
+
UNMUTE = 201
|
|
23
|
+
STREAM_START = 300
|
|
24
|
+
STREAM_STOP = 301
|
|
25
|
+
CODEC_UPDATE = 400
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GspAcceptResult:
|
|
30
|
+
"""Outcome of :meth:`GspClient.accept`."""
|
|
31
|
+
|
|
32
|
+
status: str
|
|
33
|
+
signal: Optional[str] = None
|
|
34
|
+
signal_code: Optional[SignalType] = None
|
|
35
|
+
sender: Optional[int] = None
|
|
36
|
+
role_claim: Optional[int] = None
|
|
37
|
+
request_id: Optional[int] = None
|
|
38
|
+
reason: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _parse(cls, json_text: str) -> "GspAcceptResult":
|
|
42
|
+
d = json.loads(json_text) if json_text else {}
|
|
43
|
+
sc = d.get("signal_code")
|
|
44
|
+
return cls(
|
|
45
|
+
status=d.get("status", "?"),
|
|
46
|
+
signal=d.get("signal"),
|
|
47
|
+
signal_code=SignalType(sc) if sc is not None else None,
|
|
48
|
+
sender=d.get("sender"),
|
|
49
|
+
role_claim=d.get("role_claim"),
|
|
50
|
+
request_id=d.get("request_id"),
|
|
51
|
+
reason=d.get("reason"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GspClient:
|
|
56
|
+
"""Group Signaling Protocol client.
|
|
57
|
+
|
|
58
|
+
Tracks ``request_id`` deduplication and maintains the local membership
|
|
59
|
+
and mute-list state.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_handle",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, handle: int) -> None:
|
|
65
|
+
self._handle = handle
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def create(cls) -> "GspClient":
|
|
69
|
+
"""Create a fresh GSP client."""
|
|
70
|
+
h = _n.gsp_client_create()
|
|
71
|
+
if h <= 0:
|
|
72
|
+
raise OSError("gsp_client_create")
|
|
73
|
+
return cls(h)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def handle(self) -> int:
|
|
77
|
+
"""Native handle (i32)."""
|
|
78
|
+
return self._handle
|
|
79
|
+
|
|
80
|
+
def send(
|
|
81
|
+
self,
|
|
82
|
+
node: GroupNode,
|
|
83
|
+
mls: MlsContext,
|
|
84
|
+
target: int,
|
|
85
|
+
signal: SignalType,
|
|
86
|
+
role_claim: int,
|
|
87
|
+
request_id: int,
|
|
88
|
+
) -> OutboundFrame:
|
|
89
|
+
"""Send a signal."""
|
|
90
|
+
buf = _n.gsp_client_send(
|
|
91
|
+
self._handle, node.handle, mls.handle, target,
|
|
92
|
+
int(signal), role_claim, request_id,
|
|
93
|
+
)
|
|
94
|
+
return _unpack(buf, "gsp_client_send")
|
|
95
|
+
|
|
96
|
+
def accept(self, plaintext: bytes) -> GspAcceptResult:
|
|
97
|
+
"""Accept a plaintext payload delivered by the GBP layer."""
|
|
98
|
+
def call(ptr, length):
|
|
99
|
+
return _n.gsp_client_accept(self._handle, ptr, length)
|
|
100
|
+
ptr = _n.call_with_bytes(plaintext, call)
|
|
101
|
+
return GspAcceptResult._parse(_n.take_cstring(ptr))
|
|
102
|
+
|
|
103
|
+
def reset(self) -> None:
|
|
104
|
+
"""Clear the request-id deduplication set. Intended for use after an epoch change."""
|
|
105
|
+
_n.gsp_client_reset(self._handle)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Release the native handle. Idempotent."""
|
|
109
|
+
if self._handle:
|
|
110
|
+
_n.gsp_client_destroy(self._handle)
|
|
111
|
+
self._handle = 0
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "GspClient":
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
def __del__(self) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self.close()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Group Text Protocol client wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from . import _native as _n
|
|
10
|
+
from .gbp_node import GroupNode, OutboundFrame, _unpack
|
|
11
|
+
from .mls_context import MlsContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GtpAcceptResult:
|
|
16
|
+
"""Outcome of :meth:`GtpClient.accept`."""
|
|
17
|
+
|
|
18
|
+
status: str
|
|
19
|
+
sender: Optional[int] = None
|
|
20
|
+
message_id: Optional[int] = None
|
|
21
|
+
text: Optional[str] = None
|
|
22
|
+
reason: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _parse(cls, json_text: str) -> "GtpAcceptResult":
|
|
26
|
+
d = json.loads(json_text) if json_text else {}
|
|
27
|
+
return cls(
|
|
28
|
+
status=d.get("status", "?"),
|
|
29
|
+
sender=d.get("sender"),
|
|
30
|
+
message_id=d.get("message_id"),
|
|
31
|
+
text=d.get("text"),
|
|
32
|
+
reason=d.get("reason"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GtpClient:
|
|
37
|
+
"""Group Text Protocol client.
|
|
38
|
+
|
|
39
|
+
Tracks idempotency by ``(sender_id, message_id)``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("_handle",)
|
|
43
|
+
|
|
44
|
+
def __init__(self, handle: int) -> None:
|
|
45
|
+
self._handle = handle
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def create(cls) -> "GtpClient":
|
|
49
|
+
"""Create a fresh GTP client."""
|
|
50
|
+
h = _n.gtp_client_create()
|
|
51
|
+
if h <= 0:
|
|
52
|
+
raise OSError("gtp_client_create")
|
|
53
|
+
return cls(h)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def handle(self) -> int:
|
|
57
|
+
"""Native handle (i32)."""
|
|
58
|
+
return self._handle
|
|
59
|
+
|
|
60
|
+
def send(
|
|
61
|
+
self,
|
|
62
|
+
node: GroupNode,
|
|
63
|
+
mls: MlsContext,
|
|
64
|
+
target: int,
|
|
65
|
+
message_id: int,
|
|
66
|
+
text: str,
|
|
67
|
+
) -> OutboundFrame:
|
|
68
|
+
"""Send a text message."""
|
|
69
|
+
data = text.encode("utf-8")
|
|
70
|
+
def call(ptr, length):
|
|
71
|
+
return _n.gtp_client_send(
|
|
72
|
+
self._handle, node.handle, mls.handle, target, message_id, ptr, length
|
|
73
|
+
)
|
|
74
|
+
buf = _n.call_with_bytes(data, call)
|
|
75
|
+
return _unpack(buf, "gtp_client_send")
|
|
76
|
+
|
|
77
|
+
def accept(self, plaintext: bytes) -> GtpAcceptResult:
|
|
78
|
+
"""Accept a plaintext payload delivered by the GBP layer."""
|
|
79
|
+
def call(ptr, length):
|
|
80
|
+
return _n.gtp_client_accept(self._handle, ptr, length)
|
|
81
|
+
ptr = _n.call_with_bytes(plaintext, call)
|
|
82
|
+
return GtpAcceptResult._parse(_n.take_cstring(ptr))
|
|
83
|
+
|
|
84
|
+
def reset(self) -> None:
|
|
85
|
+
"""Clear the idempotency state. Intended for use after an epoch change."""
|
|
86
|
+
_n.gtp_client_reset(self._handle)
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
"""Release the native handle. Idempotent."""
|
|
90
|
+
if self._handle:
|
|
91
|
+
_n.gtp_client_destroy(self._handle)
|
|
92
|
+
self._handle = 0
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> "GtpClient":
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
98
|
+
self.close()
|
|
99
|
+
|
|
100
|
+
def __del__(self) -> None:
|
|
101
|
+
try:
|
|
102
|
+
self.close()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""MLS (RFC 9420) context wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
|
|
7
|
+
from . import _native as _n
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MlsContext:
|
|
11
|
+
"""Managed wrapper around an MLS context owned by the native library.
|
|
12
|
+
|
|
13
|
+
Owns a single-member group plus a published ``KeyPackage`` that can be
|
|
14
|
+
used to invite this member into another group. Always use as a context
|
|
15
|
+
manager so the native handle is released.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("_handle", "identity")
|
|
19
|
+
|
|
20
|
+
def __init__(self, handle: int, identity: str) -> None:
|
|
21
|
+
self._handle = handle
|
|
22
|
+
self.identity = identity
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def create(cls, identity: str) -> "MlsContext":
|
|
26
|
+
"""Create a fresh MLS context."""
|
|
27
|
+
data = identity.encode("utf-8")
|
|
28
|
+
handle = _n.call_with_bytes(data, _n.gbp_mls_create)
|
|
29
|
+
if handle <= 0:
|
|
30
|
+
raise OSError(f"gbp_mls_create: {_n.last_error()}")
|
|
31
|
+
return cls(handle, identity)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def handle(self) -> int:
|
|
35
|
+
"""Native handle (i32)."""
|
|
36
|
+
return self._handle
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def epoch(self) -> int:
|
|
40
|
+
"""Current group epoch."""
|
|
41
|
+
return int(_n.gbp_mls_epoch(self._handle))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def group_id(self) -> bytes:
|
|
45
|
+
"""16-byte group identifier."""
|
|
46
|
+
buf = (ctypes.c_uint8 * 16)()
|
|
47
|
+
if not _n.gbp_mls_group_id(self._handle, ctypes.cast(buf, ctypes.c_void_p)):
|
|
48
|
+
raise OSError(f"group_id: {_n.last_error()}")
|
|
49
|
+
return bytes(buf)
|
|
50
|
+
|
|
51
|
+
def export_key_package(self) -> bytes:
|
|
52
|
+
"""Export this member's TLS-serialised KeyPackage."""
|
|
53
|
+
buf = _n.gbp_mls_export_key_package(self._handle)
|
|
54
|
+
out = _n.take_buffer(buf)
|
|
55
|
+
if not out:
|
|
56
|
+
raise OSError(f"export_key_package: {_n.last_error()}")
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
def invite(self, key_package: bytes) -> bytes:
|
|
60
|
+
"""Invite ``key_package`` into the local group; returns the Welcome."""
|
|
61
|
+
def call(ptr, length):
|
|
62
|
+
return _n.gbp_mls_invite(self._handle, ptr, length)
|
|
63
|
+
buf = _n.call_with_bytes(key_package, call)
|
|
64
|
+
out = _n.take_buffer(buf)
|
|
65
|
+
if not out:
|
|
66
|
+
raise OSError(f"invite: {_n.last_error()}")
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
def accept_welcome(self, welcome: bytes) -> None:
|
|
70
|
+
"""Replace the local group with the one described by ``welcome``."""
|
|
71
|
+
def call(ptr, length):
|
|
72
|
+
return _n.gbp_mls_accept_welcome(self._handle, ptr, length)
|
|
73
|
+
ok = _n.call_with_bytes(welcome, call)
|
|
74
|
+
if not ok:
|
|
75
|
+
raise OSError(f"accept_welcome: {_n.last_error()}")
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Release the native handle. Idempotent."""
|
|
79
|
+
if self._handle:
|
|
80
|
+
_n.gbp_mls_destroy(self._handle)
|
|
81
|
+
self._handle = 0
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> "MlsContext":
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
def __del__(self) -> None:
|
|
90
|
+
try:
|
|
91
|
+
self.close()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gbp-stack
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python bindings for the Group Protocol Stack: a layered, end-to-end encrypted group-messaging protocol family built on top of MLS (RFC 9420).
|
|
5
|
+
Author: Group Protocol Stack contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/F000NKKK/Group-Protocol-Stack
|
|
8
|
+
Project-URL: Repository, https://github.com/F000NKKK/Group-Protocol-Stack
|
|
9
|
+
Project-URL: Documentation, https://github.com/F000NKKK/Group-Protocol-Stack#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/F000NKKK/Group-Protocol-Stack/issues
|
|
11
|
+
Keywords: mls,rfc9420,e2ee,group-messaging,chat,voice,signaling,cryptography,ffi
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# gbp-stack — Python bindings for the Group Protocol Stack
|
|
26
|
+
|
|
27
|
+
[](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE)
|
|
28
|
+
|
|
29
|
+
Python bindings for the [Group Protocol Stack](https://github.com/F000NKKK/Group-Protocol-Stack):
|
|
30
|
+
a layered, end-to-end encrypted group-messaging protocol family built on top
|
|
31
|
+
of [MLS (RFC 9420)](https://www.rfc-editor.org/rfc/rfc9420).
|
|
32
|
+
|
|
33
|
+
This package wraps the native `gbp_stack` shared library through `ctypes`.
|
|
34
|
+
The wheel for each supported platform bundles the appropriate native binary
|
|
35
|
+
under `gbp_stack/_native/<rid>/`.
|
|
36
|
+
|
|
37
|
+
## Layers
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌── application ──────────────────────────────────────────────────────┐
|
|
41
|
+
│ GtpClient · GapClient · GspClient (TCP / UDP / SCTP-like) │
|
|
42
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
43
|
+
│ GroupNode (GBP — IP-like base) │
|
|
44
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
45
|
+
│ MlsContext (RFC 9420) │
|
|
46
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from gbp_stack import MlsContext, GroupNode, GtpClient
|
|
53
|
+
|
|
54
|
+
with MlsContext.create("alice") as alice_mls, \
|
|
55
|
+
MlsContext.create("bob") as bob_mls:
|
|
56
|
+
|
|
57
|
+
bob_kp = bob_mls.export_key_package()
|
|
58
|
+
welcome = alice_mls.invite(bob_kp)
|
|
59
|
+
bob_mls.accept_welcome(welcome)
|
|
60
|
+
|
|
61
|
+
group_id = alice_mls.group_id
|
|
62
|
+
with GroupNode.create(member_id=1, group_id=group_id) as alice, \
|
|
63
|
+
GroupNode.create(member_id=2, group_id=group_id) as bob, \
|
|
64
|
+
GtpClient.create() as gtp_alice, \
|
|
65
|
+
GtpClient.create() as gtp_bob:
|
|
66
|
+
|
|
67
|
+
alice.bootstrap_as_creator(alice_mls.epoch)
|
|
68
|
+
bob.bootstrap_as_joiner(bob_mls.epoch)
|
|
69
|
+
|
|
70
|
+
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
71
|
+
message_id=0xCAFE_F00D, text="hello")
|
|
72
|
+
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
73
|
+
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
74
|
+
print(gtp_bob.accept(ev.plaintext).text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
Licensed under [Apache License, Version 2.0](https://github.com/F000NKKK/Group-Protocol-Stack/blob/main/LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
gbp_stack/__init__.py
|
|
5
|
+
gbp_stack/_native.py
|
|
6
|
+
gbp_stack/gap_client.py
|
|
7
|
+
gbp_stack/gbp_node.py
|
|
8
|
+
gbp_stack/gsp_client.py
|
|
9
|
+
gbp_stack/gtp_client.py
|
|
10
|
+
gbp_stack/mls_context.py
|
|
11
|
+
gbp_stack.egg-info/PKG-INFO
|
|
12
|
+
gbp_stack.egg-info/SOURCES.txt
|
|
13
|
+
gbp_stack.egg-info/dependency_links.txt
|
|
14
|
+
gbp_stack.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gbp_stack
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gbp-stack"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python bindings for the Group Protocol Stack: a layered, end-to-end encrypted group-messaging protocol family built on top of MLS (RFC 9420)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Group Protocol Stack contributors" }]
|
|
13
|
+
keywords = ["mls", "rfc9420", "e2ee", "group-messaging", "chat", "voice", "signaling", "cryptography", "ffi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
"Topic :: System :: Networking",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/F000NKKK/Group-Protocol-Stack"
|
|
29
|
+
Repository = "https://github.com/F000NKKK/Group-Protocol-Stack"
|
|
30
|
+
Documentation = "https://github.com/F000NKKK/Group-Protocol-Stack#readme"
|
|
31
|
+
Issues = "https://github.com/F000NKKK/Group-Protocol-Stack/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
include-package-data = true
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["gbp_stack*"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
"gbp_stack" = [
|
|
41
|
+
"_native/win-x64/*.dll",
|
|
42
|
+
"_native/linux-x64/*.so",
|
|
43
|
+
"_native/osx-x64/*.dylib",
|
|
44
|
+
"_native/osx-arm64/*.dylib",
|
|
45
|
+
]
|
gbp_stack-0.2.0/setup.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Setup shim that marks the wheel as platform-specific.
|
|
2
|
+
|
|
3
|
+
The package ships a native shared library, so the produced wheel must NOT be
|
|
4
|
+
tagged as ``py3-none-any``; it must carry a real platform tag. We do this by
|
|
5
|
+
overriding ``bdist_wheel.root_is_pure``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from setuptools import setup
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from setuptools.command.bdist_wheel import bdist_wheel # type: ignore
|
|
12
|
+
except ImportError: # older setuptools
|
|
13
|
+
from wheel.bdist_wheel import bdist_wheel # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _bdist_wheel(bdist_wheel): # type: ignore[misc, valid-type]
|
|
17
|
+
def finalize_options(self) -> None:
|
|
18
|
+
super().finalize_options()
|
|
19
|
+
self.root_is_pure = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
setup(cmdclass={"bdist_wheel": _bdist_wheel})
|