rfed 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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/_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/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
+ )
rfed/channel.py ADDED
@@ -0,0 +1,94 @@
1
+ """Deterministic rfed channel derivation.
2
+
3
+ A channel is a deterministic :class:`RNS.Identity` derived from a plain-text
4
+ channel name. Any party that knows the name independently arrives at the same
5
+ identity hash ("channel hash") and keypair, so senders can encrypt to the
6
+ channel and subscribers can decrypt — with no server-side registration.
7
+
8
+ Algorithm (``RFed/SPEC.md`` §1)::
9
+
10
+ seed = SHA-256(channel_name) → 32 bytes
11
+ x25519_priv = seed
12
+ ed25519_priv = seed
13
+ x25519_pub = X25519_public_key(seed) → 32 bytes
14
+ ed25519_pub = Ed25519_public_key(seed) → 32 bytes
15
+ bundle = x25519_pub ‖ ed25519_pub → 64 bytes
16
+ channel_hash = SHA-256(bundle)[0..16] → 16 bytes
17
+
18
+ The channel's private-key bundle is ``seed ‖ seed`` (the same 32-byte
19
+ ``SHA-256(name)`` scalar used for both X25519 and Ed25519), matching the Rust
20
+ reference's ``private_key_bundle = seed || seed`` and the canonical Python
21
+ ``channel_hash.compute_channel_hash`` vectors.
22
+
23
+ This module uses :mod:`RNS` only for its cryptography primitives
24
+ (:func:`RNS.Identity.full_hash` / :func:`RNS.Identity.truncated_hash`), so it
25
+ works **without a running Reticulum** — only :class:`~RNS.Destination` creation
26
+ needs a live transport.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ from typing import Tuple
33
+
34
+ import RNS
35
+
36
+ from rfed.constants import LXMF_DELIVERY_NAME
37
+
38
+ __all__ = ["derive_channel", "delivery_hash_for", "channel_path"]
39
+
40
+
41
+ def _identity_from_seed(seed: bytes) -> RNS.Identity:
42
+ """Build a full RNS Identity (both private keys) from a 32-byte seed.
43
+
44
+ The private-key bundle is ``seed ‖ seed`` (X25519 seed ‖ Ed25519 seed),
45
+ matching the Rust/Python references.
46
+ """
47
+ if len(seed) != 32:
48
+ raise ValueError("channel seed must be 32 bytes")
49
+ identity = RNS.Identity(create_keys=False)
50
+ identity.load_private_key(seed + seed)
51
+ return identity
52
+
53
+
54
+ def derive_channel(name: str) -> Tuple[RNS.Identity, bytes]:
55
+ """Derive a channel's deterministic Identity and 16-byte channel hash.
56
+
57
+ Parameters
58
+ ----------
59
+ name:
60
+ Dot-separated channel name (e.g. ``"dacar.policy.v1"`` or a
61
+ ``"<hex>.<segments>"`` private channel).
62
+
63
+ Returns
64
+ -------
65
+ (identity, channel_hash)
66
+ ``identity`` is a full RNS Identity holding both private keys, so it can
67
+ encrypt, decrypt, and derive its ``lxmf.delivery`` hash.
68
+ ``channel_hash`` is the 16-byte channel identity hash used as the rfed
69
+ routing label.
70
+ """
71
+ seed = hashlib.sha256(name.encode("utf-8")).digest()
72
+ identity = _identity_from_seed(seed)
73
+ # identity.hash is SHA-256(pub_bundle)[:16], already the channel hash.
74
+ return identity, identity.hash
75
+
76
+
77
+ def delivery_hash_for(identity: RNS.Identity) -> bytes:
78
+ """Compute the ``lxmf.delivery`` destination hash for an Identity.
79
+
80
+ The 16-byte truncated ``SHA-256(name_hash("lxmf.delivery") ‖ identity_hash)``.
81
+
82
+ For a channel message this is the LXMF ``destination_hash`` the sender signs
83
+ over (the channel's delivery address), **not** the bare channel identity
84
+ hash. Confusing the two is the classic rfed bug — see
85
+ ``RFed/SPEC.md`` "CANONICAL WIRE FORMAT" invariants.
86
+ """
87
+ name_hash = RNS.Identity.full_hash(LXMF_DELIVERY_NAME.encode("utf-8"))
88
+ name_hash = name_hash[: RNS.Identity.NAME_HASH_LENGTH // 8] # first 10 bytes
89
+ return RNS.Identity.truncated_hash(name_hash + identity.hash)
90
+
91
+
92
+ def channel_path(*segments: str) -> str:
93
+ """Join channel-name segments with ``.`` (mirrors RNS aspect notation)."""
94
+ return ".".join(segments)