Resolve issue #6 — LRRTT and HEADER_1 for link-addressed DATA (§6.4.2, §6.4.3)
Upstream RNS enforces two requirements in code that SPEC.md left implicit;
both caused silent message loss in a clean-room Go LXMF service against
upstream Python rns 1.2.4 / lxmf 0.9.7.
§6.4.2 LRRTT — initiator's link-activation packet
- HEADER_1, DATA, dest_type=LINK (0x03), ctx=0xfe; body is
`umsgpack.packb(rtt_seconds)` encrypted with the link's session keys.
- The responder transitions HANDSHAKE→ACTIVE only on LRRTT receipt
(Link.py:534-553), which is also what fires the link_established
callback. LXMF's set_resource_strategy(ACCEPT_APP) is installed
from that callback; without it, every RESOURCE_ADV the initiator
sends hits the silent ACCEPT_NONE branch at Link.py:1087.
§6.4.3 Header type for post-handshake DATA and Resource
- Link-addressed packets are routed via link_table, which forwards
header bytes verbatim (Transport.py:1587-1622). HEADER_2 with a
relay's transport_id therefore arrives at the destination intact
and is dropped by packet_filter (Transport.py:1283-1285) as
"for another transport instance".
- Mandates HEADER_1 with no transport_id for all post-handshake
link DATA / Resource / control packets regardless of hop count.
- Asymmetry with LINKREQUEST (which IS path_table-routed and so
HEADER_2-eligible) is spelled out.
Companion changes:
- §6.4 renamed to "Session keys and link activation"; existing
HKDF content moved into §6.4.1.
- §2.5 LRRTT context-byte entry points at §6.4.2.
- §12.5.2 (Link DATA forwarding) cross-references §6.4.3.
- §14 failure-modes table: two new entries for the silent-drop
chains documented above.
- flows/send-link-lxmf.md step 4 strengthened (LRRTT is mandatory,
not informational); step 6 corrected (Transport.outbound does NOT
apply HEADER_1→HEADER_2 for link DATA — that conversion is
path_table-keyed, link DATA is link_table-keyed).
- test-vectors/links.json extended with an LRRTT entry: pinned
rtt_seconds=0.05 + pinned 16-byte IV produces deterministic
wire bytes for the encrypted body.
- tools/regen_links.py drives the LRRTT generation with an
os.urandom patch for the Token IV.
- tools/verify_link_lrrtt.py (new) locks the wire claims:
HEADER_1, ctx=0xfe, dest=link_id, body decrypts under
derived_key to msgpack float64 matching rtt_seconds.
Citations all verified against installed RNS 1.2.4 / LXMF 0.9.7.
All 14 verifiers PASS.
This commit is contained in:
parent
5574d3bed3
commit
073203abae
7 changed files with 276 additions and 11 deletions
83
SPEC.md
83
SPEC.md
|
|
@ -48,7 +48,7 @@ Source citations refer to the standard `pip install rns lxmf` install layout (`R
|
|||
- [6.1 LINKREQUEST (initiator → responder)](#61-linkrequest-initiator-responder)
|
||||
- [6.2 LRPROOF (responder → initiator)](#62-lrproof-responder-initiator)
|
||||
- [6.3 link_id derivation](#63-link_id-derivation)
|
||||
- [6.4 Session key derivation](#64-session-key-derivation)
|
||||
- [6.4 Session keys and link activation](#64-session-keys-and-link-activation)
|
||||
- [6.5 Packet receipts (regular `PROOF` packets)](#65-packet-receipts-regular-proof-packets)
|
||||
- [6.6 MTU and mode signalling (3-byte trailer on LINKREQUEST and LRPROOF)](#66-mtu-and-mode-signalling-3-byte-trailer-on-linkrequest-and-lrproof)
|
||||
- [6.7 KEEPALIVE and link teardown](#67-keepalive-and-link-teardown)
|
||||
|
|
@ -403,7 +403,7 @@ Full context inventory from `RNS/Packet.py:74-92` (RNS 1.2.4):
|
|||
| `0xFB` | LINKIDENTIFY | Backchannel-identify proof on an established Link (§5 backchannel) |
|
||||
| `0xFC` | LINKCLOSE | Link teardown notification |
|
||||
| `0xFD` | LINKPROOF | Defined but **not actually emitted** by upstream RNS 1.2.4 in this revision. Both `Identity.prove` and `Link.prove_packet` build their proof packets with `context = NONE (0x00)` — the proof-ness is conveyed by `packet_type = PROOF (3)`, not by this context byte. Reserved for a future revision; see §6.5 |
|
||||
| `0xFE` | LRRTT | Link RTT measurement reply |
|
||||
| `0xFE` | LRRTT | Link round-trip-time reply — initiator's link-activation packet (§6.4.2) |
|
||||
| `0xFF` | LRPROOF | Link request proof (§6.2) |
|
||||
|
||||
### 2.6 Source
|
||||
|
|
@ -1005,7 +1005,11 @@ The "hashable part" deliberately strips `header_type`, `context_flag`, `transpor
|
|||
|
||||
For LINKREQUEST packets specifically, the trailing signalling bytes (if present, indicated by `len(packet.data) > Link.ECPUBSIZE` in `link_id_from_lr_packet` at `RNS/Link.py:341-348`) are stripped from the END of `hashable_part` before hashing, so the link_id is invariant under MTU-discovery signalling.
|
||||
|
||||
### 6.4 Session key derivation
|
||||
### 6.4 Session keys and link activation
|
||||
|
||||
After the LINKREQUEST/LRPROOF exchange completes, both peers must (a) derive matching link session keys, (b) drive the link state machine to `ACTIVE`, and (c) settle on the wire conventions for all subsequent traffic on the link. The three subsections below cover each step. Skipping any of them silently breaks interop in distinct ways: §6.4.1 wrong → no peer can decrypt anything; §6.4.2 omitted → the responder never reaches `ACTIVE` and silently drops all link DATA; §6.4.3 wrong on multi-hop → packets are dropped at the destination's transport filter.
|
||||
|
||||
#### 6.4.1 Session key derivation
|
||||
|
||||
Both sides compute:
|
||||
|
||||
|
|
@ -1018,6 +1022,75 @@ encrypt_key = session_key[32..64]
|
|||
|
||||
Subsequent DATA packets on the link use the Link-derived-key Token format (section 3.1, no ephemeral_pub prefix).
|
||||
|
||||
#### 6.4.2 LRRTT — initiator's link-activation packet
|
||||
|
||||
After validating the responder's LRPROOF and deriving session keys, the initiator MUST send a Link Round-Trip Time packet to the responder before transmitting any application DATA. The wire form is:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `header_type` | `HEADER_1` |
|
||||
| `packet_type` | `DATA (0x00)` |
|
||||
| `destination_type` | `LINK (0x03)` |
|
||||
| `dest_hash` | `link_id` |
|
||||
| `context` | `LRRTT (0xFE)` |
|
||||
| body (plaintext) | `umsgpack.packb(rtt_seconds)` — a single msgpack float64 (9 bytes) carrying the initiator's measured RTT in seconds (LRREQ-send to LRPROOF-receive) |
|
||||
| body (wire) | the plaintext above, encrypted with the link's session keys per §3.1 (link form Token, no `eph_pub` prefix) |
|
||||
|
||||
Source: `RNS/Link.py:440-442` constructs and sends the packet immediately after LRPROOF validation:
|
||||
|
||||
```python
|
||||
rtt_data = umsgpack.packb(self.rtt)
|
||||
rtt_packet = RNS.Packet(self, rtt_data, context=RNS.Packet.LRRTT)
|
||||
rtt_packet.send()
|
||||
```
|
||||
|
||||
The responder uses receipt of LRRTT as the trigger to transition its link state from `HANDSHAKE` to `ACTIVE` (`RNS/Link.py:534-553`). The initiator transitions independently upon LRPROOF validation (`Link.py:430-432`); the responder MUST NOT transition before LRRTT arrives. The responder routes context `LRRTT` to `Link.rtt_packet()` from its main `receive()` dispatch at `RNS/Link.py:1056-1058`.
|
||||
|
||||
`Link.rtt_packet()` is also the only path on the responder side that fires the `link_established` callback (`Link.py:550-551`). Without that callback, application layers cannot install link-state policies that depend on `ACTIVE` — most importantly, LXMF's `LXMRouter.delivery_link_established` (`LXMF/LXMRouter.py:1852-1859`) only calls `link.set_resource_strategy(ACCEPT_APP)` from this callback. Until that strategy is installed, the responder's `Link.receive()` hits the silent-drop branch `elif self.resource_strategy == Link.ACCEPT_NONE: pass` (`RNS/Link.py:1087`) on every inbound `RESOURCE_ADV`, and any oversize LXMF delivered as a Resource is discarded with no log line at default levels. This is silent, end-to-end, default-config message loss for an initiator that completes LRPROOF and immediately sends RESOURCE_ADV without first sending LRRTT.
|
||||
|
||||
The exact RTT value reported is non-load-bearing: the responder takes `max(its_own_measurement, initiator_reported)` (`Link.py:540`). Implementations that don't have an accurate RTT measurement at this point may report a coarse estimate or zero — the responder's measurement carries forward when the initiator reports a smaller value. The value is, however, included in the encrypted body and so is integrity-bound to the link session keys; a peer that fails to encrypt this body with the correct link keys will fail decrypt and `rtt_packet` returns without transitioning to `ACTIVE`.
|
||||
|
||||
#### 6.4.3 Header type for post-handshake DATA and Resource
|
||||
|
||||
All packets sent on an active Link — link DATA (`packet_type=DATA`, `context=NONE`), Resource control packets (`context` ∈ {`RESOURCE_ADV (0x02)`, `RESOURCE_REQ (0x03)`, `RESOURCE_HMU (0x04)`, `RESOURCE_ICL (0x06)`, `RESOURCE_RCL (0x07)`}), Resource part packets (`context=RESOURCE (0x01)`), and link control packets (`KEEPALIVE`, `LRRTT`, `LINKCLOSE`, `LINKIDENTIFY`, `REQUEST`, `RESPONSE`, `CHANNEL`) — MUST be emitted with `header_type=HEADER_1` and no `transport_id`, regardless of whether the responder is reachable directly or through one or more transit relays.
|
||||
|
||||
This is asymmetric to the LINKREQUEST that established the link. LINKREQUEST is destination-hash-routed via `path_table` and therefore eligible for `HEADER_2` with `transport_id` set to the next-hop relay; the relay's path_table-forwarding branch strips `transport_id` (HEADER_2 → HEADER_1) at the last hop (`RNS/Transport.py:1500-1517`):
|
||||
|
||||
```python
|
||||
if remaining_hops > 1:
|
||||
# Just increase hop count and transmit (HEADER_2 preserved)
|
||||
elif remaining_hops == 1:
|
||||
# Strip transport headers and transmit
|
||||
new_flags = (RNS.Packet.HEADER_1) << 6 | (Transport.BROADCAST) << 4 | (packet.flags & 0b00001111)
|
||||
new_raw = struct.pack("!B", new_flags)
|
||||
new_raw += struct.pack("!B", packet.hops)
|
||||
new_raw += packet.raw[(RNS.Identity.TRUNCATED_HASHLENGTH//8)+2:]
|
||||
```
|
||||
|
||||
Once the link exists, post-handshake traffic addressed to `link_id` is routed via `link_table`, not `path_table`. The link_table forwarding branch (`RNS/Transport.py:1587-1622`) does NOT touch the header — it bumps `hops` and forwards the bytes verbatim:
|
||||
|
||||
```python
|
||||
new_raw = packet.raw[0:1]
|
||||
new_raw += struct.pack("!B", packet.hops)
|
||||
new_raw += packet.raw[2:]
|
||||
Transport.transmit(outbound_interface, new_raw)
|
||||
```
|
||||
|
||||
A HEADER_2 link DATA packet would therefore arrive at the destination with `transport_id` intact, where the receiver's `Transport.packet_filter` (`RNS/Transport.py:1283-1285`) drops it as "for another transport instance" because the embedded `transport_id` is the relay's identity, not the receiver's:
|
||||
|
||||
```python
|
||||
if packet.transport_id != None and packet.packet_type != RNS.Packet.ANNOUNCE:
|
||||
if packet.transport_id != Transport.identity.hash:
|
||||
RNS.log("Ignored packet ... in transport for other transport instance", RNS.LOG_EXTREME)
|
||||
return False
|
||||
```
|
||||
|
||||
The drop fires only at `LOG_EXTREME` (level 7) and is invisible at the default `LOG_NOTICE` (level 3); see §9.9.
|
||||
|
||||
Upstream RNS does not trip this in practice because its own constructors use `Packet`'s defaults (`HEADER_1`, `transport_id=None`) regardless of multi-hop. The relevant ones, all in `RNS/Resource.py` and `RNS/Link.py`, take the form `RNS.Packet(self.link, body, context=...)` (e.g. `Resource.py:521` for `RESOURCE_ADV`); see `Packet.__init__` at `RNS/Packet.py:122-123` for the default values. Upstream-to-upstream interop therefore never exercises this path. A clean-room implementation that copies the LINKREQUEST multi-hop pattern verbatim — setting `transport_id` on every link-bound packet — silently fails on any link with one or more transit relays in the path. The unit tests for that implementation pass (both sides agree on the same wire mistake) but localhost-rnsd interop with a real upstream destination drops every Resource part.
|
||||
|
||||
The asymmetry summarized: the same Link is set up via a `HEADER_2`-eligible LINKREQUEST, but uses `HEADER_1` for everything else once established.
|
||||
|
||||
### 6.5 Packet receipts (regular `PROOF` packets)
|
||||
|
||||
A `PROOF`-type packet (`packet_type = 3`, `context = NONE (0x00)`) is the receipt that closes the loop on every CTX_NONE DATA packet — both opportunistic DATA addressed to a SINGLE destination and DATA flowing on an active Link. Without it, the sender's `PacketReceipt` never resolves, its retransmit queue fires repeatedly, and on a Link the KEEPALIVE budget is exhausted and the link torn down.
|
||||
|
|
@ -2849,6 +2922,8 @@ After validation, the link_table entry is marked `validated`, and from now on th
|
|||
|
||||
For a `DATA` packet with `destination_type == LINK` whose `dest_hash` is in `link_table`, the relay forwards on the appropriate direction's interface. The link_table entry remembers both sides via `IDX_LT_NH_IF` (toward initiator end) and `IDX_LT_RCVD_IF` (toward responder end); the relay picks based on which interface the inbound packet arrived on.
|
||||
|
||||
Unlike the path_table forwarding in §12.2 — which strips `transport_id` (`HEADER_2` → `HEADER_1`) at the last hop — link_table forwarding does NOT touch the header. The relay bumps `hops` and emits `packet.raw[0:1] + struct.pack("!B", packet.hops) + packet.raw[2:]` (`Transport.py:1618-1620`). Whatever header bytes the initiator emitted reach the destination verbatim. This is why the originator's wire conventions for post-handshake link DATA are constrained — see §6.4.3: senders MUST emit `HEADER_1` with no `transport_id` for every link-addressed packet, because a `HEADER_2` link packet would arrive at the destination with `transport_id` intact and be dropped by the destination's `packet_filter` as "for another transport instance".
|
||||
|
||||
#### 12.5.3 PROOF receipt forwarding via `reverse_table`
|
||||
|
||||
`Transport.py:2199-2208`. When a PROOF arrives whose `dest_hash` is in `reverse_table` (i.e. an opportunistic-DATA proof being routed back to its originator), the relay pops the entry, checks the proof arrived on the correct outbound interface (`receiving_interface == reverse_entry[IDX_RT_OUTB_IF]`), and forwards on the originally-receiving interface:
|
||||
|
|
@ -3061,6 +3136,8 @@ A client running on a constrained device (less RAM, slower CPU) can scale all of
|
|||
| Link establishes but tears down within 5 minutes of inactivity | §6.7 — KEEPALIVE not implemented. Initiator sends `0xFF` ping every `keepalive` seconds; responder replies with `0xFE` pong | §6.7.1 |
|
||||
| Sender sees DATA bursts repeatedly retransmitted, link dies | §6.5 — receiver isn't emitting the mandatory PROOF receipt for each CTX_NONE Link DATA packet | `tools/verify_proof_packet.py` |
|
||||
| Some peers work, others reject every PROOF I send | §6.5.2 — wrong proof body length. Upstream default emits 64-byte implicit proofs (`signature` only) but your peer expects 96-byte explicit (`packet_hash \|\| signature`). Validator dispatches on length | `tools/verify_proof_packet.py` |
|
||||
| Initiator and responder complete LRPROOF but every Resource ADV / link DATA the initiator sends is silently dropped at the responder | §6.4.2 — initiator never emitted LRRTT after LRPROOF. Responder stays in `HANDSHAKE`, `link_established` callback never fires, LXMF's `set_resource_strategy(ACCEPT_APP)` never installs, and `Link.receive` hits the silent `ACCEPT_NONE` branch on every RESOURCE_ADV | §6.4.2 |
|
||||
| Single-hop link works, but the same flow over a multi-hop link silently drops every link DATA / Resource part at the destination | §6.4.3 — link-addressed packets emitted as `HEADER_2` with `transport_id` set to the next-hop relay. link_table forwarding doesn't strip `transport_id`, so the destination's `packet_filter` rejects it as "for another transport instance" (LOG_EXTREME). Use `HEADER_1` and `transport_id=None` regardless of hop count | §6.4.3 |
|
||||
|
||||
### Resource transfers (large bodies)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ Validation (`RNS/Link.py:410-422`):
|
|||
5. On success: `link.handshake()` runs (`RNS/Link.py:353-370`) — ECDH with the responder's fresh X25519 pub, then HKDF over the shared secret with `salt = link_id`, derives `signing_key(32) || encrypt_key(32)` per SPEC.md §6.4. Link state transitions to `Link.ACTIVE`.
|
||||
6. The initiator's `established_callback` registered at step 2 — `LXMRouter.process_outbound` — fires, re-entering step 2 with the link now ACTIVE.
|
||||
|
||||
A confirmed-MTU hint in the LRPROOF (length is `64+32+3 = 99` instead of `64+32 = 96`) updates `link.mtu` to the smaller of the responder's hint and the initiator's view (`RNS/Link.py:404-408`). An RTT report message follows automatically (`RNS/Link.py:441`, packet with `context = LRRTT`).
|
||||
A confirmed-MTU hint in the LRPROOF (length is `64+32+3 = 99` instead of `64+32 = 96`) updates `link.mtu` to the smaller of the responder's hint and the initiator's view (`RNS/Link.py:404-408`).
|
||||
|
||||
Immediately after step 5 succeeds — and before any application DATA leaves — the initiator MUST emit a Link Round-Trip Time packet (`context = LRRTT (0xfe)`, `RNS/Link.py:440-442`). This is **not** an informational hint; the responder uses LRRTT receipt as the sole trigger to transition its own link state from `HANDSHAKE` to `ACTIVE` and to fire the application's `link_established_callback` (per SPEC.md §6.4.2). On the LXMF responder that callback is what installs `set_resource_strategy(ACCEPT_APP)` (`LXMF/LXMRouter.py:1855`); without it, every Resource ADV the initiator sends — including any LXM body large enough to spill from packet form into Resource form — silently hits the `ACCEPT_NONE` branch on the responder and is dropped. The initiator transitions to `ACTIVE` independently on LRPROOF validation, but the responder does not.
|
||||
|
||||
### 5. Transfer the LXM body over the active link
|
||||
|
||||
|
|
@ -141,7 +143,7 @@ so the LXMessage advances `SENT → DELIVERED` when the recipient's PROOF for th
|
|||
|
||||
### 6. Wire bytes leave (KISS / HDLC framing)
|
||||
|
||||
Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leaves the interface, with `Transport.outbound` applying the HEADER_1→HEADER_2 conversion if the link's next hop is more than 1 transport hop away. The next-hop interface for a link is cached on the link object (`link.attached_interface`) so the hop count comes from the link establishment time, not a fresh path-table lookup.
|
||||
Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leaves the interface. Note that, in contrast to opportunistic DATA, **`Transport.outbound` does NOT apply the HEADER_1→HEADER_2 conversion for link-addressed packets**, regardless of how many transport hops the link traverses. The HEADER_1→HEADER_2 path is keyed on a `path_table` lookup against `dest_hash`; a link-addressed packet's `dest_hash` is the `link_id`, which lives in `link_table`, not `path_table`. Relays then forward it via `link_table` forwarding — which preserves the header bytes verbatim — so emitting HEADER_2 with `transport_id` set on a link-addressed packet would have it dropped at the destination's `packet_filter` as "for another transport instance" (SPEC.md §6.4.3). The next-hop interface for a link is cached on the link object (`link.attached_interface`) so all subsequent traffic uses that same interface without further path-table lookup.
|
||||
|
||||
### 7. PROOF receipt arrives → `__mark_delivered` fires
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Populated against RNS 1.2.4 / LXMF 0.9.7:
|
|||
- ✅ `identities.json` — Alice + Bob identity vectors (regenerator: `../tools/regen_identities.py`, verifier: `../tools/verify_destination_hash.py`).
|
||||
- ✅ `announces.json` — two announce vectors (no-ratchet + with-ratchet) signed by Alice (regenerator: `../tools/regen_announces.py`, verifier: `../tools/verify_announce_roundtrip.py`).
|
||||
- ✅ `lxmf.json` — two opportunistic-LXMF vectors Alice → Bob (regenerator: `../tools/regen_lxmf.py`, verifier: `../tools/verify_lxmf_opportunistic.py`).
|
||||
- ✅ `links.json` — full Link handshake vector (LINKREQUEST + LRPROOF + derived session key) Alice → Bob (regenerator: `../tools/regen_links.py`, verifier: `../tools/verify_link_handshake.py`).
|
||||
- ✅ `links.json` — full Link handshake vector (LINKREQUEST + LRPROOF + derived session key) Alice → Bob, plus an LRRTT packet (§6.4.2) emitted from the initiator with pinned IV and `rtt_seconds = 0.05` (regenerator: `../tools/regen_links.py`, verifiers: `../tools/verify_link_handshake.py`, `../tools/verify_link_lrrtt.py`).
|
||||
|
||||
All four files are byte-deterministic across runs: regenerators pin every random source (ephemeral keys, IVs, `random_hash` prefix + timestamp, LXMF timestamp) so the output is reproducible against a fixed upstream RNS / LXMF version.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_about": "Link handshake test vectors. Each vector records a full Reticulum Link handshake: LINKREQUEST (initiator -> responder) and LRPROOF (responder -> initiator). The ephemeral X25519/Ed25519 keys are pinned via the `inputs.*_priv_hex` blobs; both Ed25519 signatures are RFC 8032 deterministic so the resulting wire bytes are reproducible. A clean-room implementation can verify by: (a) packing a LINKREQUEST from the recorded initiator ephemerals and confirming bytes match `linkrequest_raw_hex`; (b) computing `link_id` per SPEC.md S6.3 (N=2 for HEADER_1) and matching `link_id_hex`; (c) packing an LRPROOF as the responder, with bob's identity Ed25519 sig over `link_id || responder_X25519_pub || responder_long_term_Ed25519_pub || signalling`, and matching `lrproof_raw_hex`; (d) running ECDH+HKDF on either side and matching `derived_key_hex`. Regenerate with `generator_script`.",
|
||||
"_about": "Link handshake test vectors. Each vector records a full Reticulum Link handshake: LINKREQUEST (initiator -> responder) and LRPROOF (responder -> initiator). The ephemeral X25519/Ed25519 keys are pinned via the `inputs.*_priv_hex` blobs; both Ed25519 signatures are RFC 8032 deterministic so the resulting wire bytes are reproducible. A clean-room implementation can verify by: (a) packing a LINKREQUEST from the recorded initiator ephemerals and confirming bytes match `linkrequest_raw_hex`; (b) computing `link_id` per SPEC.md S6.3 (N=2 for HEADER_1) and matching `link_id_hex`; (c) packing an LRPROOF as the responder, with bob's identity Ed25519 sig over `link_id || responder_X25519_pub || responder_long_term_Ed25519_pub || signalling`, and matching `lrproof_raw_hex`; (d) running ECDH+HKDF on either side and matching `derived_key_hex`; (e) building an LRRTT packet (S6.4.2) addressed to the link_id with `context=LRRTT (0xfe)` and an encrypted body of `umsgpack.packb(lrrtt.rtt_seconds)`, using `lrrtt.iv_hex` as the Token IV, and matching `lrrtt.raw_hex` / `lrrtt.body_hex`. Regenerate with `generator_script`.",
|
||||
"vectors": [
|
||||
{
|
||||
"label": "alice_to_bob_aes256cbc",
|
||||
|
|
@ -31,14 +31,23 @@
|
|||
"shared_secret_hex": "5bf22caf31c0316785b0b9bc60e56d48582ce59435ce5b3c028052be42631e0f",
|
||||
"derived_key_hex": "d4c8238d23a1810c3dbe4caec15253d5a86d7fe6afa8dfa76f915579723fd88cbcd2ab3a0cd96f5b6ffd8abec8307f05cd791dc9c4fca900f706b0313a51ab65",
|
||||
"mtu": 500,
|
||||
"mode": 1
|
||||
"mode": 1,
|
||||
"lrrtt": {
|
||||
"rtt_seconds": 0.05,
|
||||
"iv_hex": "44444444444444444444444444444444",
|
||||
"plaintext_hex": "cb3fa999999999999a",
|
||||
"raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474fe4444444444444444444444444444444408eed3f995972368190afe8851fcc6850767824d5c8980840a16d4e5c873a5977669bb9c488d68ecf0da3ff6111cf630",
|
||||
"body_hex": "4444444444444444444444444444444408eed3f995972368190afe8851fcc6850767824d5c8980840a16d4e5c873a5977669bb9c488d68ecf0da3ff6111cf630"
|
||||
}
|
||||
},
|
||||
"rns_version_at_generation": "1.2.0",
|
||||
"rns_version_at_generation": "1.2.4",
|
||||
"generator_script": "tools/regen_links.py",
|
||||
"verifies_spec_sections": [
|
||||
"6.1",
|
||||
"6.2",
|
||||
"6.3",
|
||||
"6.4.1",
|
||||
"6.4.2",
|
||||
"6.6"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ Populated against RNS 1.2.4 / LXMF 0.9.7:
|
|||
| `verify_lxmf_opportunistic.py` | §5.1, §5.2, §5.5, §5.6 — full identity → encrypt → decrypt → parse round-trip | ✅ |
|
||||
| `verify_proof_packet.py` | §6.5 — implicit (64B) and explicit (96B) proof body forms, validator length-dispatch | ✅ |
|
||||
| `verify_link_handshake.py` | §6.1, §6.2, §6.3, §6.6 — LINKREQUEST/LRPROOF body order, link_id derivation, signalling | ✅ |
|
||||
| `verify_link_lrrtt.py` | §6.4.2, §6.4.3 — LRRTT wire form, HEADER_1 header, dest_type=LINK, ctx=0xfe, link-form Token body, msgpack float64 plaintext | ✅ |
|
||||
| `verify_path_request.py` | §1.2 well-known hashes, §7.1 LXMF path-preamble gating | ✅ |
|
||||
| `verify_rnode_split.py` | §8.3 — RNode air-frame split-packet TX/RX state machines | ✅ |
|
||||
| `verify_msgpack_quirk.py` | §9.3 — encoding name as bytes vs str affects upstream parsing | ✅ |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import tempfile
|
|||
|
||||
import RNS
|
||||
from RNS.Link import Link
|
||||
from RNS.vendor import umsgpack
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
|
@ -61,6 +62,14 @@ INITIATOR_X25519_PRIV = bytes.fromhex("11"*32)
|
|||
INITIATOR_ED25519_PRIV = bytes.fromhex("22"*32)
|
||||
RESPONDER_X25519_PRIV = bytes.fromhex("33"*32)
|
||||
|
||||
# Pinned LRRTT inputs. The IV here gets injected via os.urandom while
|
||||
# packing the LRRTT packet so the wire bytes are reproducible. RTT
|
||||
# value is the initiator's measured LRREQ->LRPROOF time per S6.4.2;
|
||||
# the exact value is non-load-bearing (responder takes max with its
|
||||
# own measurement) but pinning it keeps the wire bytes deterministic.
|
||||
LRRTT_RTT_SECONDS = 0.05
|
||||
LRRTT_IV = bytes.fromhex("44"*16)
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
print(f"FAIL: {msg}")
|
||||
|
|
@ -212,6 +221,33 @@ def main():
|
|||
if initiator.link_id != responder.link_id:
|
||||
fail(f"link_id disagree: ini={initiator.link_id.hex()} res={responder.link_id.hex()}")
|
||||
|
||||
# 4. Build the initiator's LRRTT packet (S6.4.2). The initiator
|
||||
# emits this immediately after LRPROOF validation, before any
|
||||
# application DATA. We pin os.urandom so the Token IV is
|
||||
# deterministic; the rest of the wire form falls out of the
|
||||
# link's derived_key (already populated above).
|
||||
lrrtt_plaintext = umsgpack.packb(LRRTT_RTT_SECONDS)
|
||||
token_mod = sys.modules["RNS.Cryptography.Token"]
|
||||
real_urandom = token_mod.os.urandom
|
||||
|
||||
def fake_urandom(n):
|
||||
if n == 16: return LRRTT_IV
|
||||
return real_urandom(n)
|
||||
|
||||
token_mod.os.urandom = fake_urandom
|
||||
try:
|
||||
lrrtt_packet = RNS.Packet(initiator, lrrtt_plaintext,
|
||||
context=RNS.Packet.LRRTT)
|
||||
lrrtt_packet.pack()
|
||||
finally:
|
||||
token_mod.os.urandom = real_urandom
|
||||
|
||||
# Sanity round-trip: the responder side should decrypt
|
||||
# the captured ciphertext and recover the same float.
|
||||
if responder.decrypt(lrrtt_packet.ciphertext) != lrrtt_plaintext:
|
||||
fail("LRRTT round-trip failed: responder.decrypt did not "
|
||||
"recover the pinned plaintext.")
|
||||
|
||||
# Slice fields per S6.1 / S6.2 for human inspection
|
||||
lr_data = captured_lr["data"]
|
||||
ini_x25519_pub = lr_data[:32]
|
||||
|
|
@ -254,10 +290,18 @@ def main():
|
|||
"derived_key_hex": initiator.derived_key.hex(),
|
||||
"mtu": initiator.mtu,
|
||||
"mode": initiator.mode,
|
||||
"lrrtt": {
|
||||
"rtt_seconds": LRRTT_RTT_SECONDS,
|
||||
"iv_hex": LRRTT_IV.hex(),
|
||||
"plaintext_hex": lrrtt_plaintext.hex(),
|
||||
"raw_hex": lrrtt_packet.raw.hex(),
|
||||
"body_hex": lrrtt_packet.ciphertext.hex(),
|
||||
},
|
||||
},
|
||||
"rns_version_at_generation": RNS.__version__,
|
||||
"generator_script": "tools/regen_links.py",
|
||||
"verifies_spec_sections": ["6.1", "6.2", "6.3", "6.6"],
|
||||
"verifies_spec_sections": ["6.1", "6.2", "6.3", "6.4.1",
|
||||
"6.4.2", "6.6"],
|
||||
}
|
||||
|
||||
payload = {
|
||||
|
|
@ -276,7 +320,11 @@ def main():
|
|||
"responder, with bob's identity Ed25519 sig over `link_id || "
|
||||
"responder_X25519_pub || responder_long_term_Ed25519_pub || "
|
||||
"signalling`, and matching `lrproof_raw_hex`; (d) running "
|
||||
"ECDH+HKDF on either side and matching `derived_key_hex`. "
|
||||
"ECDH+HKDF on either side and matching `derived_key_hex`; "
|
||||
"(e) building an LRRTT packet (S6.4.2) addressed to the "
|
||||
"link_id with `context=LRRTT (0xfe)` and an encrypted body "
|
||||
"of `umsgpack.packb(lrrtt.rtt_seconds)`, using `lrrtt.iv_hex` "
|
||||
"as the Token IV, and matching `lrrtt.raw_hex` / `lrrtt.body_hex`. "
|
||||
"Regenerate with `generator_script`."
|
||||
),
|
||||
"vectors": [vector],
|
||||
|
|
|
|||
128
tools/verify_link_lrrtt.py
Normal file
128
tools/verify_link_lrrtt.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Verifier for SPEC.md S6.4.2 — LRRTT, the initiator's link-activation packet.
|
||||
|
||||
Consumes `test-vectors/links.json`. Asserts that the recorded LRRTT
|
||||
vector decomposes as documented in SPEC.md S6.4.2:
|
||||
|
||||
- Wire header is HEADER_1 with no transport_id (per S6.4.3).
|
||||
- packet_type = DATA (0x00).
|
||||
- destination_type = LINK (0x03), dest_hash = link_id.
|
||||
- context = LRRTT (0xfe).
|
||||
- Body is link-form Token encryption (S3.1, no eph_pub prefix):
|
||||
IV(16) || AES256_CBC(plaintext, key, iv) || HMAC(32)
|
||||
keyed off the link's derived_key.
|
||||
- Plaintext decodes via umsgpack as a single float64 matching
|
||||
the recorded rtt_seconds.
|
||||
|
||||
Run from repo root:
|
||||
|
||||
python tools/verify_link_lrrtt.py
|
||||
|
||||
Prints PASS lines and exits 0 if every assertion holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import RNS
|
||||
from RNS.Cryptography.Token import Token
|
||||
from RNS.vendor import umsgpack
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VEC_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json")
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with open(VEC_PATH, "r", encoding="utf-8") as f:
|
||||
vectors = json.load(f)["vectors"]
|
||||
|
||||
for v in vectors:
|
||||
label = v["label"]
|
||||
expected = v["expected"]
|
||||
if "lrrtt" not in expected:
|
||||
print(f"SKIP {label}: no lrrtt field")
|
||||
continue
|
||||
|
||||
lrrtt = expected["lrrtt"]
|
||||
link_id = bytes.fromhex(expected["link_id_hex"])
|
||||
derived_key = bytes.fromhex(expected["derived_key_hex"])
|
||||
rtt_seconds = lrrtt["rtt_seconds"]
|
||||
iv = bytes.fromhex(lrrtt["iv_hex"])
|
||||
plaintext = bytes.fromhex(lrrtt["plaintext_hex"])
|
||||
raw = bytes.fromhex(lrrtt["raw_hex"])
|
||||
body = bytes.fromhex(lrrtt["body_hex"])
|
||||
|
||||
# 1. Header decomposition (S2.1 + S6.4.2 + S6.4.3).
|
||||
if len(raw) < 19:
|
||||
fail(f"{label}: raw is shorter than the 19-byte HEADER_1 frame")
|
||||
|
||||
flags = raw[0]
|
||||
hops = raw[1]
|
||||
dest_hash = raw[2:18]
|
||||
context = raw[18]
|
||||
ciphertext = raw[19:]
|
||||
|
||||
header_type = (flags & 0b01000000) >> 6
|
||||
context_flag = (flags & 0b00100000) >> 5
|
||||
transport_type = (flags & 0b00010000) >> 4
|
||||
destination_type = (flags & 0b00001100) >> 2
|
||||
packet_type = (flags & 0b00000011)
|
||||
|
||||
if header_type != 0:
|
||||
fail(f"{label}: header_type = {header_type}, expected HEADER_1 (0) per S6.4.3")
|
||||
if packet_type != 0:
|
||||
fail(f"{label}: packet_type = {packet_type}, expected DATA (0) per S6.4.2")
|
||||
if destination_type != 3:
|
||||
fail(f"{label}: destination_type = {destination_type}, expected LINK (3) per S6.4.2")
|
||||
if context != 0xfe:
|
||||
fail(f"{label}: context = 0x{context:02x}, expected LRRTT (0xfe) per S6.4.2")
|
||||
if dest_hash != link_id:
|
||||
fail(f"{label}: dest_hash = {dest_hash.hex()}, expected link_id = {link_id.hex()}")
|
||||
if hops != 0:
|
||||
fail(f"{label}: hops = {hops}, originator emits 0")
|
||||
|
||||
print(f"PASS {label} S6.4.2 LRRTT header: HEADER_1 + DATA + LINK + ctx=0xfe + dest_hash=link_id")
|
||||
print(f" (context_flag={context_flag}, transport_type={transport_type})")
|
||||
|
||||
# 2. Body length and IV match the link-form Token layout (S3.1).
|
||||
if ciphertext != body:
|
||||
fail(f"{label}: raw[19:] != body — vector self-inconsistency")
|
||||
if ciphertext[:16] != iv:
|
||||
fail(f"{label}: body IV {ciphertext[:16].hex()} != recorded iv_hex {iv.hex()}")
|
||||
|
||||
print(f"PASS {label} S3.1 link-form Token: 16B IV || ciphertext || 32B HMAC, no eph_pub prefix")
|
||||
|
||||
# 3. Token decrypt round-trip using the link's derived_key.
|
||||
recovered = Token(derived_key).decrypt(body)
|
||||
if recovered != plaintext:
|
||||
fail(f"{label}: Token decrypt did not recover plaintext\n"
|
||||
f" got: {recovered.hex() if recovered else None}\n"
|
||||
f" exp: {plaintext.hex()}")
|
||||
print(f"PASS {label} S6.4.2 body decrypts under derived_key to recorded plaintext")
|
||||
|
||||
# 4. Plaintext is umsgpack(float) matching rtt_seconds.
|
||||
decoded = umsgpack.unpackb(plaintext)
|
||||
if not isinstance(decoded, float):
|
||||
fail(f"{label}: plaintext umsgpack-decoded to {type(decoded).__name__}, expected float")
|
||||
if decoded != rtt_seconds:
|
||||
fail(f"{label}: decoded float {decoded!r} != recorded rtt_seconds {rtt_seconds!r}")
|
||||
# msgpack float64 tag is 0xcb plus 8 IEEE 754 bytes — total 9 bytes.
|
||||
if len(plaintext) != 9 or plaintext[0] != 0xcb:
|
||||
fail(f"{label}: plaintext is not a 9-byte msgpack float64 (tag 0xcb)")
|
||||
print(f"PASS {label} S6.4.2 plaintext = umsgpack(float64) rtt_seconds={rtt_seconds!r} (9B, tag 0xcb)")
|
||||
|
||||
print("ALL PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue