pure-mls 3.0.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.
- pure_mls-3.0.4.0/PKG-INFO +96 -0
- pure_mls-3.0.4.0/README.md +86 -0
- pure_mls-3.0.4.0/pyproject.toml +66 -0
- pure_mls-3.0.4.0/src/pure_mls/__init__.py +37 -0
- pure_mls-3.0.4.0/src/pure_mls/cli.py +102 -0
- pure_mls-3.0.4.0/src/pure_mls/crypto.py +54 -0
- pure_mls-3.0.4.0/src/pure_mls/epoch.py +99 -0
- pure_mls-3.0.4.0/src/pure_mls/extensions.py +140 -0
- pure_mls-3.0.4.0/src/pure_mls/group.py +1824 -0
- pure_mls-3.0.4.0/src/pure_mls/hkdf.py +90 -0
- pure_mls-3.0.4.0/src/pure_mls/hpke.py +106 -0
- pure_mls-3.0.4.0/src/pure_mls/keys.py +90 -0
- pure_mls-3.0.4.0/src/pure_mls/keyschedule.py +275 -0
- pure_mls-3.0.4.0/src/pure_mls/proposals.py +228 -0
- pure_mls-3.0.4.0/src/pure_mls/py.typed +0 -0
- pure_mls-3.0.4.0/src/pure_mls/secret_tree.py +182 -0
- pure_mls-3.0.4.0/src/pure_mls/storage.py +92 -0
- pure_mls-3.0.4.0/src/pure_mls/tls.py +290 -0
- pure_mls-3.0.4.0/src/pure_mls/tree.py +740 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pure-mls
|
|
3
|
+
Version: 3.0.4.0
|
|
4
|
+
Summary: Pure Python MLS implementation with CLI. Still maturing the application messaging CLI commands.
|
|
5
|
+
Author: Joan F Garcia
|
|
6
|
+
Author-email: Joan F Garcia <joanfgarcia@gmail.com>
|
|
7
|
+
Requires-Dist: cryptography>=46.0.5
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# pure-mls
|
|
12
|
+
|
|
13
|
+
`pure-mls` is a zero-dependency, pure Python implementation of the Messaging Layer Security (MLS) protocol ([RFC 9420](https://datatracker.ietf.org/doc/rfc9420/)). This project provides a production-grade cryptographic state machine for secure group messaging.
|
|
14
|
+
|
|
15
|
+
## π Features & Interoperability
|
|
16
|
+
`pure-mls` has achieved full cryptographic interoperability with the IETF standard, passing 100% of the **Passive Client Welcome** and **PSK Resolution** test vectors (OpenMLS benchmark):
|
|
17
|
+
- **RFC 9420 Compliant**: Full implementation of the TreeKEM and Key Schedule lifecycle.
|
|
18
|
+
- **IETF Vector Verification**: 100% pass rate on interoperability suites.
|
|
19
|
+
- **Transports**: Verified E2E over WebSockets, MQTT (IoT), WebRTC (P2P), and gRPC.
|
|
20
|
+
|
|
21
|
+
## π§ Philosophy: "Sound of Silence"
|
|
22
|
+
The goal is **Absolute Purity**:
|
|
23
|
+
- No compiled bindings (no Rust, C++ or FFI).
|
|
24
|
+
- Operates natively in any Python 3.12+ environment.
|
|
25
|
+
- Suitable for zero-friction edge computing and standard backend runtimes.
|
|
26
|
+
- Built on principles of [Plausible Deniability and Zero-Knowledge](docs/00_MANIFESTO.md).
|
|
27
|
+
|
|
28
|
+
### The Linter Protocol
|
|
29
|
+
We strictly enforce the **"Sound of Silence"** code standard via `ruff` in the `pyproject.toml` file:
|
|
30
|
+
- **Zero-Warning State**: 100% clean status under Ruff's most rigorous rules.
|
|
31
|
+
- Pure Tabulations (`\t`) for minimal character footprint (`W191` allowance).
|
|
32
|
+
- Zero dead code allowed.
|
|
33
|
+
|
|
34
|
+
## πΊοΈ Architecture (Project Map)
|
|
35
|
+
```text
|
|
36
|
+
pure-mls/
|
|
37
|
+
βββ README.md # This file
|
|
38
|
+
βββ CHANGELOG.md # Version history registry
|
|
39
|
+
βββ pyproject.toml # Dependencies (uv) and Sound of Silence config (Ruff)
|
|
40
|
+
βββ src/
|
|
41
|
+
β βββ pure_mls/
|
|
42
|
+
β βββ group.py # [API] State Machine (MLSGroup)
|
|
43
|
+
β βββ tree.py # Nodes and RatchetTree structure
|
|
44
|
+
β βββ tls.py # RFC 9420 / TLS 1.3 Wire Format Primitives
|
|
45
|
+
β βββ extensions.py # MLS Extensions Framework
|
|
46
|
+
β βββ proposals.py # Group Operations (Add, Update, Remove)
|
|
47
|
+
β βββ epoch.py # Immutable states (Epochs)
|
|
48
|
+
β βββ keys.py # Ed25519 Identities and X25519 KEMs
|
|
49
|
+
β βββ keyschedule.py # Secret Derivation (Application_Key)
|
|
50
|
+
β βββ hpke.py # Hybrid Public Key Encryption Base Mode
|
|
51
|
+
βββ tests/
|
|
52
|
+
βββ test_ietf_vectors.py # 100% RFC 9420 Interop (IETF/OpenMLS)
|
|
53
|
+
βββ test_group.py # State Machine unit tests
|
|
54
|
+
βββ test_e2e_websockets.py # E2E local Websockets
|
|
55
|
+
βββ test_e2e_mqtt.py # E2E broker.hivemq.com (IoT)
|
|
56
|
+
βββ test_e2e_webrtc.py # E2E Data Channels P2P (aiortc)
|
|
57
|
+
βββ test_e2e_grpc.py # E2E Backend Swarm (Proto Hub)
|
|
58
|
+
```
|
|
59
|
+
## π Documentation & Guides
|
|
60
|
+
We believe in making cryptography accessible. For a fast, pragmatic, and irreverent introduction to MLS, check our Primate Survival Guide:
|
|
61
|
+
- π¦ [The Primate Survival Guide to pure-mls (EN)](docs/03_MLS_FOR_PRIMATES_EN.md)
|
|
62
|
+
- π¦ [GuΓa del Primate Sobreviviendo a pure-mls (ES)](docs/03_MLS_FOR_PRIMATES_ES.md)
|
|
63
|
+
|
|
64
|
+
For a deeper dive into the architecture, mathematics, and philosophy of the protocol, explore the Human Journey:
|
|
65
|
+
- πΊπΈ [The Human Guide to MLS: The Journey (EN)](docs/02_MLS_JOURNEY_EN.md)
|
|
66
|
+
- πͺπΈ [La GuΓa Humana de MLS: El Viaje (ES)](docs/02_MLS_JOURNEY_ES.md)
|
|
67
|
+
|
|
68
|
+
*Contributors: We welcome translations! Feel free to PR your language following the `02_MLS_JOURNEY_XX.md` format.*
|
|
69
|
+
|
|
70
|
+
## π API Quickstart
|
|
71
|
+
The central state machine is `MLSGroup`. Install it in your brain:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from pure_mls.group import MLSGroup
|
|
75
|
+
from pure_mls.keys import SignatureKey, KemKey
|
|
76
|
+
|
|
77
|
+
# 1. Each participant generates their own persistent identity keys
|
|
78
|
+
alice_sig, alice_kem = SignatureKey(), KemKey()
|
|
79
|
+
bob_sig, bob_kem = SignatureKey(), KemKey()
|
|
80
|
+
|
|
81
|
+
# 2. Alice initializes the Sovereign Group
|
|
82
|
+
alice_group = MLSGroup.create(b"grupo-soberano", alice_sig, alice_kem)
|
|
83
|
+
|
|
84
|
+
# 3. Alice receives Bob's `KeyPackage` (his public keys + identity) over the network
|
|
85
|
+
bob_kp = MLSGroup.create_key_package(bob_sig, bob_kem)
|
|
86
|
+
alice_next, welcome, update = alice_group.add_member(bob_kp)
|
|
87
|
+
|
|
88
|
+
# 4. Bob decrypts the Welcome (sealed with HPKE to his KEM key) and joins
|
|
89
|
+
bob_group = MLSGroup.join(welcome, bob_sig, bob_kem)
|
|
90
|
+
|
|
91
|
+
# The Underlying Mathematical Truth:
|
|
92
|
+
assert alice_next.application_key == bob_group.application_key
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
This project is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# pure-mls
|
|
2
|
+
|
|
3
|
+
`pure-mls` is a zero-dependency, pure Python implementation of the Messaging Layer Security (MLS) protocol ([RFC 9420](https://datatracker.ietf.org/doc/rfc9420/)). This project provides a production-grade cryptographic state machine for secure group messaging.
|
|
4
|
+
|
|
5
|
+
## π Features & Interoperability
|
|
6
|
+
`pure-mls` has achieved full cryptographic interoperability with the IETF standard, passing 100% of the **Passive Client Welcome** and **PSK Resolution** test vectors (OpenMLS benchmark):
|
|
7
|
+
- **RFC 9420 Compliant**: Full implementation of the TreeKEM and Key Schedule lifecycle.
|
|
8
|
+
- **IETF Vector Verification**: 100% pass rate on interoperability suites.
|
|
9
|
+
- **Transports**: Verified E2E over WebSockets, MQTT (IoT), WebRTC (P2P), and gRPC.
|
|
10
|
+
|
|
11
|
+
## π§ Philosophy: "Sound of Silence"
|
|
12
|
+
The goal is **Absolute Purity**:
|
|
13
|
+
- No compiled bindings (no Rust, C++ or FFI).
|
|
14
|
+
- Operates natively in any Python 3.12+ environment.
|
|
15
|
+
- Suitable for zero-friction edge computing and standard backend runtimes.
|
|
16
|
+
- Built on principles of [Plausible Deniability and Zero-Knowledge](docs/00_MANIFESTO.md).
|
|
17
|
+
|
|
18
|
+
### The Linter Protocol
|
|
19
|
+
We strictly enforce the **"Sound of Silence"** code standard via `ruff` in the `pyproject.toml` file:
|
|
20
|
+
- **Zero-Warning State**: 100% clean status under Ruff's most rigorous rules.
|
|
21
|
+
- Pure Tabulations (`\t`) for minimal character footprint (`W191` allowance).
|
|
22
|
+
- Zero dead code allowed.
|
|
23
|
+
|
|
24
|
+
## πΊοΈ Architecture (Project Map)
|
|
25
|
+
```text
|
|
26
|
+
pure-mls/
|
|
27
|
+
βββ README.md # This file
|
|
28
|
+
βββ CHANGELOG.md # Version history registry
|
|
29
|
+
βββ pyproject.toml # Dependencies (uv) and Sound of Silence config (Ruff)
|
|
30
|
+
βββ src/
|
|
31
|
+
β βββ pure_mls/
|
|
32
|
+
β βββ group.py # [API] State Machine (MLSGroup)
|
|
33
|
+
β βββ tree.py # Nodes and RatchetTree structure
|
|
34
|
+
β βββ tls.py # RFC 9420 / TLS 1.3 Wire Format Primitives
|
|
35
|
+
β βββ extensions.py # MLS Extensions Framework
|
|
36
|
+
β βββ proposals.py # Group Operations (Add, Update, Remove)
|
|
37
|
+
β βββ epoch.py # Immutable states (Epochs)
|
|
38
|
+
β βββ keys.py # Ed25519 Identities and X25519 KEMs
|
|
39
|
+
β βββ keyschedule.py # Secret Derivation (Application_Key)
|
|
40
|
+
β βββ hpke.py # Hybrid Public Key Encryption Base Mode
|
|
41
|
+
βββ tests/
|
|
42
|
+
βββ test_ietf_vectors.py # 100% RFC 9420 Interop (IETF/OpenMLS)
|
|
43
|
+
βββ test_group.py # State Machine unit tests
|
|
44
|
+
βββ test_e2e_websockets.py # E2E local Websockets
|
|
45
|
+
βββ test_e2e_mqtt.py # E2E broker.hivemq.com (IoT)
|
|
46
|
+
βββ test_e2e_webrtc.py # E2E Data Channels P2P (aiortc)
|
|
47
|
+
βββ test_e2e_grpc.py # E2E Backend Swarm (Proto Hub)
|
|
48
|
+
```
|
|
49
|
+
## π Documentation & Guides
|
|
50
|
+
We believe in making cryptography accessible. For a fast, pragmatic, and irreverent introduction to MLS, check our Primate Survival Guide:
|
|
51
|
+
- π¦ [The Primate Survival Guide to pure-mls (EN)](docs/03_MLS_FOR_PRIMATES_EN.md)
|
|
52
|
+
- π¦ [GuΓa del Primate Sobreviviendo a pure-mls (ES)](docs/03_MLS_FOR_PRIMATES_ES.md)
|
|
53
|
+
|
|
54
|
+
For a deeper dive into the architecture, mathematics, and philosophy of the protocol, explore the Human Journey:
|
|
55
|
+
- πΊπΈ [The Human Guide to MLS: The Journey (EN)](docs/02_MLS_JOURNEY_EN.md)
|
|
56
|
+
- πͺπΈ [La GuΓa Humana de MLS: El Viaje (ES)](docs/02_MLS_JOURNEY_ES.md)
|
|
57
|
+
|
|
58
|
+
*Contributors: We welcome translations! Feel free to PR your language following the `02_MLS_JOURNEY_XX.md` format.*
|
|
59
|
+
|
|
60
|
+
## π API Quickstart
|
|
61
|
+
The central state machine is `MLSGroup`. Install it in your brain:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pure_mls.group import MLSGroup
|
|
65
|
+
from pure_mls.keys import SignatureKey, KemKey
|
|
66
|
+
|
|
67
|
+
# 1. Each participant generates their own persistent identity keys
|
|
68
|
+
alice_sig, alice_kem = SignatureKey(), KemKey()
|
|
69
|
+
bob_sig, bob_kem = SignatureKey(), KemKey()
|
|
70
|
+
|
|
71
|
+
# 2. Alice initializes the Sovereign Group
|
|
72
|
+
alice_group = MLSGroup.create(b"grupo-soberano", alice_sig, alice_kem)
|
|
73
|
+
|
|
74
|
+
# 3. Alice receives Bob's `KeyPackage` (his public keys + identity) over the network
|
|
75
|
+
bob_kp = MLSGroup.create_key_package(bob_sig, bob_kem)
|
|
76
|
+
alice_next, welcome, update = alice_group.add_member(bob_kp)
|
|
77
|
+
|
|
78
|
+
# 4. Bob decrypts the Welcome (sealed with HPKE to his KEM key) and joins
|
|
79
|
+
bob_group = MLSGroup.join(welcome, bob_sig, bob_kem)
|
|
80
|
+
|
|
81
|
+
# The Underlying Mathematical Truth:
|
|
82
|
+
assert alice_next.application_key == bob_group.application_key
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
This project is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pure-mls"
|
|
3
|
+
version = "3.0.4.0"
|
|
4
|
+
description = "Pure Python MLS implementation with CLI. Still maturing the application messaging CLI commands."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Joan F Garcia", email = "joanfgarcia@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=46.0.5",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
pure-mls = "pure_mls.cli:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.10.3,<0.11.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"aiomqtt>=2.5.1",
|
|
24
|
+
"aiortc>=1.14.0",
|
|
25
|
+
"amqtt>=0.11.0",
|
|
26
|
+
"coverage>=7.13.5",
|
|
27
|
+
"grpcio>=1.78.0",
|
|
28
|
+
"grpcio-tools>=1.78.0",
|
|
29
|
+
"mypy>=1.19.1",
|
|
30
|
+
"pytest>=9.0.2",
|
|
31
|
+
"pytest-asyncio>=1.3.0",
|
|
32
|
+
"pytest-cov>=7.0.0",
|
|
33
|
+
"ruff>=0.15.6",
|
|
34
|
+
"websockets>=15.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 150
|
|
39
|
+
exclude = ["tests/protos/*", ".venv/*"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "W", "I", "ARG", "PLC"]
|
|
43
|
+
ignore = [
|
|
44
|
+
"W191", # Allow tabs (Sound of Silence protocol)
|
|
45
|
+
"E501", # Allow long lines
|
|
46
|
+
"E265", # Allow some comment variants
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.per-file-ignores]
|
|
50
|
+
"tests/*" = ["PLC0415", "ARG001", "ARG002"] # inline imports + fixtures are valid pytest patterns
|
|
51
|
+
"scripts/*" = ["PLC0415"] # standalone scripts use inline imports intentionally
|
|
52
|
+
"src/pure_mls/keyschedule.py" = ["ARG003"] # intermediate kept for PSK audit trail
|
|
53
|
+
|
|
54
|
+
[tool.ruff.format]
|
|
55
|
+
indent-style = "tab"
|
|
56
|
+
quote-style = "double"
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
norecursedirs = ["3rdparty", ".venv", "build", "dist"]
|
|
61
|
+
markers = [
|
|
62
|
+
"network: tests that require internet access to fetch live IETF vectors",
|
|
63
|
+
"interop: tests that cross-validate with external implementations (e.g. OpenMLS)",
|
|
64
|
+
]
|
|
65
|
+
asyncio_mode = "strict"
|
|
66
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pure_mls.epoch import EpochState
|
|
2
|
+
from pure_mls.group import (
|
|
3
|
+
EncryptedGroupSecrets,
|
|
4
|
+
FramedContent,
|
|
5
|
+
FramedContentAuthData,
|
|
6
|
+
GroupContext,
|
|
7
|
+
GroupSecrets,
|
|
8
|
+
GroupUpdate,
|
|
9
|
+
MLSGroup,
|
|
10
|
+
MLSMessage,
|
|
11
|
+
PublicMessage,
|
|
12
|
+
Welcome,
|
|
13
|
+
WireFormat,
|
|
14
|
+
)
|
|
15
|
+
from pure_mls.tree import KeyPackage, LeafNode, RatchetTree
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Core group management
|
|
19
|
+
"MLSGroup",
|
|
20
|
+
"EpochState",
|
|
21
|
+
"RatchetTree",
|
|
22
|
+
"KeyPackage",
|
|
23
|
+
"LeafNode",
|
|
24
|
+
# RFC 9420 message types
|
|
25
|
+
"Welcome",
|
|
26
|
+
"GroupUpdate",
|
|
27
|
+
"MLSMessage",
|
|
28
|
+
"WireFormat",
|
|
29
|
+
# RFC 9420 Β§6 framing (v1.1)
|
|
30
|
+
"FramedContent",
|
|
31
|
+
"FramedContentAuthData",
|
|
32
|
+
"PublicMessage",
|
|
33
|
+
# RFC 9420 internal structures
|
|
34
|
+
"GroupContext",
|
|
35
|
+
"GroupSecrets",
|
|
36
|
+
"EncryptedGroupSecrets",
|
|
37
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
|
4
|
+
|
|
5
|
+
from pure_mls.group import MLSGroup
|
|
6
|
+
from pure_mls.keys import KemKey, SignatureKey
|
|
7
|
+
from pure_mls.tree import KeyPackage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Pure-MLS Command Line Interface", prog="pure-mls")
|
|
12
|
+
subparsers = parser.add_subparsers(dest="command", help="commands")
|
|
13
|
+
subparsers.required = True
|
|
14
|
+
|
|
15
|
+
# keygen
|
|
16
|
+
p_keygen = subparsers.add_parser("keygen", help="Generate a new identity and KeyPackage")
|
|
17
|
+
p_keygen.add_argument("alias", help="Alias for the user (will create <alias>.pub and <alias>.priv)")
|
|
18
|
+
|
|
19
|
+
# create-group
|
|
20
|
+
p_create = subparsers.add_parser("create-group", help="Create a brand new MLS group")
|
|
21
|
+
p_create.add_argument("group_id", help="Name/ID of the group")
|
|
22
|
+
p_create.add_argument("founder_priv", help="Path to founder's .priv file")
|
|
23
|
+
p_create.add_argument("--out", "-o", required=True, help="Path to save the group state (.state)")
|
|
24
|
+
|
|
25
|
+
# add-member
|
|
26
|
+
p_add = subparsers.add_parser("add-member", help="Add a new member to an existing group")
|
|
27
|
+
p_add.add_argument("group_state", help="Path to the group state (.state)")
|
|
28
|
+
p_add.add_argument("invitee_pub", help="Path to the invitee's public KeyPackage (.pub)")
|
|
29
|
+
p_add.add_argument("--out-welcome", "-w", required=True, help="Path to save the Welcome message")
|
|
30
|
+
p_add.add_argument("--out-state", "-o", help="Overwrites group state by default, unless specified here")
|
|
31
|
+
|
|
32
|
+
# join-group
|
|
33
|
+
p_join = subparsers.add_parser("join-group", help="Join a group from a Welcome message")
|
|
34
|
+
p_join.add_argument("welcome", help="Path to the Welcome message")
|
|
35
|
+
p_join.add_argument("my_priv", help="Path to your private keys (.priv)")
|
|
36
|
+
p_join.add_argument("--out-state", "-o", required=True, help="Path to save your group state (.state)")
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
if args.command == "keygen":
|
|
41
|
+
sig_key = SignatureKey(private_key=ed25519.Ed25519PrivateKey.generate())
|
|
42
|
+
kem_key = KemKey(private_key=x25519.X25519PrivateKey.generate())
|
|
43
|
+
kp = KeyPackage.create(
|
|
44
|
+
encryption_key=kem_key.public_bytes(),
|
|
45
|
+
init_key_pub=kem_key.public_bytes(),
|
|
46
|
+
signature_key=sig_key.public_bytes(),
|
|
47
|
+
identity=args.alias.encode(),
|
|
48
|
+
sign_fn=sig_key.sign,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Simplistic serialization: 32 bytes sig priv + 32 bytes kem priv + (pubkeys inside KeyPackage)
|
|
52
|
+
priv_data = sig_key.private_bytes() + kem_key.private_bytes()
|
|
53
|
+
with open(f"{args.alias}.priv", "wb") as f:
|
|
54
|
+
f.write(priv_data)
|
|
55
|
+
with open(f"{args.alias}.pub", "wb") as f:
|
|
56
|
+
f.write(kp.to_bytes())
|
|
57
|
+
print(f"Created {args.alias}.priv (KEEP SECRET!) and {args.alias}.pub (Distribute freely)")
|
|
58
|
+
|
|
59
|
+
elif args.command == "create-group":
|
|
60
|
+
with open(args.founder_priv, "rb") as f:
|
|
61
|
+
pd = f.read()
|
|
62
|
+
sig_key = SignatureKey.from_private_bytes(pd[:32])
|
|
63
|
+
kem_key = KemKey.from_private_bytes(pd[32:64])
|
|
64
|
+
|
|
65
|
+
group = MLSGroup.create(args.group_id.encode(), sig_key, kem_key)
|
|
66
|
+
with open(args.out, "wb") as f:
|
|
67
|
+
f.write(group.to_bytes())
|
|
68
|
+
print(f"Created group '{args.group_id}' (state saved to {args.out})")
|
|
69
|
+
|
|
70
|
+
elif args.command == "add-member":
|
|
71
|
+
with open(args.group_state, "rb") as f:
|
|
72
|
+
group = MLSGroup.from_bytes(f.read())
|
|
73
|
+
with open(args.invitee_pub, "rb") as f:
|
|
74
|
+
kp, _ = KeyPackage.from_bytes_at(f.read())
|
|
75
|
+
|
|
76
|
+
new_group, welcome, commit = group.add_member(kp)
|
|
77
|
+
|
|
78
|
+
out_state = args.out_state if args.out_state else args.group_state
|
|
79
|
+
with open(out_state, "wb") as f:
|
|
80
|
+
f.write(new_group.to_bytes())
|
|
81
|
+
with open(args.out_welcome, "wb") as f:
|
|
82
|
+
f.write(welcome.to_bytes())
|
|
83
|
+
print(f"Added member! State updated at {out_state}. Distribute {args.out_welcome} to the new member.")
|
|
84
|
+
|
|
85
|
+
elif args.command == "join-group":
|
|
86
|
+
with open(args.my_priv, "rb") as f:
|
|
87
|
+
pd = f.read()
|
|
88
|
+
sig_key = SignatureKey.from_private_bytes(pd[:32])
|
|
89
|
+
kem_key = KemKey.from_private_bytes(pd[32:64])
|
|
90
|
+
|
|
91
|
+
with open(args.welcome, "rb") as f:
|
|
92
|
+
welcome_data = f.read()
|
|
93
|
+
|
|
94
|
+
group = MLSGroup.join(welcome_data, sig_key, kem_key)
|
|
95
|
+
with open(args.out_state, "wb") as f:
|
|
96
|
+
f.write(group.to_bytes())
|
|
97
|
+
gid = group.state.group_id.decode(errors="replace")
|
|
98
|
+
print(f"Successfully joined group '{gid}'! State saved to {args.out_state}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from pure_mls.tls import tls_opaque, tls_opaque_varint
|
|
4
|
+
from pure_mls.tree import LeafNode, ParentNode, RatchetTree
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_hpke_info(label: str, context: bytes) -> bytes:
|
|
8
|
+
"""RFC 9420 Β§4.5: HPKE info = HPKELabel{label, context} (omitting length per common interop)."""
|
|
9
|
+
full_label = b"MLS 1.0 " + label.encode("ascii")
|
|
10
|
+
return tls_opaque_varint(full_label) + tls_opaque_varint(context)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _egs_info(egi: bytes) -> bytes:
|
|
14
|
+
"""RFC 9420 Β§12.4.2: HPKE info for EncryptedGroupSecrets."""
|
|
15
|
+
return _make_hpke_info("Welcome", egi)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _up_info(group_ctx: bytes) -> bytes:
|
|
19
|
+
"""RFC 9420 Β§5.1.3: HPKE info for UpdatePathNode."""
|
|
20
|
+
return _make_hpke_info("UpdatePathNode", group_ctx)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _subtree_hash(tree: "RatchetTree", index: int) -> bytes:
|
|
24
|
+
"""RFC 9420 Β§7.8: recursive subtree hash for parent_hash computation."""
|
|
25
|
+
if index < 0 or index >= len(tree.nodes):
|
|
26
|
+
return hashlib.sha256(b"").digest()
|
|
27
|
+
node = tree.get_node(index)
|
|
28
|
+
if index % 2 == 0: # leaf
|
|
29
|
+
if node is None:
|
|
30
|
+
return hashlib.sha256(b"\x01\x00").digest()
|
|
31
|
+
assert isinstance(node, LeafNode)
|
|
32
|
+
return hashlib.sha256(b"\x01\x01" + node.to_bytes()).digest()
|
|
33
|
+
|
|
34
|
+
lvl = tree.level(index)
|
|
35
|
+
child_dist = 1 << (lvl - 1)
|
|
36
|
+
left = index - child_dist
|
|
37
|
+
right = index + child_dist
|
|
38
|
+
left_hash = _subtree_hash(tree, left)
|
|
39
|
+
right_hash = _subtree_hash(tree, right)
|
|
40
|
+
|
|
41
|
+
if node is None:
|
|
42
|
+
return hashlib.sha256(b"\x02\x00" + tls_opaque_varint(left_hash) + tls_opaque_varint(right_hash)).digest()
|
|
43
|
+
|
|
44
|
+
assert isinstance(node, ParentNode)
|
|
45
|
+
return hashlib.sha256(b"\x02\x01" + node.to_bytes() + tls_opaque_varint(left_hash) + tls_opaque_varint(right_hash)).digest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compute_parent_hash(new_public_key: bytes, parent_hash_of_parent: bytes, original_sibling_tree_hash: bytes) -> bytes:
|
|
49
|
+
"""RFC 9420 Β§7.9: parent_hash = SHA-256(label + ParentHashInput)."""
|
|
50
|
+
label = b"MLS 1.0 parent hash"
|
|
51
|
+
|
|
52
|
+
return hashlib.sha256(
|
|
53
|
+
tls_opaque(label) + tls_opaque(new_public_key) + tls_opaque(parent_hash_of_parent) + tls_opaque(original_sibling_tree_hash)
|
|
54
|
+
).digest()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from pure_mls.keyschedule import KeySchedule, PreSharedKeyID
|
|
5
|
+
from pure_mls.tree import RatchetTree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class EpochState:
|
|
10
|
+
"""
|
|
11
|
+
The absolute definition of the Group State at any point in time.
|
|
12
|
+
If two disconnected nodes have mathematically identical EpochStates,
|
|
13
|
+
they are cryptographically synchronized.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
group_id: bytes
|
|
17
|
+
epoch_id: int
|
|
18
|
+
tree: RatchetTree
|
|
19
|
+
key_schedule: KeySchedule
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
# Prevent mutation of the RatchetTree through the immutable EpochState
|
|
23
|
+
|
|
24
|
+
cloned = copy.deepcopy(self.tree)
|
|
25
|
+
cloned.freeze() # STATE-03: enforce immutability after deepcopy
|
|
26
|
+
super().__setattr__("tree", cloned)
|
|
27
|
+
|
|
28
|
+
def advance_epoch(
|
|
29
|
+
self,
|
|
30
|
+
commit_secret: bytes,
|
|
31
|
+
next_tree: RatchetTree,
|
|
32
|
+
group_context: bytes = b"",
|
|
33
|
+
psk_list: list[tuple[PreSharedKeyID, bytes]] | None = None,
|
|
34
|
+
) -> "EpochState":
|
|
35
|
+
"""Transitions the group to the next cryptographic era."""
|
|
36
|
+
next_schedule = KeySchedule.derive(
|
|
37
|
+
init_secret=self.key_schedule.init_secret,
|
|
38
|
+
commit_secret=commit_secret,
|
|
39
|
+
group_context=group_context,
|
|
40
|
+
psk_list=psk_list,
|
|
41
|
+
)
|
|
42
|
+
return EpochState(group_id=self.group_id, epoch_id=self.epoch_id + 1, tree=next_tree, key_schedule=next_schedule)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def genesis(
|
|
46
|
+
cls,
|
|
47
|
+
group_id: bytes,
|
|
48
|
+
creator_tree: RatchetTree,
|
|
49
|
+
group_context_bytes: bytes = b"",
|
|
50
|
+
) -> "EpochState":
|
|
51
|
+
"""Bootstraps Epoch 0 for a brand new sovereign group.
|
|
52
|
+
|
|
53
|
+
RFC 9420 Β§8.1: epoch 0 init_secret is the all-zeros vector.
|
|
54
|
+
|
|
55
|
+
group_context_bytes MUST be the TLS-serialised GroupContext for epoch 0
|
|
56
|
+
(group_id, epoch=0, tree_hash, confirmed_transcript_hash=b\"\").
|
|
57
|
+
Passing b\"\" (default) skips domain separation β use only in tests that
|
|
58
|
+
explicitly target the genesis-only code path without a full group context.
|
|
59
|
+
|
|
60
|
+
The caller (group.py) constructs the GroupContext and passes it here to
|
|
61
|
+
avoid a circular import: epoch.py β group.py β epoch.py.
|
|
62
|
+
"""
|
|
63
|
+
genesis_init = b"\x00" * 32 # RFC 9420 Β§8.1: epoch 0 init_secret = zeros
|
|
64
|
+
blank_commit = b"\x00" * 32
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
group_id=group_id,
|
|
68
|
+
epoch_id=0,
|
|
69
|
+
tree=creator_tree,
|
|
70
|
+
key_schedule=KeySchedule.derive(genesis_init, blank_commit, group_context=group_context_bytes),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def to_bytes(self) -> bytes:
|
|
74
|
+
"""Full EpochState binary dump for persistence."""
|
|
75
|
+
tree_bytes = self.tree.to_bytes()
|
|
76
|
+
return (
|
|
77
|
+
len(self.group_id).to_bytes(2, "big")
|
|
78
|
+
+ self.group_id
|
|
79
|
+
+ self.epoch_id.to_bytes(8, "big")
|
|
80
|
+
+ len(tree_bytes).to_bytes(4, "big")
|
|
81
|
+
+ tree_bytes
|
|
82
|
+
+ self.key_schedule.to_bytes()
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_bytes(cls, data: bytes) -> "EpochState":
|
|
87
|
+
offset = 0
|
|
88
|
+
gid_len = int.from_bytes(data[offset : offset + 2], "big")
|
|
89
|
+
offset += 2
|
|
90
|
+
gid = data[offset : offset + gid_len]
|
|
91
|
+
offset += gid_len
|
|
92
|
+
eid = int.from_bytes(data[offset : offset + 8], "big")
|
|
93
|
+
offset += 8
|
|
94
|
+
t_len = int.from_bytes(data[offset : offset + 4], "big")
|
|
95
|
+
offset += 4
|
|
96
|
+
tree = RatchetTree.from_bytes(data[offset : offset + t_len])
|
|
97
|
+
offset += t_len
|
|
98
|
+
ks = KeySchedule.from_bytes(data[offset : offset + KeySchedule.SIZE])
|
|
99
|
+
return cls(group_id=gid, epoch_id=eid, tree=tree, key_schedule=ks)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""RFC 9420 Β§13.4 Extensions Framework.
|
|
2
|
+
|
|
3
|
+
Extensions are used to provide additional information in various MLS structures:
|
|
4
|
+
- KeyPackage (Β§7)
|
|
5
|
+
- LeafNode (Β§7.2)
|
|
6
|
+
- GroupContext (Β§12.1)
|
|
7
|
+
- Welcome (Β§12.4.3)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from pure_mls.tls import (
|
|
14
|
+
_varint_decode as tls_varint_decode,
|
|
15
|
+
)
|
|
16
|
+
from pure_mls.tls import (
|
|
17
|
+
read_extensions,
|
|
18
|
+
read_opaque,
|
|
19
|
+
read_u8,
|
|
20
|
+
tls_extensions,
|
|
21
|
+
tls_opaque,
|
|
22
|
+
tls_u8,
|
|
23
|
+
tls_u16,
|
|
24
|
+
tls_varint,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Capabilities:
|
|
30
|
+
"""RFC 9420 Β§7.2: Capabilities of a client or group.
|
|
31
|
+
|
|
32
|
+
Fields are vectors (<V>) with VarInt length prefixes, as per OpenMLS interop.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
versions: List[int] = field(default_factory=lambda: [1]) # MLS 1.0 (u16 elements)
|
|
36
|
+
ciphersuites: List[int] = field(default_factory=lambda: [0x0001])
|
|
37
|
+
extensions: List[int] = field(default_factory=lambda: [1, 2, 4]) # Cap, Tree, ExtPSK
|
|
38
|
+
proposals: List[int] = field(default_factory=lambda: [1, 2, 3]) # Add, Update, Remove
|
|
39
|
+
credentials: List[int] = field(default_factory=lambda: [1]) # Basic
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def default(cls) -> "Capabilities":
|
|
43
|
+
"""Returns default capabilities for MLS 1.0."""
|
|
44
|
+
return cls()
|
|
45
|
+
|
|
46
|
+
def marshal(self) -> bytes:
|
|
47
|
+
"""Standard MLS serialization using VarInt prefixes."""
|
|
48
|
+
res = b""
|
|
49
|
+
|
|
50
|
+
def tls_vec_varint(vals: List[int]) -> bytes:
|
|
51
|
+
body = b"".join(tls_u16(v) for v in vals)
|
|
52
|
+
return tls_varint(len(body)) + body
|
|
53
|
+
|
|
54
|
+
res += tls_vec_varint(self.versions)
|
|
55
|
+
res += tls_vec_varint(self.ciphersuites)
|
|
56
|
+
res += tls_vec_varint(self.extensions)
|
|
57
|
+
res += tls_vec_varint(self.proposals)
|
|
58
|
+
res += tls_vec_varint(self.credentials)
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_bytes_at(cls, data: bytes, offset: int = 0) -> tuple["Capabilities", int]:
|
|
63
|
+
"""Context-aware parsing matching OpenMLS wire format."""
|
|
64
|
+
|
|
65
|
+
def read_varint_vec(buf: bytes, off: int) -> tuple[List[int], int]:
|
|
66
|
+
length, off = tls_varint_decode(buf, off)
|
|
67
|
+
raw = buf[off : off + length]
|
|
68
|
+
# Elements are uint16 as per RFC 9420 Appendix B and OpenMLS impl
|
|
69
|
+
values = [int.from_bytes(raw[i : i + 2], "big") for i in range(0, len(raw), 2)]
|
|
70
|
+
return values, off + length
|
|
71
|
+
|
|
72
|
+
# In pure_mls.tls, _varint_decode is the partner to tls_varint
|
|
73
|
+
|
|
74
|
+
versions, offset = read_varint_vec(data, offset)
|
|
75
|
+
ciphersuites, offset = read_varint_vec(data, offset)
|
|
76
|
+
extensions, offset = read_varint_vec(data, offset)
|
|
77
|
+
proposals, offset = read_varint_vec(data, offset)
|
|
78
|
+
credentials, offset = read_varint_vec(data, offset)
|
|
79
|
+
return cls(versions, ciphersuites, extensions, proposals, credentials), offset
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def unmarshal(cls, data: bytes) -> "Capabilities":
|
|
83
|
+
obj, _ = cls.from_bytes_at(data, 0)
|
|
84
|
+
return obj
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RatchetTreeExtension:
|
|
89
|
+
"""RFC 9420 Β§12.4.3.3: Provides the full ratchet tree state."""
|
|
90
|
+
|
|
91
|
+
tree_data: bytes # vec<optional<Node>> in serialized form
|
|
92
|
+
|
|
93
|
+
def marshal(self) -> bytes:
|
|
94
|
+
return self.tree_data
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def unmarshal(cls, data: bytes) -> "RatchetTreeExtension":
|
|
98
|
+
return cls(tree_data=data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ExternalPSKExtension:
|
|
103
|
+
"""RFC 9420 Β§12.4.3.4: Preshared Key ID for external PSK usage."""
|
|
104
|
+
|
|
105
|
+
psk_id: bytes
|
|
106
|
+
psk_nonce: bytes
|
|
107
|
+
|
|
108
|
+
def marshal(self) -> bytes:
|
|
109
|
+
# PreSharedKeyID psk_id;
|
|
110
|
+
# struct { psk_type=external, psk_id<V>, psk_nonce<V> }
|
|
111
|
+
res = tls_u8(1) # external=1
|
|
112
|
+
res += tls_opaque(self.psk_id)
|
|
113
|
+
res += tls_opaque(self.psk_nonce)
|
|
114
|
+
return res
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def unmarshal(cls, data: bytes) -> "ExternalPSKExtension":
|
|
118
|
+
offset = 0
|
|
119
|
+
psk_type, offset = read_u8(data, offset)
|
|
120
|
+
if psk_type != 1:
|
|
121
|
+
raise ValueError(f"Unsupported PSK type: {psk_type}")
|
|
122
|
+
psk_id, offset = read_opaque(data, offset)
|
|
123
|
+
psk_nonce, offset = read_opaque(data, offset)
|
|
124
|
+
return cls(psk_id, psk_nonce)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class GroupContextExtensions:
|
|
129
|
+
"""RFC 9420 Β§12.1.7: Extensions applied to the GroupContext."""
|
|
130
|
+
|
|
131
|
+
extensions: List[tuple[int, bytes]] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
def marshal(self) -> bytes:
|
|
134
|
+
# RFC 9420 Β§12.1.1: Extension extensions<V>;
|
|
135
|
+
return tls_extensions(self.extensions)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def unmarshal(cls, data: bytes) -> "GroupContextExtensions":
|
|
139
|
+
exts, _ = read_extensions(data, 0)
|
|
140
|
+
return cls(extensions=exts)
|