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.
@@ -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)