rfed 0.1.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.
- rfed-0.1.0/PKG-INFO +118 -0
- rfed-0.1.0/README.md +92 -0
- rfed-0.1.0/pyproject.toml +42 -0
- rfed-0.1.0/rfed/__init__.py +25 -0
- rfed-0.1.0/rfed/_lxmf.py +181 -0
- rfed-0.1.0/rfed/blob.py +220 -0
- rfed-0.1.0/rfed/channel.py +94 -0
- rfed-0.1.0/rfed/client.py +614 -0
- rfed-0.1.0/rfed/constants.py +82 -0
- rfed-0.1.0/rfed/stamp.py +155 -0
- rfed-0.1.0/rfed.egg-info/PKG-INFO +118 -0
- rfed-0.1.0/rfed.egg-info/SOURCES.txt +17 -0
- rfed-0.1.0/rfed.egg-info/dependency_links.txt +1 -0
- rfed-0.1.0/rfed.egg-info/requires.txt +2 -0
- rfed-0.1.0/rfed.egg-info/top_level.txt +1 -0
- rfed-0.1.0/setup.cfg +4 -0
- rfed-0.1.0/tests/test_rfed_client_response.py +331 -0
- rfed-0.1.0/tests/test_rfed_publish_link.py +192 -0
- rfed-0.1.0/tests/test_rfed_stamp.py +136 -0
rfed-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rfed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for RFed (Reticulum Federation) channels
|
|
5
|
+
Author: Henri Bergius
|
|
6
|
+
License: EUPL-1.2
|
|
7
|
+
Project-URL: Homepage, https://github.com/bergie/rfed-python
|
|
8
|
+
Project-URL: Repository, https://github.com/bergie/rfed-python
|
|
9
|
+
Project-URL: Issues, https://github.com/bergie/rfed-python/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/bergie/rfed-python/releases
|
|
11
|
+
Project-URL: Specification, https://github.com/jrl290/RFed
|
|
12
|
+
Project-URL: Related, https://github.com/bergie/dacar
|
|
13
|
+
Keywords: reticulum,rns,rfed,federation,lxmf,mesh,offline,pubsub
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Communications
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: msgpack>=1.0
|
|
25
|
+
Requires-Dist: rns>=1.5.4
|
|
26
|
+
|
|
27
|
+
# rfed-python
|
|
28
|
+
|
|
29
|
+
Python client for **[RFed (Reticulum Federation)](https://github.com/jrl290/RFed)**
|
|
30
|
+
channels — publish/subscribe over [Reticulum](https://github.com/markqvist/Reticulum).
|
|
31
|
+
|
|
32
|
+
A from-scratch port of the canonical `@reticulum/core` `RFedClient`
|
|
33
|
+
(JavaScript) and the [RFed spec](https://github.com/jrl290/RFed)'s `SPEC.md`
|
|
34
|
+
"CANONICAL WIRE FORMAT" (Rust reference node), byte-compatible with both: channel hashes, EC envelopes, LXMF tails,
|
|
35
|
+
and PoW stamps cross-validate across implementations. Extracted from the
|
|
36
|
+
[Dacar](https://github.com/bergie/dacar) Python implementation, where it was
|
|
37
|
+
originally developed.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install rfed
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Dependencies: [`rns`](https://pypi.org/project/rns/) (>= 1.5.4) and
|
|
46
|
+
`msgpack` — the same stack the rest of the Reticulum ecosystem uses.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import RNS
|
|
52
|
+
from rfed._lxmf import LxmfMessage
|
|
53
|
+
from rfed.client import RFedClient
|
|
54
|
+
|
|
55
|
+
reticulum = RNS.Reticulum() # boots a transport (e.g. AutoInterface)
|
|
56
|
+
identity = RNS.Identity() # your subscriber identity
|
|
57
|
+
client = RFedClient(identity, reticulum)
|
|
58
|
+
|
|
59
|
+
NODE = bytes.fromhex("…") # any rfed.* destination hash of a node
|
|
60
|
+
CHANNEL = "my.channel.v1"
|
|
61
|
+
|
|
62
|
+
# Subscribe — also caches the node's advertised PoW stamp cost.
|
|
63
|
+
result = client.subscribe(NODE, CHANNEL)
|
|
64
|
+
print(result.ok, result.stamp_cost)
|
|
65
|
+
|
|
66
|
+
# Publish an LXMF-envelope message (fire-and-forget SEND).
|
|
67
|
+
message = LxmfMessage(content=b"hello over rfed")
|
|
68
|
+
client.publish(NODE, CHANNEL, message)
|
|
69
|
+
|
|
70
|
+
# Listen for live fanout deliveries on your rfed.delivery destination.
|
|
71
|
+
def on_message(decoded):
|
|
72
|
+
print(decoded.message.content, decoded.signature_valid)
|
|
73
|
+
|
|
74
|
+
client.listen(on_message)
|
|
75
|
+
|
|
76
|
+
# Or catch up after being offline: drain the node's deferred queue.
|
|
77
|
+
page = client.pull(NODE, CHANNEL)
|
|
78
|
+
while page.more_pending:
|
|
79
|
+
page = client.pull(NODE, CHANNEL)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
RFed treats the encrypted `inner_blob` **opaquely**, so applications may
|
|
83
|
+
carry their own inner format instead of the LXMF envelope (Dacar, for
|
|
84
|
+
example, ships a compact Delta format that fits more payload under the RNS
|
|
85
|
+
MTU). For that, use the raw primitives:
|
|
86
|
+
|
|
87
|
+
- `client.send_publish(node_hash, rfed_payload)` — fire-and-forget SEND of a
|
|
88
|
+
pre-wrapped payload,
|
|
89
|
+
- `client.listen_raw(on_fanout)` — undecoded `(channel_name,
|
|
90
|
+
channel_identity, inner_blob)` deliveries, decrypt/decode yourself.
|
|
91
|
+
|
|
92
|
+
The pure codec modules (`rfed.constants`, `rfed.channel`, `rfed._lxmf`,
|
|
93
|
+
`rfed.blob`, `rfed.stamp`) work **without a running Reticulum** — only
|
|
94
|
+
`RFedClient`'s network methods need a live transport.
|
|
95
|
+
|
|
96
|
+
## Wire format
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
plaintext = "RTID"(4) ‖ sender_identity_pub(64) ‖ LXMF_tail
|
|
100
|
+
LXMF_tail = source_hash(16) ‖ signature(64) ‖ msgpack_payload
|
|
101
|
+
inner_blob = EC_encrypt(channel_identity.X25519_pub, plaintext)
|
|
102
|
+
rfed_payload = channel_hash(16) ‖ inner_blob ‖ stamp(32)?
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Channels are derived deterministically from their name
|
|
106
|
+
(`rfed.channel.derive_channel`), so any party knowing the name can derive the
|
|
107
|
+
shared keypair — no registration. Stamps use the standard LXMF PoW mechanism
|
|
108
|
+
(memory-hard HKDF workblock) at rfed's 16 expansion rounds.
|
|
109
|
+
|
|
110
|
+
## Status
|
|
111
|
+
|
|
112
|
+
Early beta (0.x). The wire format follows the RFed spec's "CANONICAL WIRE
|
|
113
|
+
FORMAT" and cross-validates with `@reticulum/core` (JS) and the Rust
|
|
114
|
+
reference node, but the RFed protocol itself is still evolving.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
EUPL-1.2, same as Dacar and Reticulum.
|
rfed-0.1.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# rfed-python
|
|
2
|
+
|
|
3
|
+
Python client for **[RFed (Reticulum Federation)](https://github.com/jrl290/RFed)**
|
|
4
|
+
channels — publish/subscribe over [Reticulum](https://github.com/markqvist/Reticulum).
|
|
5
|
+
|
|
6
|
+
A from-scratch port of the canonical `@reticulum/core` `RFedClient`
|
|
7
|
+
(JavaScript) and the [RFed spec](https://github.com/jrl290/RFed)'s `SPEC.md`
|
|
8
|
+
"CANONICAL WIRE FORMAT" (Rust reference node), byte-compatible with both: channel hashes, EC envelopes, LXMF tails,
|
|
9
|
+
and PoW stamps cross-validate across implementations. Extracted from the
|
|
10
|
+
[Dacar](https://github.com/bergie/dacar) Python implementation, where it was
|
|
11
|
+
originally developed.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install rfed
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Dependencies: [`rns`](https://pypi.org/project/rns/) (>= 1.5.4) and
|
|
20
|
+
`msgpack` — the same stack the rest of the Reticulum ecosystem uses.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import RNS
|
|
26
|
+
from rfed._lxmf import LxmfMessage
|
|
27
|
+
from rfed.client import RFedClient
|
|
28
|
+
|
|
29
|
+
reticulum = RNS.Reticulum() # boots a transport (e.g. AutoInterface)
|
|
30
|
+
identity = RNS.Identity() # your subscriber identity
|
|
31
|
+
client = RFedClient(identity, reticulum)
|
|
32
|
+
|
|
33
|
+
NODE = bytes.fromhex("…") # any rfed.* destination hash of a node
|
|
34
|
+
CHANNEL = "my.channel.v1"
|
|
35
|
+
|
|
36
|
+
# Subscribe — also caches the node's advertised PoW stamp cost.
|
|
37
|
+
result = client.subscribe(NODE, CHANNEL)
|
|
38
|
+
print(result.ok, result.stamp_cost)
|
|
39
|
+
|
|
40
|
+
# Publish an LXMF-envelope message (fire-and-forget SEND).
|
|
41
|
+
message = LxmfMessage(content=b"hello over rfed")
|
|
42
|
+
client.publish(NODE, CHANNEL, message)
|
|
43
|
+
|
|
44
|
+
# Listen for live fanout deliveries on your rfed.delivery destination.
|
|
45
|
+
def on_message(decoded):
|
|
46
|
+
print(decoded.message.content, decoded.signature_valid)
|
|
47
|
+
|
|
48
|
+
client.listen(on_message)
|
|
49
|
+
|
|
50
|
+
# Or catch up after being offline: drain the node's deferred queue.
|
|
51
|
+
page = client.pull(NODE, CHANNEL)
|
|
52
|
+
while page.more_pending:
|
|
53
|
+
page = client.pull(NODE, CHANNEL)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
RFed treats the encrypted `inner_blob` **opaquely**, so applications may
|
|
57
|
+
carry their own inner format instead of the LXMF envelope (Dacar, for
|
|
58
|
+
example, ships a compact Delta format that fits more payload under the RNS
|
|
59
|
+
MTU). For that, use the raw primitives:
|
|
60
|
+
|
|
61
|
+
- `client.send_publish(node_hash, rfed_payload)` — fire-and-forget SEND of a
|
|
62
|
+
pre-wrapped payload,
|
|
63
|
+
- `client.listen_raw(on_fanout)` — undecoded `(channel_name,
|
|
64
|
+
channel_identity, inner_blob)` deliveries, decrypt/decode yourself.
|
|
65
|
+
|
|
66
|
+
The pure codec modules (`rfed.constants`, `rfed.channel`, `rfed._lxmf`,
|
|
67
|
+
`rfed.blob`, `rfed.stamp`) work **without a running Reticulum** — only
|
|
68
|
+
`RFedClient`'s network methods need a live transport.
|
|
69
|
+
|
|
70
|
+
## Wire format
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
plaintext = "RTID"(4) ‖ sender_identity_pub(64) ‖ LXMF_tail
|
|
74
|
+
LXMF_tail = source_hash(16) ‖ signature(64) ‖ msgpack_payload
|
|
75
|
+
inner_blob = EC_encrypt(channel_identity.X25519_pub, plaintext)
|
|
76
|
+
rfed_payload = channel_hash(16) ‖ inner_blob ‖ stamp(32)?
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Channels are derived deterministically from their name
|
|
80
|
+
(`rfed.channel.derive_channel`), so any party knowing the name can derive the
|
|
81
|
+
shared keypair — no registration. Stamps use the standard LXMF PoW mechanism
|
|
82
|
+
(memory-hard HKDF workblock) at rfed's 16 expansion rounds.
|
|
83
|
+
|
|
84
|
+
## Status
|
|
85
|
+
|
|
86
|
+
Early beta (0.x). The wire format follows the RFed spec's "CANONICAL WIRE
|
|
87
|
+
FORMAT" and cross-validates with `@reticulum/core` (JS) and the Rust
|
|
88
|
+
reference node, but the RFed protocol itself is still evolving.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
EUPL-1.2, same as Dacar and Reticulum.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rfed"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for RFed (Reticulum Federation) channels"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "EUPL-1.2" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Henri Bergius" }]
|
|
13
|
+
keywords = ["reticulum", "rns", "rfed", "federation", "lxmf", "mesh", "offline", "pubsub"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Communications",
|
|
22
|
+
"Topic :: System :: Networking",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# LXMF-tail msgpack payload codec.
|
|
26
|
+
"msgpack>=1.0",
|
|
27
|
+
# Reticulum Network Stack: destinations, links, requests, crypto
|
|
28
|
+
# (pulls in `cryptography`). 1.5.4 pairs with the @reticulum/* 0.8.2
|
|
29
|
+
# wire protocol (rfed link publishes, pull error codes).
|
|
30
|
+
"rns>=1.5.4",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/bergie/rfed-python"
|
|
35
|
+
Repository = "https://github.com/bergie/rfed-python"
|
|
36
|
+
Issues = "https://github.com/bergie/rfed-python/issues"
|
|
37
|
+
Changelog = "https://github.com/bergie/rfed-python/releases"
|
|
38
|
+
Specification = "https://github.com/jrl290/RFed"
|
|
39
|
+
Related = "https://github.com/bergie/dacar"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
packages = ["rfed"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""RFed (Reticulum Federation) channel client for Python.
|
|
2
|
+
|
|
3
|
+
A from-scratch Python port of the canonical ``@reticulum/core`` ``RFedClient``
|
|
4
|
+
(JS) and the ``RFed/SPEC.md`` "CANONICAL WIRE FORMAT" (Rust). Extracted from
|
|
5
|
+
the Dacar Python implementation (https://github.com/bergie/dacar), where it
|
|
6
|
+
was originally developed.
|
|
7
|
+
|
|
8
|
+
It depends only on ``rns`` + ``msgpack`` (+ ``cryptography`` via ``rns``).
|
|
9
|
+
The pure codec modules (:mod:`rfed.constants`, :mod:`rfed.channel`,
|
|
10
|
+
:mod:`rfed._lxmf`, :mod:`rfed.blob`, :mod:`rfed.stamp`) work **without a
|
|
11
|
+
running Reticulum**; only :class:`rfed.client.RFedClient`'s network methods
|
|
12
|
+
need a live transport (``RNS.Destination`` / ``RNS.Link`` creation).
|
|
13
|
+
|
|
14
|
+
Submodules:
|
|
15
|
+
- :mod:`rfed.constants` — wire-format constants.
|
|
16
|
+
- :mod:`rfed.channel` — deterministic channel derivation.
|
|
17
|
+
- :mod:`rfed._lxmf` — minimal canonical LXMF wire codec.
|
|
18
|
+
- :mod:`rfed.blob` — Phase-0 RTID envelope (wrap/unwrap).
|
|
19
|
+
- :mod:`rfed.stamp` — rfed PoW stamp contract.
|
|
20
|
+
- :mod:`rfed.client` — ``RFedClient`` (subscribe/publish/pull/listen).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
__all__: list[str] = [] # submodules imported explicitly by callers
|
rfed-0.1.0/rfed/_lxmf.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Minimal canonical LXMF message wire codec.
|
|
2
|
+
|
|
3
|
+
The rfed channel envelope wraps a propagation-style LXMF message (§5.2/§5.1).
|
|
4
|
+
Rather than depend on the external ``lxmf`` package — whose
|
|
5
|
+
:class:`LXMF.LXMessage.pack()` requires a real :class:`RNS.Destination` (and
|
|
6
|
+
thus a running Reticulum) — this module reimplements just the wire format. It
|
|
7
|
+
is byte-for-byte compatible with Python LXMF's ``pack()`` /
|
|
8
|
+
``unpack_from_bytes()`` and with ``@reticulum/core``'s ``Message.serialize``,
|
|
9
|
+
so signatures and hashes cross-validate across all three.
|
|
10
|
+
|
|
11
|
+
Wire format::
|
|
12
|
+
|
|
13
|
+
direct: destination_hash(16) ‖ source_hash(16) ‖ signature(64) ‖ msgpack_payload
|
|
14
|
+
payload: [timestamp(float64), title(bin), content(bin), fields(map)] [, stamp(bin32)]
|
|
15
|
+
|
|
16
|
+
Signature (§5.5) is computed over ``destination_hash ‖ source_hash ‖
|
|
17
|
+
msgpack_payload ‖ message_hash``, where ``message_hash = SHA-256(dest ‖ src ‖
|
|
18
|
+
msgpack_payload)``. The optional 5th stamp element is stripped before hashing
|
|
19
|
+
(matching ``LXMessage.unpack_from_bytes``).
|
|
20
|
+
|
|
21
|
+
Uses :mod:`RNS` only for crypto (``full_hash`` / ``sign`` / ``validate``) and
|
|
22
|
+
:mod:`msgpack` for the payload, so it works without a running Reticulum.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Dict, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
import msgpack
|
|
31
|
+
import RNS
|
|
32
|
+
|
|
33
|
+
__all__ = ["DESTINATION_LENGTH", "SIGNATURE_LENGTH", "LxmfMessage"]
|
|
34
|
+
|
|
35
|
+
#: LXMF destination / source hash length (``TRUNCATED_HASHLENGTH // 8``).
|
|
36
|
+
DESTINATION_LENGTH = 16
|
|
37
|
+
#: Ed25519 signature length (``SIGLENGTH // 8``).
|
|
38
|
+
SIGNATURE_LENGTH = 64
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_bytes(value) -> bytes:
|
|
42
|
+
"""Coerce a str/bytes value to bytes (UTF-8) for bin encoding."""
|
|
43
|
+
if value is None:
|
|
44
|
+
return b""
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
return value.encode("utf-8")
|
|
47
|
+
if isinstance(value, (bytes, bytearray)):
|
|
48
|
+
return bytes(value)
|
|
49
|
+
raise TypeError(f"expected str or bytes, got {type(value).__name__}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pack_payload(
|
|
53
|
+
timestamp: float,
|
|
54
|
+
title: bytes,
|
|
55
|
+
content: bytes,
|
|
56
|
+
fields: Dict[str, Any],
|
|
57
|
+
stamp: Optional[bytes] = None,
|
|
58
|
+
) -> bytes:
|
|
59
|
+
"""Canonical msgpack payload: float64 timestamp, bin title/content, map fields.
|
|
60
|
+
|
|
61
|
+
``use_single_float=False`` forces float64 (matching JS ``encodeFloat64``),
|
|
62
|
+
and bytes encode as bin (``use_bin_type`` default).
|
|
63
|
+
"""
|
|
64
|
+
payload: list = [timestamp, title, content, fields]
|
|
65
|
+
if stamp is not None:
|
|
66
|
+
payload.append(stamp)
|
|
67
|
+
return msgpack.packb(payload, use_single_float=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LxmfMessage:
|
|
71
|
+
"""A minimal LXMF message carrying an application payload.
|
|
72
|
+
|
|
73
|
+
``destination_hash`` / ``source_hash`` are the ``lxmf.delivery`` destination
|
|
74
|
+
hashes (channel and sender respectively) — NOT bare identity hashes. For a
|
|
75
|
+
channel message these are forced by :func:`rfed.blob.wrap_channel_message`
|
|
76
|
+
before serialization, so placeholders are fine pre-publish.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
destination_hash: bytes = b"\x00" * 16,
|
|
82
|
+
source_hash: bytes = b"\x00" * 16,
|
|
83
|
+
content: bytes = b"",
|
|
84
|
+
title: bytes = b"",
|
|
85
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
86
|
+
timestamp: Optional[float] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.destination_hash = bytes(destination_hash)
|
|
89
|
+
self.source_hash = bytes(source_hash)
|
|
90
|
+
self.content = _to_bytes(content)
|
|
91
|
+
self.title = _to_bytes(title)
|
|
92
|
+
self.fields: Dict[str, Any] = dict(fields) if fields else {}
|
|
93
|
+
self.timestamp = timestamp
|
|
94
|
+
self.signature: Optional[bytes] = None
|
|
95
|
+
#: message_id = SHA-256(dest ‖ src ‖ msgpack_payload).
|
|
96
|
+
self.hash: Optional[bytes] = None
|
|
97
|
+
self.signature_validated: bool = False
|
|
98
|
+
#: Optional LXMF PoW stamp (5th payload element); rfed uses its own stamp.
|
|
99
|
+
self.stamp: Optional[bytes] = None
|
|
100
|
+
|
|
101
|
+
def serialize(self, source_identity: RNS.Identity) -> bytes:
|
|
102
|
+
"""Sign and serialize to the canonical LXMF wire format.
|
|
103
|
+
|
|
104
|
+
Sets :attr:`hash` (message_id), :attr:`signature`, and returns the wire
|
|
105
|
+
bytes ``dest ‖ source ‖ signature ‖ msgpack_payload``.
|
|
106
|
+
"""
|
|
107
|
+
if self.timestamp is None:
|
|
108
|
+
self.timestamp = time.time()
|
|
109
|
+
packed = _pack_payload(
|
|
110
|
+
self.timestamp, self.title, self.content, self.fields, stamp=None
|
|
111
|
+
)
|
|
112
|
+
hashed = self.destination_hash + self.source_hash + packed
|
|
113
|
+
self.hash = RNS.Identity.full_hash(hashed)
|
|
114
|
+
self.signature = source_identity.sign(hashed + self.hash)
|
|
115
|
+
return self.destination_hash + self.source_hash + self.signature + packed
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def deserialize(
|
|
119
|
+
wire: bytes, sender_pub: bytes
|
|
120
|
+
) -> "LxmfMessage":
|
|
121
|
+
"""Reconstruct and signature-verify an LXMF message from wire bytes.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
wire:
|
|
126
|
+
``dest(16) ‖ source(16) ‖ signature(64) ‖ msgpack_payload``.
|
|
127
|
+
sender_pub:
|
|
128
|
+
The 64-byte sender public-key bundle (from the rfed RTID prelude),
|
|
129
|
+
used to verify the Ed25519 signature.
|
|
130
|
+
|
|
131
|
+
The hash is computed over the received ``msgpack_payload`` bytes when the
|
|
132
|
+
payload has exactly 4 elements (the canonical, unambiguous path); a 5th
|
|
133
|
+
stamp element is stripped and the first 4 re-packed before hashing,
|
|
134
|
+
matching ``LXMF.LXMessage.unpack_from_bytes``.
|
|
135
|
+
"""
|
|
136
|
+
if len(wire) < 2 * DESTINATION_LENGTH + SIGNATURE_LENGTH:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"LXMF wire too short: {len(wire)} bytes "
|
|
139
|
+
f"(need at least {2 * DESTINATION_LENGTH + SIGNATURE_LENGTH})"
|
|
140
|
+
)
|
|
141
|
+
dest_hash = wire[:DESTINATION_LENGTH]
|
|
142
|
+
source_hash = wire[DESTINATION_LENGTH : 2 * DESTINATION_LENGTH]
|
|
143
|
+
signature = wire[
|
|
144
|
+
2 * DESTINATION_LENGTH : 2 * DESTINATION_LENGTH + SIGNATURE_LENGTH
|
|
145
|
+
]
|
|
146
|
+
packed = wire[2 * DESTINATION_LENGTH + SIGNATURE_LENGTH :]
|
|
147
|
+
|
|
148
|
+
unpacked = msgpack.unpackb(packed, raw=False, strict_map_key=False)
|
|
149
|
+
if not isinstance(unpacked, list) or len(unpacked) < 4:
|
|
150
|
+
raise ValueError("LXMF payload is not a 4+-element msgpack array")
|
|
151
|
+
stamp: Optional[bytes] = None
|
|
152
|
+
if len(unpacked) > 4:
|
|
153
|
+
stamp = unpacked[4]
|
|
154
|
+
unpacked = unpacked[:4]
|
|
155
|
+
# Re-pack the 4 elements for hashing (matches LXMF unpack_from_bytes).
|
|
156
|
+
packed_for_hash = msgpack.packb(unpacked, use_single_float=False)
|
|
157
|
+
else:
|
|
158
|
+
packed_for_hash = packed
|
|
159
|
+
|
|
160
|
+
hashed = dest_hash + source_hash + packed_for_hash
|
|
161
|
+
message_hash = RNS.Identity.full_hash(hashed)
|
|
162
|
+
timestamp, title, content, fields = unpacked[:4]
|
|
163
|
+
|
|
164
|
+
msg = LxmfMessage(
|
|
165
|
+
destination_hash=dest_hash,
|
|
166
|
+
source_hash=source_hash,
|
|
167
|
+
content=content if isinstance(content, (bytes, bytearray)) else _to_bytes(content),
|
|
168
|
+
title=title if isinstance(title, (bytes, bytearray)) else _to_bytes(title),
|
|
169
|
+
fields=fields if isinstance(fields, dict) else {},
|
|
170
|
+
timestamp=timestamp,
|
|
171
|
+
)
|
|
172
|
+
msg.signature = bytes(signature)
|
|
173
|
+
msg.hash = message_hash
|
|
174
|
+
msg.stamp = stamp
|
|
175
|
+
|
|
176
|
+
sender_identity = RNS.Identity(create_keys=False)
|
|
177
|
+
sender_identity.load_public_key(bytes(sender_pub))
|
|
178
|
+
msg.signature_validated = sender_identity.validate(
|
|
179
|
+
bytes(signature), hashed + message_hash
|
|
180
|
+
)
|
|
181
|
+
return msg
|
rfed-0.1.0/rfed/blob.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""rfed channel message envelope codec (the "RTID" prelude).
|
|
2
|
+
|
|
3
|
+
A channel message is a propagation-style LXMF message wrapped in the RTID
|
|
4
|
+
source-identity prelude, then EC-encrypted to the channel identity. The
|
|
5
|
+
resulting ``inner_blob`` is what rfed stores, syncs, and fans out verbatim —
|
|
6
|
+
rfed never decrypts or inspects it.
|
|
7
|
+
|
|
8
|
+
Layered wire format (``RFed/SPEC.md`` "CANONICAL WIRE FORMAT")::
|
|
9
|
+
|
|
10
|
+
plaintext = "RTID"(4) ‖ sender_identity_pub(64) ‖ LXMF_tail
|
|
11
|
+
LXMF_tail = source_hash(16) ‖ signature(64) ‖ msgpack_payload
|
|
12
|
+
inner_blob = EC_encrypt(channel_identity.X25519_pub, plaintext)
|
|
13
|
+
rfed_payload = channel_hash(16) ‖ inner_blob ‖ stamp(32)
|
|
14
|
+
|
|
15
|
+
``source_hash`` is the sender's ``lxmf.delivery`` *destination* hash —
|
|
16
|
+
``truncated_hash(name_hash("lxmf.delivery") ‖ identity_hash)`` — NOT the bare
|
|
17
|
+
identity hash. Integrity is the LXMF Ed25519 signature; cache poisoning is
|
|
18
|
+
impossible because reaching the EC-decrypt step already required the channel
|
|
19
|
+
private key (i.e. an authorised subscriber).
|
|
20
|
+
|
|
21
|
+
Applications may carry other inner formats instead of the LXMF tail: rfed
|
|
22
|
+
treats ``inner_blob`` opaquely, so the plaintext after the RTID prelude is a
|
|
23
|
+
private agreement between publishers and subscribers of a channel (keyed by
|
|
24
|
+
``channel_hash``); use :meth:`rfed.client.RFedClient.send_publish` /
|
|
25
|
+
:meth:`rfed.client.RFedClient.listen_raw` for those.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
import RNS
|
|
34
|
+
|
|
35
|
+
from rfed._lxmf import DESTINATION_LENGTH, LxmfMessage
|
|
36
|
+
from rfed.channel import delivery_hash_for
|
|
37
|
+
from rfed.constants import (
|
|
38
|
+
HASH_LENGTH,
|
|
39
|
+
MAGIC_LENGTH,
|
|
40
|
+
MAGIC_RTID,
|
|
41
|
+
PRELUDE_LENGTH,
|
|
42
|
+
STAMP_SIZE,
|
|
43
|
+
)
|
|
44
|
+
from rfed.stamp import generate_channel_stamp
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"RfedPayload",
|
|
48
|
+
"DecodedChannelMessage",
|
|
49
|
+
"wrap_channel_message",
|
|
50
|
+
"parse_fanout_payload",
|
|
51
|
+
"parse_send_payload",
|
|
52
|
+
"unwrap_channel_message",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RfedPayload:
|
|
58
|
+
"""A fully-wrapped rfed SEND payload and its constituent parts."""
|
|
59
|
+
|
|
60
|
+
rfed_payload: bytes
|
|
61
|
+
channel_hash: bytes
|
|
62
|
+
channel_delivery_hash: bytes
|
|
63
|
+
inner_blob: bytes
|
|
64
|
+
stamp: Optional[bytes]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DecodedChannelMessage:
|
|
69
|
+
"""A decoded channel fanout message."""
|
|
70
|
+
|
|
71
|
+
message: LxmfMessage
|
|
72
|
+
sender_pub: bytes
|
|
73
|
+
sender_identity: RNS.Identity
|
|
74
|
+
source_hash: bytes
|
|
75
|
+
signature_valid: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wrap_channel_message(
|
|
79
|
+
*,
|
|
80
|
+
channel_identity: RNS.Identity,
|
|
81
|
+
sender_identity: RNS.Identity,
|
|
82
|
+
sender_lxm_delivery_hash: bytes,
|
|
83
|
+
lxm_message: LxmfMessage,
|
|
84
|
+
stamp_cost: Optional[int] = None,
|
|
85
|
+
) -> RfedPayload:
|
|
86
|
+
"""Wrap an LXMF message into a rfed channel SEND payload.
|
|
87
|
+
|
|
88
|
+
The LXMF message is serialised (signed by ``sender_identity``), the RTID
|
|
89
|
+
prelude + sender public key are prepended to the LXMF tail, the whole thing
|
|
90
|
+
is EC-encrypted to the channel identity, and the channel hash + optional PoW
|
|
91
|
+
stamp are framed around it.
|
|
92
|
+
|
|
93
|
+
``lxm_message.destination_hash`` / ``source_hash`` are forced to the correct
|
|
94
|
+
``lxmf.delivery`` hashes (channel and sender respectively) so the classic
|
|
95
|
+
"source_hash is the identity hash" bug cannot occur.
|
|
96
|
+
"""
|
|
97
|
+
channel_hash = channel_identity.hash
|
|
98
|
+
channel_delivery_hash = delivery_hash_for(channel_identity)
|
|
99
|
+
|
|
100
|
+
# Force correct LXMF addressing: source_hash MUST be the sender's
|
|
101
|
+
# lxmf.delivery destination hash, never the bare identity hash.
|
|
102
|
+
lxm_message.destination_hash = bytes(channel_delivery_hash)
|
|
103
|
+
lxm_message.source_hash = bytes(sender_lxm_delivery_hash)
|
|
104
|
+
|
|
105
|
+
wire = lxm_message.serialize(sender_identity)
|
|
106
|
+
sender_pub = sender_identity.get_public_key()
|
|
107
|
+
# LXMF tail = wire after the destination hash: source ‖ signature ‖ payload.
|
|
108
|
+
lxmf_tail = wire[DESTINATION_LENGTH:]
|
|
109
|
+
|
|
110
|
+
plaintext = MAGIC_RTID + sender_pub + lxmf_tail
|
|
111
|
+
inner_blob = channel_identity.encrypt(plaintext)
|
|
112
|
+
|
|
113
|
+
stamp: Optional[bytes] = None
|
|
114
|
+
if stamp_cost and stamp_cost > 0:
|
|
115
|
+
stamp, _ = generate_channel_stamp(channel_hash, inner_blob, stamp_cost)
|
|
116
|
+
|
|
117
|
+
rfed_payload = (
|
|
118
|
+
bytes(channel_hash) + bytes(inner_blob) + (bytes(stamp) if stamp else b"")
|
|
119
|
+
)
|
|
120
|
+
return RfedPayload(
|
|
121
|
+
rfed_payload=rfed_payload,
|
|
122
|
+
channel_hash=bytes(channel_hash),
|
|
123
|
+
channel_delivery_hash=bytes(channel_delivery_hash),
|
|
124
|
+
inner_blob=bytes(inner_blob),
|
|
125
|
+
stamp=stamp,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_fanout_payload(payload: bytes) -> tuple:
|
|
130
|
+
"""Split a fanout payload ``[ channel_hash(16) ‖ inner_blob ]``.
|
|
131
|
+
|
|
132
|
+
The fanout hop carries no stamp (it was validated and stripped at ingest).
|
|
133
|
+
"""
|
|
134
|
+
if len(payload) < HASH_LENGTH:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"rfed fanout payload too short: {len(payload)} bytes "
|
|
137
|
+
f"(need at least {HASH_LENGTH})"
|
|
138
|
+
)
|
|
139
|
+
return payload[:HASH_LENGTH], payload[HASH_LENGTH:]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_send_payload(payload: bytes) -> tuple:
|
|
143
|
+
"""Split a SEND payload ``[ channel_hash(16) ‖ inner_blob ‖ stamp(32) ]``.
|
|
144
|
+
|
|
145
|
+
Use when a stamp is known to be present (the node's ``stamp_cost`` is
|
|
146
|
+
non-nil); use :func:`parse_fanout_payload` for the stamp-free fanout form.
|
|
147
|
+
"""
|
|
148
|
+
min_len = HASH_LENGTH + STAMP_SIZE
|
|
149
|
+
if len(payload) < min_len:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"rfed SEND payload too short: {len(payload)} bytes "
|
|
152
|
+
f"(need at least {min_len})"
|
|
153
|
+
)
|
|
154
|
+
return (
|
|
155
|
+
payload[:HASH_LENGTH],
|
|
156
|
+
payload[HASH_LENGTH : len(payload) - STAMP_SIZE],
|
|
157
|
+
payload[len(payload) - STAMP_SIZE :],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def unwrap_channel_message(
|
|
162
|
+
*, inner_blob: bytes, channel_identity: RNS.Identity, channel_delivery_hash: bytes
|
|
163
|
+
) -> DecodedChannelMessage:
|
|
164
|
+
"""Decrypt and reconstruct an LXMF message from a channel ``inner_blob``.
|
|
165
|
+
|
|
166
|
+
Inverse of :func:`wrap_channel_message`: EC-decrypts with the channel
|
|
167
|
+
identity, verifies the RTID magic, extracts the embedded sender public key,
|
|
168
|
+
and feeds the reconstructed LXMF wire block to
|
|
169
|
+
:meth:`LxmfMessage.deserialize`. The sender identity is cached via
|
|
170
|
+
:func:`RNS.Identity.remember` so subsequent messages from the same sender
|
|
171
|
+
validate without the prelude (best-effort).
|
|
172
|
+
|
|
173
|
+
The returned ``signature_valid`` is **the** integrity check: a forged
|
|
174
|
+
``sender_identity_pub`` produces a signature mismatch.
|
|
175
|
+
"""
|
|
176
|
+
plaintext = channel_identity.decrypt(bytes(inner_blob))
|
|
177
|
+
if plaintext is None:
|
|
178
|
+
raise ValueError("rfed inner_blob EC-decryption failed (wrong channel?)")
|
|
179
|
+
plaintext = bytes(plaintext)
|
|
180
|
+
if len(plaintext) < PRELUDE_LENGTH + DESTINATION_LENGTH:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"rfed prelude plaintext too short: {len(plaintext)} bytes"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Verify magic — receivers MUST refuse blobs without "RTID".
|
|
186
|
+
magic = plaintext[:MAGIC_LENGTH]
|
|
187
|
+
if magic != MAGIC_RTID:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f'rfed prelude magic mismatch: expected "RTID", got {magic!r}'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
sender_pub = plaintext[MAGIC_LENGTH:PRELUDE_LENGTH]
|
|
193
|
+
lxmf_tail = plaintext[PRELUDE_LENGTH:]
|
|
194
|
+
|
|
195
|
+
# Reconstruct the canonical LXMF block: dest_hash(16) ‖ source ‖ sig ‖ payload.
|
|
196
|
+
full_wire = bytes(channel_delivery_hash) + lxmf_tail
|
|
197
|
+
message = LxmfMessage.deserialize(full_wire, sender_pub)
|
|
198
|
+
|
|
199
|
+
sender_identity = RNS.Identity(create_keys=False)
|
|
200
|
+
sender_identity.load_public_key(sender_pub)
|
|
201
|
+
|
|
202
|
+
# Cache the sender identity so future messages validate without the prelude.
|
|
203
|
+
# Best-effort: decode must still succeed without it.
|
|
204
|
+
try:
|
|
205
|
+
RNS.Identity.remember(
|
|
206
|
+
message.hash or message.source_hash,
|
|
207
|
+
message.source_hash,
|
|
208
|
+
sender_pub,
|
|
209
|
+
None,
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
return DecodedChannelMessage(
|
|
215
|
+
message=message,
|
|
216
|
+
sender_pub=bytes(sender_pub),
|
|
217
|
+
sender_identity=sender_identity,
|
|
218
|
+
source_hash=message.source_hash,
|
|
219
|
+
signature_valid=message.signature_validated,
|
|
220
|
+
)
|