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