gbp-stack 1.2.3__tar.gz → 1.4.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-1.2.3 → gbp_stack-1.4.0}/PKG-INFO +76 -5
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/README.md +75 -4
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/__init__.py +4 -2
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/_native.py +24 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/gbp_node.py +53 -1
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/gsp_client.py +13 -5
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack.egg-info/PKG-INFO +76 -5
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack.egg-info/SOURCES.txt +2 -1
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/pyproject.toml +1 -1
- gbp_stack-1.4.0/tests/test_integration.py +1054 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/capabilities.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/gap_client.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/gtp_client.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/history.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/jitter.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/mls_context.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/roles.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack/sframe_session.py +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack.egg-info/dependency_links.txt +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/gbp_stack.egg-info/top_level.txt +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/setup.cfg +0 -0
- {gbp_stack-1.2.3 → gbp_stack-1.4.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gbp-stack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
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
5
|
Author: Group Protocol Stack contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -61,11 +61,23 @@ Beyond the protocol clients, the package ships ready-made helpers:
|
|
|
61
61
|
* `SFrameSession` + `SFrameEncryptor` — SFrame (draft-ietf-sframe-enc) E2EE
|
|
62
62
|
for GAP audio frames; per-sender AES-GCM keys derived from MLS exporter,
|
|
63
63
|
1024-entry sliding-window replay protection.
|
|
64
|
+
* `encode_gbp_frame` — low-level helper to construct a raw CBOR GBP frame.
|
|
65
|
+
* `lookup_error` — return the CBOR `ErrorObject` for a known error code.
|
|
66
|
+
|
|
67
|
+
### Coordinator events
|
|
68
|
+
|
|
69
|
+
`NodeEvent` surfaces three new event kinds for coordinator election:
|
|
70
|
+
|
|
71
|
+
| `kind` | Extra fields | Meaning |
|
|
72
|
+
|--------|-------------|---------|
|
|
73
|
+
| `coordinator_election_needed` | — | The local node should initiate GSP `COORDINATOR_CLAIM` |
|
|
74
|
+
| `became_coordinator` | — | This node won the election |
|
|
75
|
+
| `coordinator_claim` | `claimant` | A peer sent `COORDINATOR_CLAIM` with this member id |
|
|
64
76
|
|
|
65
77
|
## Install
|
|
66
78
|
|
|
67
79
|
```sh
|
|
68
|
-
pip install gbp-stack==1.
|
|
80
|
+
pip install gbp-stack==1.4.0
|
|
69
81
|
```
|
|
70
82
|
|
|
71
83
|
## Quick start
|
|
@@ -77,7 +89,7 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
77
89
|
MlsContext.create("bob") as bob_mls:
|
|
78
90
|
|
|
79
91
|
bob_kp = bob_mls.export_key_package()
|
|
80
|
-
welcome = alice_mls.invite(bob_kp)
|
|
92
|
+
welcome = alice_mls.invite(bob_kp) # alice auto-finalizes; epoch advances to 1
|
|
81
93
|
bob_mls.accept_welcome(welcome)
|
|
82
94
|
|
|
83
95
|
group_id = alice_mls.group_id
|
|
@@ -92,8 +104,67 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
92
104
|
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
93
105
|
message_id=0xCAFE_F00D, text="hello")
|
|
94
106
|
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
95
|
-
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
96
|
-
|
|
107
|
+
if ev.kind == "payload_received" and ev.stream_type == 2: # StreamType.Text
|
|
108
|
+
result = gtp_bob.accept(ev.plaintext, bob_mls.epoch)
|
|
109
|
+
print(result.text) # → "hello"
|
|
110
|
+
# result.status is "new" (first message from this sender)
|
|
111
|
+
# subsequent messages → "new"; duplicates → "duplicate"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## GSP signals with per-signal arguments
|
|
115
|
+
|
|
116
|
+
Signals that target a specific member or resource require CBOR-encoded `args`.
|
|
117
|
+
The `send` method accepts an optional `args: bytes` keyword argument.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import struct
|
|
121
|
+
from gbp_stack import GspClient, SignalType
|
|
122
|
+
|
|
123
|
+
# Minimal CBOR helpers
|
|
124
|
+
def cbor_uint(n: int) -> bytes:
|
|
125
|
+
if n <= 23: return bytes([n])
|
|
126
|
+
if n <= 0xFF: return bytes([0x18, n])
|
|
127
|
+
if n <= 0xFFFF: return bytes([0x19, n >> 8, n & 0xFF])
|
|
128
|
+
return bytes([0x1A, (n>>24)&0xFF, (n>>16)&0xFF, (n>>8)&0xFF, n&0xFF])
|
|
129
|
+
|
|
130
|
+
def cbor_map1(k: int, v: int) -> bytes:
|
|
131
|
+
return bytes([0xA1]) + cbor_uint(k) + cbor_uint(v)
|
|
132
|
+
|
|
133
|
+
def cbor_map2(k0: int, v0: int, k1: int, v1: int) -> bytes:
|
|
134
|
+
return bytes([0xA2]) + cbor_uint(k0) + cbor_uint(v0) + cbor_uint(k1) + cbor_uint(v1)
|
|
135
|
+
|
|
136
|
+
# Signal-specific args schemas:
|
|
137
|
+
# MUTE / UNMUTE → {0: target_member_id}
|
|
138
|
+
# ROLE_CHANGE → {0: target_member_id, 1: new_role_id}
|
|
139
|
+
# STREAM_START / STREAM_STOP → {0: stream_type}
|
|
140
|
+
# CODEC_UPDATE → {0: codec_id}
|
|
141
|
+
# JOIN / LEAVE → no args required
|
|
142
|
+
|
|
143
|
+
with GspClient.create() as gsp_alice:
|
|
144
|
+
# Mute member 3 (no role_claim needed for self-moderation)
|
|
145
|
+
frame = gsp_alice.send(
|
|
146
|
+
alice_node, alice_mls,
|
|
147
|
+
target=0, # 0 = broadcast
|
|
148
|
+
signal=SignalType.MUTE,
|
|
149
|
+
role_claim=0,
|
|
150
|
+
request_id=1,
|
|
151
|
+
args=cbor_map1(0, 3), # {0: target_member_id=3}
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## MLS multi-member group pattern
|
|
156
|
+
|
|
157
|
+
When inviting a member to an **existing** group (not the first invite), use
|
|
158
|
+
`invite_full` so that existing members can process the commit:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# Alice adds Carol to an alice+bob group
|
|
162
|
+
commit, welcome = alice_mls.invite_full(carol_mls.export_key_package())
|
|
163
|
+
alice_mls.finalize_commit() # alice's epoch advances
|
|
164
|
+
bob_mls.process_message(commit) # bob stages the commit
|
|
165
|
+
bob_mls.finalize_commit() # bob's epoch advances to match alice
|
|
166
|
+
carol_mls.accept_welcome(welcome) # carol joins
|
|
167
|
+
assert alice_mls.epoch == bob_mls.epoch == carol_mls.epoch
|
|
97
168
|
```
|
|
98
169
|
|
|
99
170
|
## License
|
|
@@ -37,11 +37,23 @@ Beyond the protocol clients, the package ships ready-made helpers:
|
|
|
37
37
|
* `SFrameSession` + `SFrameEncryptor` — SFrame (draft-ietf-sframe-enc) E2EE
|
|
38
38
|
for GAP audio frames; per-sender AES-GCM keys derived from MLS exporter,
|
|
39
39
|
1024-entry sliding-window replay protection.
|
|
40
|
+
* `encode_gbp_frame` — low-level helper to construct a raw CBOR GBP frame.
|
|
41
|
+
* `lookup_error` — return the CBOR `ErrorObject` for a known error code.
|
|
42
|
+
|
|
43
|
+
### Coordinator events
|
|
44
|
+
|
|
45
|
+
`NodeEvent` surfaces three new event kinds for coordinator election:
|
|
46
|
+
|
|
47
|
+
| `kind` | Extra fields | Meaning |
|
|
48
|
+
|--------|-------------|---------|
|
|
49
|
+
| `coordinator_election_needed` | — | The local node should initiate GSP `COORDINATOR_CLAIM` |
|
|
50
|
+
| `became_coordinator` | — | This node won the election |
|
|
51
|
+
| `coordinator_claim` | `claimant` | A peer sent `COORDINATOR_CLAIM` with this member id |
|
|
40
52
|
|
|
41
53
|
## Install
|
|
42
54
|
|
|
43
55
|
```sh
|
|
44
|
-
pip install gbp-stack==1.
|
|
56
|
+
pip install gbp-stack==1.4.0
|
|
45
57
|
```
|
|
46
58
|
|
|
47
59
|
## Quick start
|
|
@@ -53,7 +65,7 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
53
65
|
MlsContext.create("bob") as bob_mls:
|
|
54
66
|
|
|
55
67
|
bob_kp = bob_mls.export_key_package()
|
|
56
|
-
welcome = alice_mls.invite(bob_kp)
|
|
68
|
+
welcome = alice_mls.invite(bob_kp) # alice auto-finalizes; epoch advances to 1
|
|
57
69
|
bob_mls.accept_welcome(welcome)
|
|
58
70
|
|
|
59
71
|
group_id = alice_mls.group_id
|
|
@@ -68,8 +80,67 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
68
80
|
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
69
81
|
message_id=0xCAFE_F00D, text="hello")
|
|
70
82
|
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
71
|
-
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
72
|
-
|
|
83
|
+
if ev.kind == "payload_received" and ev.stream_type == 2: # StreamType.Text
|
|
84
|
+
result = gtp_bob.accept(ev.plaintext, bob_mls.epoch)
|
|
85
|
+
print(result.text) # → "hello"
|
|
86
|
+
# result.status is "new" (first message from this sender)
|
|
87
|
+
# subsequent messages → "new"; duplicates → "duplicate"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## GSP signals with per-signal arguments
|
|
91
|
+
|
|
92
|
+
Signals that target a specific member or resource require CBOR-encoded `args`.
|
|
93
|
+
The `send` method accepts an optional `args: bytes` keyword argument.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import struct
|
|
97
|
+
from gbp_stack import GspClient, SignalType
|
|
98
|
+
|
|
99
|
+
# Minimal CBOR helpers
|
|
100
|
+
def cbor_uint(n: int) -> bytes:
|
|
101
|
+
if n <= 23: return bytes([n])
|
|
102
|
+
if n <= 0xFF: return bytes([0x18, n])
|
|
103
|
+
if n <= 0xFFFF: return bytes([0x19, n >> 8, n & 0xFF])
|
|
104
|
+
return bytes([0x1A, (n>>24)&0xFF, (n>>16)&0xFF, (n>>8)&0xFF, n&0xFF])
|
|
105
|
+
|
|
106
|
+
def cbor_map1(k: int, v: int) -> bytes:
|
|
107
|
+
return bytes([0xA1]) + cbor_uint(k) + cbor_uint(v)
|
|
108
|
+
|
|
109
|
+
def cbor_map2(k0: int, v0: int, k1: int, v1: int) -> bytes:
|
|
110
|
+
return bytes([0xA2]) + cbor_uint(k0) + cbor_uint(v0) + cbor_uint(k1) + cbor_uint(v1)
|
|
111
|
+
|
|
112
|
+
# Signal-specific args schemas:
|
|
113
|
+
# MUTE / UNMUTE → {0: target_member_id}
|
|
114
|
+
# ROLE_CHANGE → {0: target_member_id, 1: new_role_id}
|
|
115
|
+
# STREAM_START / STREAM_STOP → {0: stream_type}
|
|
116
|
+
# CODEC_UPDATE → {0: codec_id}
|
|
117
|
+
# JOIN / LEAVE → no args required
|
|
118
|
+
|
|
119
|
+
with GspClient.create() as gsp_alice:
|
|
120
|
+
# Mute member 3 (no role_claim needed for self-moderation)
|
|
121
|
+
frame = gsp_alice.send(
|
|
122
|
+
alice_node, alice_mls,
|
|
123
|
+
target=0, # 0 = broadcast
|
|
124
|
+
signal=SignalType.MUTE,
|
|
125
|
+
role_claim=0,
|
|
126
|
+
request_id=1,
|
|
127
|
+
args=cbor_map1(0, 3), # {0: target_member_id=3}
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## MLS multi-member group pattern
|
|
132
|
+
|
|
133
|
+
When inviting a member to an **existing** group (not the first invite), use
|
|
134
|
+
`invite_full` so that existing members can process the commit:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# Alice adds Carol to an alice+bob group
|
|
138
|
+
commit, welcome = alice_mls.invite_full(carol_mls.export_key_package())
|
|
139
|
+
alice_mls.finalize_commit() # alice's epoch advances
|
|
140
|
+
bob_mls.process_message(commit) # bob stages the commit
|
|
141
|
+
bob_mls.finalize_commit() # bob's epoch advances to match alice
|
|
142
|
+
carol_mls.accept_welcome(welcome) # carol joins
|
|
143
|
+
assert alice_mls.epoch == bob_mls.epoch == carol_mls.epoch
|
|
73
144
|
```
|
|
74
145
|
|
|
75
146
|
## License
|
|
@@ -20,7 +20,7 @@ README for a worked example.
|
|
|
20
20
|
from ._native import last_error, version
|
|
21
21
|
from .capabilities import CapabilitiesNegotiator
|
|
22
22
|
from .gap_client import GapAcceptResult, GapClient
|
|
23
|
-
from .gbp_node import GroupNode, NodeEvent, NodeState, OutboundFrame, StreamType
|
|
23
|
+
from .gbp_node import GroupNode, NodeEvent, NodeState, OutboundFrame, StreamType, encode_gbp_frame, lookup_error
|
|
24
24
|
from .gsp_client import GspAcceptResult, GspClient, SignalType
|
|
25
25
|
from .gtp_client import GtpAcceptResult, GtpClient
|
|
26
26
|
from .history import MessageEntry, MessageHistory, Watermark
|
|
@@ -66,8 +66,10 @@ __all__ = [
|
|
|
66
66
|
"SignalType",
|
|
67
67
|
"StreamType",
|
|
68
68
|
"Watermark",
|
|
69
|
+
"encode_gbp_frame",
|
|
69
70
|
"last_error",
|
|
71
|
+
"lookup_error",
|
|
70
72
|
"version",
|
|
71
73
|
]
|
|
72
74
|
|
|
73
|
-
__version__ = "1.
|
|
75
|
+
__version__ = "1.4.0"
|
|
@@ -153,6 +153,11 @@ gsp_client_send = _bind(
|
|
|
153
153
|
GbpBuffer,
|
|
154
154
|
[c_int32, c_int32, c_int32, c_uint32, c_uint32, c_uint32, c_uint32],
|
|
155
155
|
)
|
|
156
|
+
gsp_client_send_with_args = _bind(
|
|
157
|
+
"gsp_client_send_with_args",
|
|
158
|
+
GbpBuffer,
|
|
159
|
+
[c_int32, c_int32, c_int32, c_uint32, c_uint32, c_uint32, c_uint32, c_void_p, c_size_t],
|
|
160
|
+
)
|
|
156
161
|
gsp_client_accept = _bind("gsp_client_accept", c_void_p, [c_int32, c_uint64, c_void_p, c_size_t])
|
|
157
162
|
|
|
158
163
|
# ── SFrame ────────────────────────────────────────────────────────────────────
|
|
@@ -175,6 +180,25 @@ gbp_sframe_decrypt = _bind(
|
|
|
175
180
|
[c_int32, c_void_p, c_size_t, c_void_p, c_size_t, ctypes.POINTER(c_uint32)],
|
|
176
181
|
)
|
|
177
182
|
|
|
183
|
+
gbp_frame_encode_v = _bind(
|
|
184
|
+
"gbp_frame_encode_v",
|
|
185
|
+
GbpBuffer,
|
|
186
|
+
[
|
|
187
|
+
c_uint8, # version
|
|
188
|
+
c_void_p, # group_id_16
|
|
189
|
+
c_uint64, # epoch
|
|
190
|
+
c_uint32, # transition_id
|
|
191
|
+
c_uint32, # stream_type
|
|
192
|
+
c_uint32, # stream_id
|
|
193
|
+
c_uint16, # flags
|
|
194
|
+
c_uint32, # sequence_no
|
|
195
|
+
c_void_p, # payload_ptr
|
|
196
|
+
c_size_t, # payload_len
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
gbp_error_lookup = _bind("gbp_error_lookup", GbpBuffer, [c_uint16])
|
|
201
|
+
|
|
178
202
|
|
|
179
203
|
def take_buffer(buf: GbpBuffer) -> bytes:
|
|
180
204
|
"""Copy a returned :class:`GbpBuffer` into a ``bytes`` object and free it."""
|
|
@@ -69,7 +69,12 @@ class NodeEvent:
|
|
|
69
69
|
* ``control`` — populates ``sender``, ``opcode`` and ``transition_id``;
|
|
70
70
|
* ``error`` — populates ``code``, ``code_hex``, ``class_``, ``retryable``,
|
|
71
71
|
``fatal`` and ``reason``;
|
|
72
|
-
* ``epoch_advanced`` — populates ``epoch`` and ``transition_id
|
|
72
|
+
* ``epoch_advanced`` — populates ``epoch`` and ``transition_id``;
|
|
73
|
+
* ``coordinator_election_needed`` — no extra fields; the local node should
|
|
74
|
+
start the coordinator-election handshake (GSP ``COORDINATOR_CLAIM``);
|
|
75
|
+
* ``became_coordinator`` — no extra fields; this node won the election;
|
|
76
|
+
* ``coordinator_claim`` — populates ``claimant`` (member id of the peer
|
|
77
|
+
that sent a ``COORDINATOR_CLAIM`` signal).
|
|
73
78
|
"""
|
|
74
79
|
|
|
75
80
|
kind: str
|
|
@@ -92,6 +97,7 @@ class NodeEvent:
|
|
|
92
97
|
fatal: Optional[bool] = None
|
|
93
98
|
reason: Optional[str] = None
|
|
94
99
|
epoch: Optional[int] = None
|
|
100
|
+
claimant: Optional[int] = None
|
|
95
101
|
|
|
96
102
|
@classmethod
|
|
97
103
|
def _from_dict(cls, d: dict) -> "NodeEvent":
|
|
@@ -118,6 +124,7 @@ class NodeEvent:
|
|
|
118
124
|
fatal=d.get("fatal"),
|
|
119
125
|
reason=d.get("reason"),
|
|
120
126
|
epoch=d.get("epoch"),
|
|
127
|
+
claimant=d.get("claimant"),
|
|
121
128
|
)
|
|
122
129
|
|
|
123
130
|
|
|
@@ -137,6 +144,51 @@ def _unpack(buf: _n.GbpBuffer, what: str) -> OutboundFrame:
|
|
|
137
144
|
return OutboundFrame(target=target, wire=raw[4:])
|
|
138
145
|
|
|
139
146
|
|
|
147
|
+
def encode_gbp_frame(
|
|
148
|
+
version: int,
|
|
149
|
+
group_id: bytes,
|
|
150
|
+
epoch: int,
|
|
151
|
+
transition_id: int,
|
|
152
|
+
stream_type: int,
|
|
153
|
+
stream_id: int,
|
|
154
|
+
flags: int,
|
|
155
|
+
sequence_no: int,
|
|
156
|
+
payload: bytes,
|
|
157
|
+
) -> bytes:
|
|
158
|
+
"""Encode a raw GBP frame to CBOR bytes.
|
|
159
|
+
|
|
160
|
+
Low-level helper — most callers should use :meth:`GroupNode.send_control`
|
|
161
|
+
or the sub-protocol ``send`` methods instead.
|
|
162
|
+
"""
|
|
163
|
+
if len(group_id) != 16:
|
|
164
|
+
raise ValueError("group_id must be 16 bytes")
|
|
165
|
+
gid = (ctypes.c_uint8 * 16).from_buffer_copy(group_id)
|
|
166
|
+
|
|
167
|
+
def call(ptr, length):
|
|
168
|
+
return _n.gbp_frame_encode_v(
|
|
169
|
+
version,
|
|
170
|
+
ctypes.cast(gid, ctypes.c_void_p),
|
|
171
|
+
epoch,
|
|
172
|
+
transition_id,
|
|
173
|
+
stream_type,
|
|
174
|
+
stream_id,
|
|
175
|
+
flags,
|
|
176
|
+
sequence_no,
|
|
177
|
+
ptr,
|
|
178
|
+
length,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
buf = _n.call_with_bytes(payload, call)
|
|
182
|
+
return _n.take_buffer(buf)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def lookup_error(code: int) -> Optional[bytes]:
|
|
186
|
+
"""Return the CBOR-encoded ``ErrorObject`` for *code*, or ``None`` if unknown."""
|
|
187
|
+
buf = _n.gbp_error_lookup(code)
|
|
188
|
+
data = _n.take_buffer(buf)
|
|
189
|
+
return data if data else None
|
|
190
|
+
|
|
191
|
+
|
|
140
192
|
class GroupNode:
|
|
141
193
|
"""GBP-layer group node.
|
|
142
194
|
|
|
@@ -85,12 +85,20 @@ class GspClient:
|
|
|
85
85
|
signal: SignalType,
|
|
86
86
|
role_claim: int,
|
|
87
87
|
request_id: int,
|
|
88
|
+
args: bytes = b"",
|
|
88
89
|
) -> OutboundFrame:
|
|
89
|
-
"""Send a signal.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
90
|
+
"""Send a signal.
|
|
91
|
+
|
|
92
|
+
``args`` carries opcode-specific CBOR-encoded arguments required by
|
|
93
|
+
signals such as MUTE/UNMUTE (``{0: target_member_id}``),
|
|
94
|
+
ROLE_CHANGE (``{0: target_member_id, 1: new_role_id}``), etc.
|
|
95
|
+
"""
|
|
96
|
+
def do_send(ptr, length):
|
|
97
|
+
return _n.gsp_client_send_with_args(
|
|
98
|
+
self._handle, node.handle, mls.handle, target,
|
|
99
|
+
int(signal), role_claim, request_id, ptr, length,
|
|
100
|
+
)
|
|
101
|
+
buf = _n.call_with_bytes(args, do_send)
|
|
94
102
|
return _unpack(buf, "gsp_client_send")
|
|
95
103
|
|
|
96
104
|
def accept(self, plaintext: bytes, current_epoch: int) -> GspAcceptResult:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gbp-stack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
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
5
|
Author: Group Protocol Stack contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -61,11 +61,23 @@ Beyond the protocol clients, the package ships ready-made helpers:
|
|
|
61
61
|
* `SFrameSession` + `SFrameEncryptor` — SFrame (draft-ietf-sframe-enc) E2EE
|
|
62
62
|
for GAP audio frames; per-sender AES-GCM keys derived from MLS exporter,
|
|
63
63
|
1024-entry sliding-window replay protection.
|
|
64
|
+
* `encode_gbp_frame` — low-level helper to construct a raw CBOR GBP frame.
|
|
65
|
+
* `lookup_error` — return the CBOR `ErrorObject` for a known error code.
|
|
66
|
+
|
|
67
|
+
### Coordinator events
|
|
68
|
+
|
|
69
|
+
`NodeEvent` surfaces three new event kinds for coordinator election:
|
|
70
|
+
|
|
71
|
+
| `kind` | Extra fields | Meaning |
|
|
72
|
+
|--------|-------------|---------|
|
|
73
|
+
| `coordinator_election_needed` | — | The local node should initiate GSP `COORDINATOR_CLAIM` |
|
|
74
|
+
| `became_coordinator` | — | This node won the election |
|
|
75
|
+
| `coordinator_claim` | `claimant` | A peer sent `COORDINATOR_CLAIM` with this member id |
|
|
64
76
|
|
|
65
77
|
## Install
|
|
66
78
|
|
|
67
79
|
```sh
|
|
68
|
-
pip install gbp-stack==1.
|
|
80
|
+
pip install gbp-stack==1.4.0
|
|
69
81
|
```
|
|
70
82
|
|
|
71
83
|
## Quick start
|
|
@@ -77,7 +89,7 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
77
89
|
MlsContext.create("bob") as bob_mls:
|
|
78
90
|
|
|
79
91
|
bob_kp = bob_mls.export_key_package()
|
|
80
|
-
welcome = alice_mls.invite(bob_kp)
|
|
92
|
+
welcome = alice_mls.invite(bob_kp) # alice auto-finalizes; epoch advances to 1
|
|
81
93
|
bob_mls.accept_welcome(welcome)
|
|
82
94
|
|
|
83
95
|
group_id = alice_mls.group_id
|
|
@@ -92,8 +104,67 @@ with MlsContext.create("alice") as alice_mls, \
|
|
|
92
104
|
frame = gtp_alice.send(alice, alice_mls, target=2,
|
|
93
105
|
message_id=0xCAFE_F00D, text="hello")
|
|
94
106
|
for ev in bob.on_wire(bob_mls, frame.wire):
|
|
95
|
-
if ev.kind == "payload_received" and ev.stream_type == 2:
|
|
96
|
-
|
|
107
|
+
if ev.kind == "payload_received" and ev.stream_type == 2: # StreamType.Text
|
|
108
|
+
result = gtp_bob.accept(ev.plaintext, bob_mls.epoch)
|
|
109
|
+
print(result.text) # → "hello"
|
|
110
|
+
# result.status is "new" (first message from this sender)
|
|
111
|
+
# subsequent messages → "new"; duplicates → "duplicate"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## GSP signals with per-signal arguments
|
|
115
|
+
|
|
116
|
+
Signals that target a specific member or resource require CBOR-encoded `args`.
|
|
117
|
+
The `send` method accepts an optional `args: bytes` keyword argument.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import struct
|
|
121
|
+
from gbp_stack import GspClient, SignalType
|
|
122
|
+
|
|
123
|
+
# Minimal CBOR helpers
|
|
124
|
+
def cbor_uint(n: int) -> bytes:
|
|
125
|
+
if n <= 23: return bytes([n])
|
|
126
|
+
if n <= 0xFF: return bytes([0x18, n])
|
|
127
|
+
if n <= 0xFFFF: return bytes([0x19, n >> 8, n & 0xFF])
|
|
128
|
+
return bytes([0x1A, (n>>24)&0xFF, (n>>16)&0xFF, (n>>8)&0xFF, n&0xFF])
|
|
129
|
+
|
|
130
|
+
def cbor_map1(k: int, v: int) -> bytes:
|
|
131
|
+
return bytes([0xA1]) + cbor_uint(k) + cbor_uint(v)
|
|
132
|
+
|
|
133
|
+
def cbor_map2(k0: int, v0: int, k1: int, v1: int) -> bytes:
|
|
134
|
+
return bytes([0xA2]) + cbor_uint(k0) + cbor_uint(v0) + cbor_uint(k1) + cbor_uint(v1)
|
|
135
|
+
|
|
136
|
+
# Signal-specific args schemas:
|
|
137
|
+
# MUTE / UNMUTE → {0: target_member_id}
|
|
138
|
+
# ROLE_CHANGE → {0: target_member_id, 1: new_role_id}
|
|
139
|
+
# STREAM_START / STREAM_STOP → {0: stream_type}
|
|
140
|
+
# CODEC_UPDATE → {0: codec_id}
|
|
141
|
+
# JOIN / LEAVE → no args required
|
|
142
|
+
|
|
143
|
+
with GspClient.create() as gsp_alice:
|
|
144
|
+
# Mute member 3 (no role_claim needed for self-moderation)
|
|
145
|
+
frame = gsp_alice.send(
|
|
146
|
+
alice_node, alice_mls,
|
|
147
|
+
target=0, # 0 = broadcast
|
|
148
|
+
signal=SignalType.MUTE,
|
|
149
|
+
role_claim=0,
|
|
150
|
+
request_id=1,
|
|
151
|
+
args=cbor_map1(0, 3), # {0: target_member_id=3}
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## MLS multi-member group pattern
|
|
156
|
+
|
|
157
|
+
When inviting a member to an **existing** group (not the first invite), use
|
|
158
|
+
`invite_full` so that existing members can process the commit:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# Alice adds Carol to an alice+bob group
|
|
162
|
+
commit, welcome = alice_mls.invite_full(carol_mls.export_key_package())
|
|
163
|
+
alice_mls.finalize_commit() # alice's epoch advances
|
|
164
|
+
bob_mls.process_message(commit) # bob stages the commit
|
|
165
|
+
bob_mls.finalize_commit() # bob's epoch advances to match alice
|
|
166
|
+
carol_mls.accept_welcome(welcome) # carol joins
|
|
167
|
+
assert alice_mls.epoch == bob_mls.epoch == carol_mls.epoch
|
|
97
168
|
```
|
|
98
169
|
|
|
99
170
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gbp-stack"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
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
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|