Correct SPEC.md §6.2 LRPROOF body order and §6.3 link_id offsets

Two source-cited corrections found while drafting the link send flow:

§6.2 — the LRPROOF body is signature(64) || responder_X25519_pub(32) ||
[signalling], not link_id || responder_X25519_pub || signature ||
[signalling]. The link_id appears in the packet header (dest_hash
position) per RNS/Packet.py:182-184 when context==LRPROOF, not in the
body. The responder's long-term Ed25519 pub is also NOT on the wire —
both sides know it from a prior announce, and it is included only in the
signature input. Citations: RNS/Link.py:373 (signer), :376 (proof_data),
:417 (validator).

§6.3 — get_hashable_part offsets N are 2 for HEADER_1 and 18 for HEADER_2
(skip flags+hops, and additionally skip transport_id for HEADER_2),
producing the same hashable_part on both sides regardless of relay
conversion. Previously listed as 18/34, which would have stripped the
dest_hash. Citation: RNS/Packet.py:354-361.

Both corrections are direct upstream source citations (criterion #2 from
agent.md §1) so they are recorded as verified. todo.md adds an entry to
write tools/verify_link_handshake.py to lock them in with a runtime test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rob 2026-05-03 10:24:07 -04:00
commit 8480555320
2 changed files with 30 additions and 8 deletions

25
SPEC.md
View file

@ -292,30 +292,39 @@ Both initiator-side keys are **fresh ephemeral keys** (not the initiator's long-
### 6.2 LRPROOF (responder → initiator) ### 6.2 LRPROOF (responder → initiator)
A `packet_type = PROOF (3)` with `context = 0xff`, addressed to the initiator's transport_id (or to `link_id` when 1 hop away). Body: A `packet_type = PROOF (3)` with `context = 0xff`, addressed to the link itself — i.e. `dest_hash` in the packet header is the 16-byte `link_id` (`RNS/Packet.py:182-184`: when context is `LRPROOF`, `header += destination.link_id` and the body is appended unencrypted).
Body (`proof_data` at `RNS/Link.py:376`):
``` ```
link_id(16) || responder_X25519_pub(32) || signature(64) || [signalling(3)] signature(64) || responder_X25519_pub(32) || [signalling(3)]
``` ```
Only the responder's X25519 is fresh-ephemeral; the responder signs with its **long-term** Ed25519 private key (asymmetric with the initiator). Signature input: Only the responder's X25519 is fresh-ephemeral; the responder signs with its **long-term** Ed25519 private key (asymmetric with the initiator). The responder's long-term Ed25519 public key is **not** sent on the wire — both sides already know it from the responder's prior announce, and it is included implicitly in the signature input. Signature input (`RNS/Link.py:373` for the signer, `:417` for the validator):
``` ```
signed_data = link_id || responder_X25519_pub || responder_long_term_Ed25519_pub || [signalling] signed_data = link_id || responder_X25519_pub || responder_long_term_Ed25519_pub || [signalling]
``` ```
The full wire packet is therefore: `flags(1) || hops(1) || link_id(16) || context=0xff(1) || signature(64) || responder_X25519_pub(32) || [signalling(3)]`.
### 6.3 link_id derivation ### 6.3 link_id derivation
``` ```
link_id = SHA256(hashable_part_of_LINKREQUEST_packet)[:16] link_id = SHA256(hashable_part_of_LINKREQUEST_packet)[:16]
hashable_part = (flags & 0x0F) || raw[N:]
where N = 18 for HEADER_1, 34 for HEADER_2
``` ```
The "hashable part" deliberately strips `header_type`, `context_flag`, `transport_type` (top 4 bits of flags — modifiable by transit relays) and the `hops` byte (modified by every relay). This produces the same `link_id` whether computed at the initiator (HEADER_1) or at the responder (HEADER_2 if the LINKREQUEST went through a relay) — both sides agree on the 16-byte ID. `hashable_part` is built by `Packet.get_hashable_part` (`RNS/Packet.py:354-361`):
For LINKREQUEST packets specifically, the trailing 3 signalling bytes (if present, indicated by body length > 64) are stripped from the END of `hashable_part` before hashing. ```
hashable_part = byte(flags & 0x0F) || raw[N:]
where N = 2 for HEADER_1 (strip flags + hops)
N = 18 for HEADER_2 (strip flags + hops + transport_id)
```
The "hashable part" deliberately strips `header_type`, `context_flag`, `transport_type` (top 4 bits of flags — modifiable by transit relays), the `hops` byte (modified by every relay), and (for HEADER_2) the `transport_id` (added by the originator and re-written by each relay). What remains in both cases is the low nibble of flags + dest_hash + context + body, so the resulting `link_id` is the same whether the LINKREQUEST is hashed at the initiator (HEADER_1) or at the responder after one or more transport relays (HEADER_2). Both sides agree on the 16-byte ID.
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:340-347`) 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 key derivation

13
todo.md
View file

@ -71,6 +71,19 @@ to remove their markers:
`RATCHET_EXPIRY = 60*60*24*30` (`RNS/Identity.py:69`). `RATCHET_EXPIRY = 60*60*24*30` (`RNS/Identity.py:69`).
SPEC.md §7.4 corrected. SPEC.md §7.4 corrected.
## Open `⚠️` items needing a runtime verifier
- [ ] **Lock in the §6.2 / §6.3 corrections with `verify_link_handshake.py`.**
The wire-byte order of the LRPROOF body (`signature || responder_X25519_pub || signalling`,
not `link_id || responder_X25519_pub || signature || signalling`) and
the `link_id` derivation offsets (`N=2` for HEADER_1, `N=18` for HEADER_2,
not 18/34) were corrected against direct upstream source citations
(`RNS/Link.py:376`, `RNS/Packet.py:354-361`) in `SPEC.md` §6.2/§6.3
while writing `flows/send-link-lxmf.md`. They are source-cited but
not yet exercised by a runtime verifier. Add `tools/verify_link_handshake.py`
that drives an upstream LINKREQUEST → LRPROOF → ACTIVE handshake and
asserts byte-level layouts + `link_id` invariance under HEADER_1↔HEADER_2.
## Spec polishing (lower priority) ## Spec polishing (lower priority)
- [ ] **Split `SPEC.md` into per-layer files** as the document grows - [ ] **Split `SPEC.md` into per-layer files** as the document grows