cairn-p2p 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.
- cairn_p2p-0.2.0/.gitignore +49 -0
- cairn_p2p-0.2.0/PKG-INFO +24 -0
- cairn_p2p-0.2.0/README.md +52 -0
- cairn_p2p-0.2.0/pyproject.toml +40 -0
- cairn_p2p-0.2.0/src/cairn/__init__.py +55 -0
- cairn_p2p-0.2.0/src/cairn/channel.py +179 -0
- cairn_p2p-0.2.0/src/cairn/config.py +175 -0
- cairn_p2p-0.2.0/src/cairn/crypto/__init__.py +79 -0
- cairn_p2p-0.2.0/src/cairn/crypto/aead.py +58 -0
- cairn_p2p-0.2.0/src/cairn/crypto/identity.py +185 -0
- cairn_p2p-0.2.0/src/cairn/crypto/kdf.py +42 -0
- cairn_p2p-0.2.0/src/cairn/crypto/noise.py +445 -0
- cairn_p2p-0.2.0/src/cairn/crypto/ratchet.py +311 -0
- cairn_p2p-0.2.0/src/cairn/crypto/spake2_pake.py +32 -0
- cairn_p2p-0.2.0/src/cairn/crypto/storage.py +206 -0
- cairn_p2p-0.2.0/src/cairn/discovery/__init__.py +39 -0
- cairn_p2p-0.2.0/src/cairn/discovery/mdns.py +660 -0
- cairn_p2p-0.2.0/src/cairn/discovery/rendezvous.py +158 -0
- cairn_p2p-0.2.0/src/cairn/errors.py +167 -0
- cairn_p2p-0.2.0/src/cairn/mesh/__init__.py +32 -0
- cairn_p2p-0.2.0/src/cairn/mesh/relay.py +76 -0
- cairn_p2p-0.2.0/src/cairn/mesh/router.py +207 -0
- cairn_p2p-0.2.0/src/cairn/node.py +498 -0
- cairn_p2p-0.2.0/src/cairn/pairing/__init__.py +57 -0
- cairn_p2p-0.2.0/src/cairn/pairing/adapter.py +46 -0
- cairn_p2p-0.2.0/src/cairn/pairing/link.py +114 -0
- cairn_p2p-0.2.0/src/cairn/pairing/payload.py +83 -0
- cairn_p2p-0.2.0/src/cairn/pairing/pin.py +84 -0
- cairn_p2p-0.2.0/src/cairn/pairing/qr.py +74 -0
- cairn_p2p-0.2.0/src/cairn/pairing/rate_limit.py +132 -0
- cairn_p2p-0.2.0/src/cairn/pairing/sas.py +44 -0
- cairn_p2p-0.2.0/src/cairn/protocol/__init__.py +96 -0
- cairn_p2p-0.2.0/src/cairn/protocol/envelope.py +120 -0
- cairn_p2p-0.2.0/src/cairn/protocol/types.py +75 -0
- cairn_p2p-0.2.0/src/cairn/protocol/version.py +101 -0
- cairn_p2p-0.2.0/src/cairn/server/__init__.py +55 -0
- cairn_p2p-0.2.0/src/cairn/server/forward.py +317 -0
- cairn_p2p-0.2.0/src/cairn/server/management.py +562 -0
- cairn_p2p-0.2.0/src/cairn/session.py +211 -0
- cairn_p2p-0.2.0/src/cairn/transport/__init__.py +55 -0
- cairn_p2p-0.2.0/src/cairn/transport/chain.py +316 -0
- cairn_p2p-0.2.0/src/cairn/transport/heartbeat.py +293 -0
- cairn_p2p-0.2.0/src/cairn/transport/nat.py +282 -0
- cairn_p2p-0.2.0/src/cairn/transport/tcp.py +70 -0
- cairn_p2p-0.2.0/tests/__init__.py +0 -0
- cairn_p2p-0.2.0/tests/test_cbor_vectors.py +161 -0
- cairn_p2p-0.2.0/tests/test_config_api.py +528 -0
- cairn_p2p-0.2.0/tests/test_crypto_primitives.py +312 -0
- cairn_p2p-0.2.0/tests/test_crypto_vectors.py +331 -0
- cairn_p2p-0.2.0/tests/test_discovery.py +424 -0
- cairn_p2p-0.2.0/tests/test_double_ratchet.py +154 -0
- cairn_p2p-0.2.0/tests/test_integration.py +430 -0
- cairn_p2p-0.2.0/tests/test_key_storage.py +193 -0
- cairn_p2p-0.2.0/tests/test_mesh.py +271 -0
- cairn_p2p-0.2.0/tests/test_noise_spake2.py +269 -0
- cairn_p2p-0.2.0/tests/test_pairing.py +255 -0
- cairn_p2p-0.2.0/tests/test_pairing_extras.py +322 -0
- cairn_p2p-0.2.0/tests/test_reconnection.py +332 -0
- cairn_p2p-0.2.0/tests/test_server.py +920 -0
- cairn_p2p-0.2.0/tests/test_sessions.py +466 -0
- cairn_p2p-0.2.0/tests/test_transport.py +399 -0
- cairn_p2p-0.2.0/tests/test_wire_protocol.py +302 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
target/
|
|
2
|
+
|
|
3
|
+
# Dependencies
|
|
4
|
+
node_modules/
|
|
5
|
+
vendor/
|
|
6
|
+
.venv/
|
|
7
|
+
composer.lock
|
|
8
|
+
|
|
9
|
+
# Build artifacts
|
|
10
|
+
dist/
|
|
11
|
+
|
|
12
|
+
# Caches
|
|
13
|
+
__pycache__/
|
|
14
|
+
.phpunit.cache/
|
|
15
|
+
*.pyc
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
*~
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
# Coverage reports
|
|
29
|
+
coverage/
|
|
30
|
+
htmlcov/
|
|
31
|
+
*.lcov
|
|
32
|
+
.nyc_output/
|
|
33
|
+
|
|
34
|
+
# PHP
|
|
35
|
+
.phpunit.result.cache
|
|
36
|
+
composer.phar
|
|
37
|
+
|
|
38
|
+
# Python
|
|
39
|
+
*.egg-info/
|
|
40
|
+
*.egg
|
|
41
|
+
.eggs/
|
|
42
|
+
|
|
43
|
+
# Go
|
|
44
|
+
*.test
|
|
45
|
+
cairn-conformance-runner-go
|
|
46
|
+
|
|
47
|
+
# Environment / secrets
|
|
48
|
+
.env
|
|
49
|
+
.env.*
|
cairn_p2p-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cairn-p2p
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: P2P connectivity library
|
|
5
|
+
Project-URL: Repository, https://github.com/moukrea/cairn
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: base58>=2.1
|
|
9
|
+
Requires-Dist: cbor2>=5.6
|
|
10
|
+
Requires-Dist: cryptography>=42.0
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Requires-Dist: qrcode>=7.4
|
|
14
|
+
Requires-Dist: spake2>=0.9
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
20
|
+
Provides-Extra: discovery
|
|
21
|
+
Requires-Dist: kademlia>=2.2; extra == 'discovery'
|
|
22
|
+
Requires-Dist: zeroconf>=0.131; extra == 'discovery'
|
|
23
|
+
Provides-Extra: libp2p
|
|
24
|
+
Requires-Dist: libp2p; extra == 'libp2p'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# cairn-py
|
|
2
|
+
|
|
3
|
+
Python implementation of the cairn P2P connectivity library.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cairn
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python 3.11+
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
from cairn import CairnNode
|
|
20
|
+
|
|
21
|
+
async def main():
|
|
22
|
+
node = await CairnNode.create()
|
|
23
|
+
peer = await node.pair_with_pin("123456")
|
|
24
|
+
await peer.send(b"hello")
|
|
25
|
+
|
|
26
|
+
asyncio.run(main())
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API Overview
|
|
30
|
+
|
|
31
|
+
- `CairnNode` -- Main entry point, manages identity, sessions, and discovery
|
|
32
|
+
- `Session` -- Persistent encrypted session with a peer
|
|
33
|
+
- `PeerIdentity` -- Ed25519 identity with Peer ID derivation
|
|
34
|
+
- `CairnConfig` -- Configuration with tier presets
|
|
35
|
+
|
|
36
|
+
## Key Dependencies
|
|
37
|
+
|
|
38
|
+
- `cryptography` -- AEAD encryption, key derivation
|
|
39
|
+
- `spake2` -- Password-authenticated key exchange for pairing
|
|
40
|
+
- `cbor2` -- CBOR wire protocol encoding
|
|
41
|
+
- `websockets` -- WebSocket transport
|
|
42
|
+
- `httpx` -- HTTP client for signaling
|
|
43
|
+
|
|
44
|
+
## Optional Dependencies
|
|
45
|
+
|
|
46
|
+
- `libp2p` -- libp2p transport integration
|
|
47
|
+
- `zeroconf` -- mDNS LAN discovery
|
|
48
|
+
- `kademlia` -- DHT discovery
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
Licensed under the [MIT License](../../LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cairn-p2p"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "P2P connectivity library"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"base58>=2.1",
|
|
13
|
+
"cbor2>=5.6",
|
|
14
|
+
"cryptography>=42.0",
|
|
15
|
+
"spake2>=0.9",
|
|
16
|
+
"qrcode>=7.4",
|
|
17
|
+
"websockets>=12.0",
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
"pyyaml>=6.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/moukrea/cairn"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
libp2p = ["libp2p"]
|
|
27
|
+
discovery = ["zeroconf>=0.131", "kademlia>=2.2"]
|
|
28
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.3"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/cairn"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "W", "I"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""cairn - P2P connectivity library."""
|
|
2
|
+
|
|
3
|
+
from cairn.config import (
|
|
4
|
+
CairnConfig,
|
|
5
|
+
MeshSettings,
|
|
6
|
+
ReconnectionPolicy,
|
|
7
|
+
TurnServer,
|
|
8
|
+
)
|
|
9
|
+
from cairn.errors import (
|
|
10
|
+
AuthenticationFailedError,
|
|
11
|
+
CairnError,
|
|
12
|
+
ErrorBehavior,
|
|
13
|
+
MeshRouteNotFoundError,
|
|
14
|
+
PairingExpiredError,
|
|
15
|
+
PairingRejectedError,
|
|
16
|
+
PeerUnreachableError,
|
|
17
|
+
SessionExpiredError,
|
|
18
|
+
TransportExhaustedError,
|
|
19
|
+
VersionMismatchError,
|
|
20
|
+
)
|
|
21
|
+
from cairn.node import (
|
|
22
|
+
Channel,
|
|
23
|
+
NetworkInfo,
|
|
24
|
+
Node,
|
|
25
|
+
NodeEvent,
|
|
26
|
+
NodeEventType,
|
|
27
|
+
Session,
|
|
28
|
+
create,
|
|
29
|
+
create_server,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AuthenticationFailedError",
|
|
34
|
+
"CairnConfig",
|
|
35
|
+
"CairnError",
|
|
36
|
+
"Channel",
|
|
37
|
+
"ErrorBehavior",
|
|
38
|
+
"MeshRouteNotFoundError",
|
|
39
|
+
"MeshSettings",
|
|
40
|
+
"NetworkInfo",
|
|
41
|
+
"Node",
|
|
42
|
+
"NodeEvent",
|
|
43
|
+
"NodeEventType",
|
|
44
|
+
"PairingExpiredError",
|
|
45
|
+
"PairingRejectedError",
|
|
46
|
+
"PeerUnreachableError",
|
|
47
|
+
"ReconnectionPolicy",
|
|
48
|
+
"Session",
|
|
49
|
+
"SessionExpiredError",
|
|
50
|
+
"TransportExhaustedError",
|
|
51
|
+
"TurnServer",
|
|
52
|
+
"VersionMismatchError",
|
|
53
|
+
"create",
|
|
54
|
+
"create_server",
|
|
55
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Channel multiplexing over sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum, auto
|
|
7
|
+
|
|
8
|
+
import cbor2
|
|
9
|
+
|
|
10
|
+
RESERVED_CHANNEL_PREFIX: str = "__cairn_"
|
|
11
|
+
CHANNEL_FORWARD: str = "__cairn_forward"
|
|
12
|
+
CHANNEL_INIT_TYPE: int = 0x0303
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_channel_name(name: str) -> None:
|
|
16
|
+
"""Validate that a channel name is not reserved.
|
|
17
|
+
|
|
18
|
+
Raises ValueError if the name is empty or uses the reserved prefix.
|
|
19
|
+
"""
|
|
20
|
+
if not name:
|
|
21
|
+
raise ValueError("channel name must not be empty")
|
|
22
|
+
if name.startswith(RESERVED_CHANNEL_PREFIX):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"channel name '{name}' uses reserved prefix "
|
|
25
|
+
f"'{RESERVED_CHANNEL_PREFIX}'"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChannelState(Enum):
|
|
30
|
+
"""Channel lifecycle states."""
|
|
31
|
+
|
|
32
|
+
OPENING = auto()
|
|
33
|
+
OPEN = auto()
|
|
34
|
+
REJECTED = auto()
|
|
35
|
+
CLOSED = auto()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Channel:
|
|
39
|
+
"""A named channel multiplexed over a session stream."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
name: str,
|
|
44
|
+
stream_id: int,
|
|
45
|
+
metadata: bytes | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.name = name
|
|
48
|
+
self.stream_id = stream_id
|
|
49
|
+
self.state = ChannelState.OPENING
|
|
50
|
+
self.metadata = metadata
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_open(self) -> bool:
|
|
54
|
+
return self.state == ChannelState.OPEN
|
|
55
|
+
|
|
56
|
+
def accept(self) -> None:
|
|
57
|
+
"""Transition to Open state."""
|
|
58
|
+
if self.state != ChannelState.OPENING:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"cannot accept channel '{self.name}' "
|
|
61
|
+
f"in state {self.state.name}"
|
|
62
|
+
)
|
|
63
|
+
self.state = ChannelState.OPEN
|
|
64
|
+
|
|
65
|
+
def reject(self) -> None:
|
|
66
|
+
"""Transition to Rejected state."""
|
|
67
|
+
if self.state != ChannelState.OPENING:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"cannot reject channel '{self.name}' "
|
|
70
|
+
f"in state {self.state.name}"
|
|
71
|
+
)
|
|
72
|
+
self.state = ChannelState.REJECTED
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Transition to Closed state."""
|
|
76
|
+
if self.state == ChannelState.CLOSED:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"channel '{self.name}' is already closed"
|
|
79
|
+
)
|
|
80
|
+
self.state = ChannelState.CLOSED
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ChannelInit:
|
|
85
|
+
"""First message sent on a newly opened stream."""
|
|
86
|
+
|
|
87
|
+
channel_name: str
|
|
88
|
+
metadata: bytes | None = None
|
|
89
|
+
|
|
90
|
+
def to_cbor(self) -> bytes:
|
|
91
|
+
"""Encode to CBOR."""
|
|
92
|
+
m: dict[str, object] = {
|
|
93
|
+
"channel_name": self.channel_name
|
|
94
|
+
}
|
|
95
|
+
if self.metadata is not None:
|
|
96
|
+
m["metadata"] = self.metadata
|
|
97
|
+
return cbor2.dumps(m)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_cbor(cls, data: bytes) -> ChannelInit:
|
|
101
|
+
"""Decode from CBOR."""
|
|
102
|
+
m = cbor2.loads(data)
|
|
103
|
+
return cls(
|
|
104
|
+
channel_name=m["channel_name"],
|
|
105
|
+
metadata=m.get("metadata"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ChannelManager:
|
|
110
|
+
"""Manages channels within a session."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self._channels: dict[int, Channel] = {}
|
|
114
|
+
|
|
115
|
+
def open_channel(
|
|
116
|
+
self,
|
|
117
|
+
name: str,
|
|
118
|
+
stream_id: int,
|
|
119
|
+
metadata: bytes | None = None,
|
|
120
|
+
) -> ChannelInit:
|
|
121
|
+
"""Open a new channel on a given stream.
|
|
122
|
+
|
|
123
|
+
Returns the ChannelInit payload to send.
|
|
124
|
+
"""
|
|
125
|
+
validate_channel_name(name)
|
|
126
|
+
if stream_id in self._channels:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"stream {stream_id} already has a channel"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
channel = Channel(name, stream_id, metadata)
|
|
132
|
+
self._channels[stream_id] = channel
|
|
133
|
+
return ChannelInit(
|
|
134
|
+
channel_name=name, metadata=metadata
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def handle_channel_init(
|
|
138
|
+
self, stream_id: int, init: ChannelInit
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Handle an incoming ChannelInit from a remote peer."""
|
|
141
|
+
if stream_id in self._channels:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"stream {stream_id} already has a channel"
|
|
144
|
+
)
|
|
145
|
+
channel = Channel(
|
|
146
|
+
init.channel_name, stream_id, init.metadata
|
|
147
|
+
)
|
|
148
|
+
self._channels[stream_id] = channel
|
|
149
|
+
|
|
150
|
+
def accept_channel(self, stream_id: int) -> None:
|
|
151
|
+
"""Accept an incoming channel."""
|
|
152
|
+
channel = self._get_channel(stream_id)
|
|
153
|
+
channel.accept()
|
|
154
|
+
|
|
155
|
+
def reject_channel(self, stream_id: int) -> None:
|
|
156
|
+
"""Reject an incoming channel."""
|
|
157
|
+
channel = self._get_channel(stream_id)
|
|
158
|
+
channel.reject()
|
|
159
|
+
|
|
160
|
+
def close_channel(self, stream_id: int) -> None:
|
|
161
|
+
"""Close a channel."""
|
|
162
|
+
channel = self._get_channel(stream_id)
|
|
163
|
+
channel.close()
|
|
164
|
+
|
|
165
|
+
def get_channel(self, stream_id: int) -> Channel | None:
|
|
166
|
+
"""Get a channel by stream ID."""
|
|
167
|
+
return self._channels.get(stream_id)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def channel_count(self) -> int:
|
|
171
|
+
return len(self._channels)
|
|
172
|
+
|
|
173
|
+
def _get_channel(self, stream_id: int) -> Channel:
|
|
174
|
+
channel = self._channels.get(stream_id)
|
|
175
|
+
if channel is None:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"no channel on stream {stream_id}"
|
|
178
|
+
)
|
|
179
|
+
return channel
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Configuration builder with tier presets (spec 11, section 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Default STUN servers
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
DEFAULT_STUN_SERVERS: list[str] = [
|
|
12
|
+
"stun:stun.l.google.com:19302",
|
|
13
|
+
"stun:stun1.l.google.com:19302",
|
|
14
|
+
"stun:stun.cloudflare.com:3478",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Supporting dataclasses
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TurnServer:
|
|
25
|
+
"""TURN relay server credentials."""
|
|
26
|
+
|
|
27
|
+
url: str = ""
|
|
28
|
+
username: str = ""
|
|
29
|
+
credential: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ReconnectionPolicy:
|
|
34
|
+
"""Reconnection and timeout policy (spec section 2.2)."""
|
|
35
|
+
|
|
36
|
+
connect_timeout: float = 30.0
|
|
37
|
+
transport_timeout: float = 10.0
|
|
38
|
+
reconnect_max_duration: float = 3600.0
|
|
39
|
+
reconnect_backoff_initial: float = 1.0
|
|
40
|
+
reconnect_backoff_max: float = 60.0
|
|
41
|
+
reconnect_backoff_factor: float = 2.0
|
|
42
|
+
rendezvous_poll_interval: float = 30.0
|
|
43
|
+
session_expiry: float = 86400.0
|
|
44
|
+
pairing_payload_expiry: float = 300.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MeshSettings:
|
|
49
|
+
"""Mesh routing settings (spec section 1.2)."""
|
|
50
|
+
|
|
51
|
+
mesh_enabled: bool = False
|
|
52
|
+
max_hops: int = 3
|
|
53
|
+
relay_willing: bool = False
|
|
54
|
+
relay_capacity: int = 10
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# CairnConfig
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CairnConfig:
|
|
64
|
+
"""Top-level configuration (spec section 1.1).
|
|
65
|
+
|
|
66
|
+
Every field has a sensible default, enabling zero-config usage (Tier 0).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
stun_servers: list[str] = field(
|
|
70
|
+
default_factory=lambda: list(DEFAULT_STUN_SERVERS)
|
|
71
|
+
)
|
|
72
|
+
turn_servers: list[TurnServer] = field(
|
|
73
|
+
default_factory=list
|
|
74
|
+
)
|
|
75
|
+
signaling_servers: list[str] = field(
|
|
76
|
+
default_factory=list
|
|
77
|
+
)
|
|
78
|
+
tracker_urls: list[str] = field(default_factory=list)
|
|
79
|
+
bootstrap_nodes: list[str] = field(default_factory=list)
|
|
80
|
+
reconnection_policy: ReconnectionPolicy = field(
|
|
81
|
+
default_factory=ReconnectionPolicy
|
|
82
|
+
)
|
|
83
|
+
mesh_settings: MeshSettings = field(
|
|
84
|
+
default_factory=MeshSettings
|
|
85
|
+
)
|
|
86
|
+
server_mode: bool = False
|
|
87
|
+
|
|
88
|
+
# --- Tier presets ---
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def tier0(cls) -> CairnConfig:
|
|
92
|
+
"""Tier 0: fully decentralized, zero-config."""
|
|
93
|
+
return cls()
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def tier1(
|
|
97
|
+
cls,
|
|
98
|
+
signaling_servers: list[str] | None = None,
|
|
99
|
+
turn_servers: list[TurnServer] | None = None,
|
|
100
|
+
) -> CairnConfig:
|
|
101
|
+
"""Tier 1: add signaling server and optional TURN relay."""
|
|
102
|
+
return cls(
|
|
103
|
+
signaling_servers=signaling_servers or [],
|
|
104
|
+
turn_servers=turn_servers or [],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def tier2(
|
|
109
|
+
cls,
|
|
110
|
+
signaling_servers: list[str] | None = None,
|
|
111
|
+
turn_servers: list[TurnServer] | None = None,
|
|
112
|
+
tracker_urls: list[str] | None = None,
|
|
113
|
+
bootstrap_nodes: list[str] | None = None,
|
|
114
|
+
) -> CairnConfig:
|
|
115
|
+
"""Tier 2: self-hosted infrastructure."""
|
|
116
|
+
return cls(
|
|
117
|
+
signaling_servers=signaling_servers or [],
|
|
118
|
+
turn_servers=turn_servers or [],
|
|
119
|
+
tracker_urls=tracker_urls or [],
|
|
120
|
+
bootstrap_nodes=bootstrap_nodes or [],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def tier3(
|
|
125
|
+
cls,
|
|
126
|
+
signaling_servers: list[str] | None = None,
|
|
127
|
+
turn_servers: list[TurnServer] | None = None,
|
|
128
|
+
tracker_urls: list[str] | None = None,
|
|
129
|
+
bootstrap_nodes: list[str] | None = None,
|
|
130
|
+
mesh_settings: MeshSettings | None = None,
|
|
131
|
+
) -> CairnConfig:
|
|
132
|
+
"""Tier 3: fully self-hosted with mesh routing."""
|
|
133
|
+
return cls(
|
|
134
|
+
signaling_servers=signaling_servers or [],
|
|
135
|
+
turn_servers=turn_servers or [],
|
|
136
|
+
tracker_urls=tracker_urls or [],
|
|
137
|
+
bootstrap_nodes=bootstrap_nodes or [],
|
|
138
|
+
mesh_settings=mesh_settings or MeshSettings(
|
|
139
|
+
mesh_enabled=True
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def default_server(cls) -> CairnConfig:
|
|
145
|
+
"""Default server-mode config."""
|
|
146
|
+
return cls(
|
|
147
|
+
server_mode=True,
|
|
148
|
+
reconnection_policy=ReconnectionPolicy(
|
|
149
|
+
session_expiry=7 * 86400.0,
|
|
150
|
+
),
|
|
151
|
+
mesh_settings=MeshSettings(
|
|
152
|
+
relay_willing=True,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def validate(self) -> None:
|
|
157
|
+
"""Validate configuration. Raises ValueError on invalid settings."""
|
|
158
|
+
if not self.stun_servers and not self.turn_servers:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"stun_servers must not be empty unless "
|
|
161
|
+
"turn_servers are configured"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if self.reconnection_policy.reconnect_backoff_factor <= 1.0:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"reconnect_backoff_factor must be greater than 1.0"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
self.mesh_settings.max_hops < 1
|
|
171
|
+
or self.mesh_settings.max_hops > 10
|
|
172
|
+
):
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"max_hops must be between 1 and 10"
|
|
175
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cryptographic primitives: identity, key exchange, AEAD, HKDF, Noise, SPAKE2."""
|
|
2
|
+
|
|
3
|
+
from cairn.crypto.aead import (
|
|
4
|
+
AES_GCM_TAG_SIZE,
|
|
5
|
+
CHACHA_TAG_SIZE,
|
|
6
|
+
KEY_SIZE,
|
|
7
|
+
NONCE_SIZE,
|
|
8
|
+
CipherSuite,
|
|
9
|
+
aead_decrypt,
|
|
10
|
+
aead_encrypt,
|
|
11
|
+
)
|
|
12
|
+
from cairn.crypto.identity import (
|
|
13
|
+
IdentityKeypair,
|
|
14
|
+
PeerId,
|
|
15
|
+
X25519Keypair,
|
|
16
|
+
peer_id_from_public_key,
|
|
17
|
+
verify_signature,
|
|
18
|
+
)
|
|
19
|
+
from cairn.crypto.kdf import (
|
|
20
|
+
HKDF_INFO_CHAIN_KEY,
|
|
21
|
+
HKDF_INFO_MESSAGE_KEY,
|
|
22
|
+
HKDF_INFO_RENDEZVOUS,
|
|
23
|
+
HKDF_INFO_SAS,
|
|
24
|
+
HKDF_INFO_SESSION_KEY,
|
|
25
|
+
hkdf_sha256,
|
|
26
|
+
)
|
|
27
|
+
from cairn.crypto.noise import (
|
|
28
|
+
EMOJI_TABLE,
|
|
29
|
+
HandshakeResult,
|
|
30
|
+
NoiseXXHandshake,
|
|
31
|
+
Role,
|
|
32
|
+
derive_emoji_sas,
|
|
33
|
+
derive_numeric_sas,
|
|
34
|
+
)
|
|
35
|
+
from cairn.crypto.ratchet import (
|
|
36
|
+
DoubleRatchet,
|
|
37
|
+
RatchetConfig,
|
|
38
|
+
RatchetHeader,
|
|
39
|
+
)
|
|
40
|
+
from cairn.crypto.spake2_pake import Spake2Session
|
|
41
|
+
from cairn.crypto.storage import (
|
|
42
|
+
FilesystemKeyStorage,
|
|
43
|
+
InMemoryKeyStorage,
|
|
44
|
+
KeyStorage,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AES_GCM_TAG_SIZE",
|
|
49
|
+
"CHACHA_TAG_SIZE",
|
|
50
|
+
"CipherSuite",
|
|
51
|
+
"DoubleRatchet",
|
|
52
|
+
"EMOJI_TABLE",
|
|
53
|
+
"HKDF_INFO_CHAIN_KEY",
|
|
54
|
+
"HKDF_INFO_MESSAGE_KEY",
|
|
55
|
+
"HKDF_INFO_RENDEZVOUS",
|
|
56
|
+
"HKDF_INFO_SAS",
|
|
57
|
+
"HKDF_INFO_SESSION_KEY",
|
|
58
|
+
"HandshakeResult",
|
|
59
|
+
"IdentityKeypair",
|
|
60
|
+
"KEY_SIZE",
|
|
61
|
+
"NONCE_SIZE",
|
|
62
|
+
"NoiseXXHandshake",
|
|
63
|
+
"PeerId",
|
|
64
|
+
"RatchetConfig",
|
|
65
|
+
"RatchetHeader",
|
|
66
|
+
"Role",
|
|
67
|
+
"Spake2Session",
|
|
68
|
+
"X25519Keypair",
|
|
69
|
+
"FilesystemKeyStorage",
|
|
70
|
+
"InMemoryKeyStorage",
|
|
71
|
+
"KeyStorage",
|
|
72
|
+
"aead_decrypt",
|
|
73
|
+
"aead_encrypt",
|
|
74
|
+
"derive_emoji_sas",
|
|
75
|
+
"derive_numeric_sas",
|
|
76
|
+
"hkdf_sha256",
|
|
77
|
+
"peer_id_from_public_key",
|
|
78
|
+
"verify_signature",
|
|
79
|
+
]
|