Compare commits
2 commits
3659ba0324
...
7ffbb0ef5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ffbb0ef5e | |||
| 7433063bfb |
16 changed files with 1108 additions and 38 deletions
18
SPEC.md
18
SPEC.md
|
|
@ -686,6 +686,16 @@ destination_hash(16) || source_hash(16) || signature(64) || msgpack_payload(...)
|
|||
|
||||
Full layout. The Link's session key encrypts the whole blob.
|
||||
|
||||
For DIRECT delivery, upstream computes
|
||||
`content_size = len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD` and
|
||||
selects a single Link DATA packet when `content_size <=
|
||||
LINK_PACKET_MAX_CONTENT`; otherwise it sends the complete body as a Resource.
|
||||
With default RNS 1.2.4 / LXMF 0.9.7 parameters the boundary is 319/320. The
|
||||
threshold applies to this computed LXMF content size, not simply the raw
|
||||
`content` field and not the complete signed body. At the 319 boundary, the
|
||||
complete canonical body is 431 bytes (`Link.MDU`) and the Link-encrypted wire
|
||||
packet is 499 bytes. (verified by `tools/verify_link_lxmf.py`)
|
||||
|
||||
### 5.3 `msgpack_payload`
|
||||
|
||||
A msgpack array of 4 elements (5th optional):
|
||||
|
|
@ -2411,7 +2421,7 @@ The repeater repo's `pre_build.py` patches several other microReticulum protocol
|
|||
|
||||
## 10. Resource fragmentation protocol
|
||||
|
||||
A **Resource** is the standard RNS mechanism for transferring a payload that exceeds the per-packet content limit of an established Reticulum Link. LXMF, Link REQUEST/RESPONSE, NomadNet, and file-transfer utilities use it for large bodies; an application can also define its own sequencing protocol or use Channel, so Resource is not the only possible large-data mechanism. With default RNS 1.2.4 / LXMF 0.9.7 parameters, DIRECT LXMF selects Resource when content exceeds **319 bytes** (`LINK_PACKET_MAX_CONTENT = Link.MDU(431) - LXMF_OVERHEAD(112)`, verified by `tools/verify_resource.py`). Resource is built **on top of** an active Link — it relies on the Link's session key for encryption (§3.1 link-derived form) and on the Link's bidirectional DATA channel for control traffic.
|
||||
A **Resource** is the standard RNS mechanism for transferring a payload that exceeds the per-packet content limit of an established Reticulum Link. LXMF, Link REQUEST/RESPONSE, NomadNet, and file-transfer utilities use it for large bodies; an application can also define its own sequencing protocol or use Channel, so Resource is not the only possible large-data mechanism. With default RNS 1.2.4 / LXMF 0.9.7 parameters, DIRECT LXMF selects Resource when its **computed LXMF content size** exceeds **319 bytes** (`LINK_PACKET_MAX_CONTENT = Link.MDU(431) - LXMF_OVERHEAD(112)`, verified by `tools/verify_link_lxmf.py`). This is `len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD`, not necessarily `len(content)` and not the complete signed body. Resource is built **on top of** an active Link — it relies on the Link's session key for encryption (§3.1 link-derived form) and on the Link's bidirectional DATA channel for control traffic.
|
||||
|
||||
The complete reference is `RNS/Resource.py` (1380 lines in RNS 1.2.4); `RNS/Packet.py:74-79` defines the context constants. This section describes the wire-level invariants a clean-room implementation must respect; many implementation choices (window sizing heuristics, watchdog timers, EIFR computation) are private and listed only when their absence would cause an interop break.
|
||||
|
||||
|
|
@ -2419,7 +2429,7 @@ The complete reference is `RNS/Resource.py` (1380 lines in RNS 1.2.4); `RNS/Pack
|
|||
|
||||
Three triggers in upstream:
|
||||
|
||||
1. **`LXMessage.send()` for `DIRECT` method with `representation == RESOURCE`.** Set automatically when the encrypted-form LXMF body exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:415-421`).
|
||||
1. **`LXMessage.send()` for `DIRECT` method with `representation == RESOURCE`.** Set automatically when the computed LXMF `content_size` exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:405-421`; verified by `tools/verify_link_lxmf.py`).
|
||||
2. **NomadNet page request fulfillment** — a server returning a page whose body exceeds the link MTU.
|
||||
3. **Direct file transfers** via `rncp` and similar utilities.
|
||||
|
||||
|
|
@ -3769,8 +3779,10 @@ See [`test-vectors/`](test-vectors/). Currently populated:
|
|||
- **`announces.json`** — signed announce packets, with and without ratchet material. Verified by `tools/verify_announce_roundtrip.py`; regenerated by `tools/regen_announces.py`. Covers SPEC.md §4.1, §4.2, and §4.5.
|
||||
- **`lxmf.json`** — deterministic opportunistic LXMF plaintext and Token ciphertext vectors. Verified by `tools/verify_lxmf_opportunistic.py`; regenerated by `tools/regen_lxmf.py`. Covers SPEC.md §3 and §5.
|
||||
- **`links.json`** — Link handshake and LRRTT vectors, including LINKREQUEST, LRPROOF, derived keys, and the activation packet. Verified by `tools/verify_link_handshake.py` and `tools/verify_link_lrrtt.py`; regenerated by `tools/regen_links.py`. Covers SPEC.md §6.1-§6.4 and §6.6.
|
||||
- **`resources.json`** — deterministic multi-part Resource ciphertext, part packets, hashmap, advertisement, and proof body. Verified by `tools/verify_resource.py`; regenerated by `tools/regen_resources.py`. Covers SPEC.md §10.2, §10.4, §10.8, and §10.12.
|
||||
- **`link-lxmf.json`** — deterministic DIRECT LXMF vectors at the exact PACKET/RESOURCE boundary, using the session key from `links.json`. Verified by `tools/verify_link_lxmf.py`; regenerated by `tools/regen_link_lxmf.py`. Covers SPEC.md §5.2, §5.5, §5.6, §6.4.3, and §10.1.
|
||||
|
||||
Remaining vector work should focus on negative/rejection cases and link-delivered LXMF bodies rather than the original bootstrap categories.
|
||||
Remaining vector work should focus on broader negative/rejection cases rather than the original bootstrap categories.
|
||||
|
||||
An implementation that round-trips every test vector — both directions — should be wire-compatible with upstream Reticulum and LXMF for the covered operations.
|
||||
|
||||
|
|
|
|||
4
agent.md
4
agent.md
|
|
@ -167,8 +167,8 @@ Initial confidence assessment (subjective, not authoritative — re-do this audi
|
|||
| §7.6 `TCPServerInterface.OUT` override | Source-cited; matches behavior observed in the mobile-app's local-transport experiments. |
|
||||
| §8 KISS / HDLC framing | High — both work in production on the reference clients |
|
||||
| §9.1–§9.8 Implementation gotchas | Each was a real bug that bit a real implementation. High confidence each is real; some lack formal test scripts. |
|
||||
| §10 Resource fragmentation | Source-cited from `RNS/Resource.py` against RNS 1.2.4; not yet runtime-verified in this repo's `tools/`. |
|
||||
| §11 Test vectors | Historical bootstrap item. `test-vectors/` is now populated with identities, announces, opportunistic LXMF, and Link vectors. Future work should add negative vectors and link-delivered LXMF coverage. |
|
||||
| §10 Resource fragmentation | Source-audited against RNS 1.2.4 and runtime-verified by `tools/verify_resource.py`, including deterministic vectors, receiver assembly/proof, multi-segment sizing, and negative cases. |
|
||||
| §11 Test vectors | Historical bootstrap item. `test-vectors/` now covers identities, announces, opportunistic LXMF, Link establishment, link-delivered LXMF, and Resource. Future work should add broader negative vectors. |
|
||||
| §12 Source map | High |
|
||||
|
||||
**Historical bootstrap tasks from the initial audit, now mostly complete:**
|
||||
|
|
|
|||
75
audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md
Normal file
75
audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Tier 1 Audit: Link-Delivered LXMF
|
||||
|
||||
Question: How does upstream LXMF 0.9.7 select, emit, receive, and acknowledge
|
||||
DIRECT LXMF over an established RNS 1.2.4 Link?
|
||||
|
||||
Evidence baseline:
|
||||
|
||||
- RNS package: `rns==1.2.4`
|
||||
- LXMF package: `lxmf==0.9.7`
|
||||
- Sources: `LXMF/LXMessage.py`, `LXMF/LXMRouter.py`, `RNS/Packet.py`,
|
||||
`RNS/Link.py`, and `RNS/Resource.py`
|
||||
- Audit date: 2026-06-08
|
||||
|
||||
The Tier 2 evidence is `tools/verify_link_lxmf.py` and the deterministic
|
||||
`test-vectors/link-lxmf.json`. Confirmed findings are promoted into `SPEC.md`
|
||||
and the Link-LXMF flow documents.
|
||||
|
||||
## Confirmed Model
|
||||
|
||||
1. `LXMessage.pack()` computes:
|
||||
|
||||
```
|
||||
content_size = len(packed_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD
|
||||
```
|
||||
|
||||
DIRECT selects PACKET when `content_size <= LINK_PACKET_MAX_CONTENT`
|
||||
and Resource otherwise (`LXMessage.py:405-421`). The threshold applies to
|
||||
this computed value, not simply `len(content)` and not the complete signed
|
||||
LXMF body.
|
||||
|
||||
2. With default RNS 1.2.4 / LXMF 0.9.7 parameters,
|
||||
`LINK_PACKET_MAX_CONTENT = 319`. The deterministic boundary fixtures use
|
||||
empty title/fields, so their raw content lengths also happen to be 319 and
|
||||
320. The PACKET fixture's complete canonical LXMF body is 431 bytes, equal
|
||||
to `Link.MDU`; after Link Token framing its complete wire packet is 499
|
||||
bytes.
|
||||
|
||||
3. DIRECT/PACKET passes the complete canonical body
|
||||
`destination_hash || source_hash || signature || msgpack_payload` to
|
||||
`RNS.Packet(link, packed)` (`LXMessage.py:627-635`). Packet packing emits
|
||||
HEADER_1 DATA/LINK with context NONE and Link-derived Token encryption
|
||||
(`Packet.py:176-219`).
|
||||
|
||||
4. DIRECT/RESOURCE passes that same complete canonical body to
|
||||
`RNS.Resource(packed, link, ...)` (`LXMessage.py:643-653`). Resource then
|
||||
applies its own whole-stream Link encryption and fragmentation.
|
||||
|
||||
5. On receive, `LXMRouter.delivery_packet()` proves the Link DATA packet,
|
||||
classifies `destination_type == LINK` as DIRECT, and passes the decrypted
|
||||
body unchanged to `lxmf_delivery()` (`LXMRouter.py:1822-1850`). Unlike the
|
||||
opportunistic path, it does not prepend a destination hash.
|
||||
|
||||
6. `delivery_resource_concluded()` passes the assembled Resource body to
|
||||
`lxmf_delivery(..., method=DIRECT)` (`LXMRouter.py:1876-1885`).
|
||||
|
||||
7. PACKET completion is the regular Link DATA proof callback; Resource
|
||||
completion is the Resource callback. Both converge on
|
||||
`LXMessage.__mark_delivered()` (`LXMessage.py:471-490, 594-603`).
|
||||
|
||||
## Tier 2 Scope
|
||||
|
||||
`tools/verify_link_lxmf.py` verifies:
|
||||
|
||||
1. Exact computed-content boundary: 319 selects PACKET, 320 selects Resource.
|
||||
2. Deterministic Link DATA bytes using the session key from `links.json`.
|
||||
3. DIRECT/PACKET decrypts to the complete canonical LXMF body and validates
|
||||
Alice's signature.
|
||||
4. DIRECT/RESOURCE decrypts and reassembles to the same canonical form.
|
||||
5. A wrong Link key cannot decrypt the DIRECT/PACKET ciphertext.
|
||||
6. `LXMRouter.delivery_packet()` proves Link DATA, classifies it DIRECT, and
|
||||
forwards the full plaintext unchanged.
|
||||
|
||||
This work unit does not run a live threaded Link exchange. Link establishment,
|
||||
LRRTT activation, generic Link proof format, and generic Resource behavior are
|
||||
covered by their existing focused verifiers.
|
||||
|
|
@ -10,9 +10,9 @@ Evidence baseline:
|
|||
`RNS/Link.py`, and `LXMF/LXMessage.py`
|
||||
- Audit date: 2026-06-08
|
||||
|
||||
This began as a Tier 1 source-analysis report. The focused Tier 2 checks now
|
||||
live in `tools/verify_resource.py`; the confirmed F1-F6 corrections were
|
||||
promoted into `SPEC.md` §10 on 2026-06-08.
|
||||
This began as a Tier 1 source-analysis report. The completed Tier 2 checks
|
||||
live in `tools/verify_resource.py` and `test-vectors/resources.json`; the
|
||||
confirmed F1-F6 corrections were promoted into `SPEC.md` §10 on 2026-06-08.
|
||||
|
||||
## Confirmed Core Model
|
||||
|
||||
|
|
@ -117,8 +117,8 @@ later-version note only when documenting an actual version change.
|
|||
|
||||
## Tier 2 Verifier Scope
|
||||
|
||||
The first focused verifier is implemented in `tools/verify_resource.py` and
|
||||
avoids a live threaded transfer. It currently covers items 1-6 below:
|
||||
The focused verifier is implemented in `tools/verify_resource.py` and avoids
|
||||
a live threaded transfer. It covers:
|
||||
|
||||
1. Construct a Resource with a deterministic fake Link encryption key, fixed
|
||||
throwaway prefix, and fixed advertisement `r`.
|
||||
|
|
@ -131,11 +131,11 @@ avoids a live threaded transfer. It currently covers items 1-6 below:
|
|||
including simultaneous part fulfilment and RESOURCE_HMU generation.
|
||||
7. Verify receiver assembly strips the throwaway prefix, decrypts once,
|
||||
validates the hash, and emits the expected RESOURCE_PRF bytes.
|
||||
8. Add a multi-segment fixture proving `d` remains total logical size while
|
||||
8. Verify a multi-segment fixture proving `d` remains total logical size while
|
||||
`t`, `n`, `h`, and `r` describe the current segment.
|
||||
9. Add negative cases: malformed ADV, wrong `r`, corrupt part, invalid HMU
|
||||
9. Verify negative cases: malformed ADV, wrong `r`, corrupt part, invalid HMU
|
||||
boundary, and oversized decompression.
|
||||
|
||||
Remaining Tier 2 work is a deterministic `test-vectors/resources.json` plus
|
||||
items 7-9. The confirmed first claim set has already been promoted into
|
||||
`SPEC.md` and the Resource flow documents.
|
||||
The deterministic fixture is regenerated by `tools/regen_resources.py`.
|
||||
Tier 2 Resource work is complete for this audit scope; confirmed claims have
|
||||
been promoted into `SPEC.md` and the Resource flow documents.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ A regular DATA packet on the link (`context = NONE`, Token-encrypted with link s
|
|||
|
||||
The handler calls `packet.prove()` immediately (mandatory PROOF receipt per §6.5), then dispatches the body to `LXMessage.unpack_from_bytes` and `lxmf_delivery` exactly like the opportunistic flow's steps 10-12.
|
||||
|
||||
`delivery_packet` passes Link plaintext through unchanged and marks the method
|
||||
DIRECT; it does **not** prepend a destination hash. This distinction and the
|
||||
319/320 PACKET/Resource boundary are verified by
|
||||
`tools/verify_link_lxmf.py`.
|
||||
|
||||
### 6. Inbound LXMF DATA — Resource representation
|
||||
|
||||
A larger LXMF body arrives as a Resource transfer per `flows/receive-resource.md`. The Link's `resource_strategy = ACCEPT_APP` triggers `delivery_resource_advertised(resource)` (`LXMF/LXMRouter.py:1867-1874`):
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ Out of scope: opportunistic delivery (see [`send-opportunistic-lxmf.md`](send-op
|
|||
DIRECT is reached two ways:
|
||||
|
||||
1. **App-requested:** `LXMessage(desired_method=LXMessage.DIRECT, …)`. Used for messages too large for one packet, or for sessions where the app wants the link's full-duplex DATA channel (e.g. an interactive chat). The router opens or reuses an `RNS.Link` to the recipient and sends the message over it.
|
||||
2. **Auto-downgrade from OPPORTUNISTIC:** `LXMessage.pack` at `LXMF/LXMessage.py:394-398` falls back to `DIRECT` if the encrypted-form content size exceeds `ENCRYPTED_PACKET_MAX_CONTENT`. The originator may or may not surface this transition to the user; it's silent at the protocol layer.
|
||||
2. **Auto-downgrade from OPPORTUNISTIC:** `LXMessage.pack` at `LXMF/LXMessage.py:394-398` falls back to `DIRECT` if the computed LXMF `content_size` exceeds `ENCRYPTED_PACKET_MAX_CONTENT`. The originator may or may not surface this transition to the user; it's silent at the protocol layer.
|
||||
|
||||
Within DIRECT there are two **representations** decided at pack time (`LXMF/LXMessage.py:414-421`):
|
||||
Within DIRECT there are two **representations** decided at pack time (`LXMF/LXMessage.py:405-421`). Upstream compares its computed `content_size = len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD`, not simply `len(content)` or the full signed body:
|
||||
|
||||
- **PACKET** — the body fits in a single `LINK_PACKET_MAX_CONTENT`-sized DATA packet on the link.
|
||||
- **RESOURCE** — the body is larger than that and must be sent as an `RNS.Resource` (multi-packet, fragmented, with its own checksum / progress / retransmit machinery). The Resource fragmentation protocol is documented in [`../SPEC.md`](../SPEC.md) §10 and the dedicated send/receive Resource flows. This flow document covers PACKET in full and links to those details for RESOURCE.
|
||||
|
|
@ -147,11 +147,11 @@ Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leav
|
|||
|
||||
### 7. PROOF receipt arrives → `__mark_delivered` fires
|
||||
|
||||
The recipient (per the receive flow — TODO `receive-link-lxmf.md`) decrypts the link DATA, parses the LXMF body via `LXMessage.unpack_from_bytes`, validates the signature, and emits a PROOF for this packet (`Packet.prove` from inside `LXMRouter.delivery_packet` at line 1820, same as the opportunistic receive). The PROOF travels back along the link, `PacketReceipt.proven` resolves on the sender, and `__mark_delivered` puts the LXMessage in `DELIVERED`.
|
||||
The recipient (per [`receive-link-lxmf.md`](receive-link-lxmf.md)) decrypts the link DATA, parses the LXMF body via `LXMessage.unpack_from_bytes`, validates the signature, and emits a PROOF for this packet (`Packet.prove` from inside `LXMRouter.delivery_packet` at line 1820, same as the opportunistic receive). The PROOF travels back along the link, `PacketReceipt.proven` resolves on the sender, and `__mark_delivered` puts the LXMessage in `DELIVERED`.
|
||||
|
||||
### 8. (Optional) RESOURCE representation for large bodies
|
||||
|
||||
If the packed body exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:415-421`), `representation` is set to `RESOURCE` and step 5 instead constructs an `RNS.Resource` (`__as_resource`, line 651):
|
||||
If computed `content_size` exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:405-421`), `representation` is set to `RESOURCE` and step 5 instead constructs an `RNS.Resource` (`__as_resource`, line 651):
|
||||
|
||||
```python
|
||||
RNS.Resource(self.packed, self.__delivery_destination, callback=..., progress_callback=..., auto_compress=...)
|
||||
|
|
@ -173,9 +173,9 @@ This enables an interactive conversation over a single link rather than each mes
|
|||
|
||||
### 10. Link teardown
|
||||
|
||||
A link stays in `ACTIVE` until either side calls `link.teardown()`, the watchdog times out (no inbound activity within `Link.STALE_GRACE` after the keepalive interval), or the next-hop interface goes down. `RNS/Link.py` keepalives are `CTX_KEEPALIVE` (0xfd) packets; the cadence is `Link.KEEPALIVE` seconds.
|
||||
A link stays in `ACTIVE` until either side calls `link.teardown()`, the watchdog times out, or the next-hop interface goes down. `RNS/Link.py` keepalives use `KEEPALIVE (0xFA)`; their cadence is dynamically clamped between `KEEPALIVE_MIN = 5s` and `KEEPALIVE_MAX = 360s`.
|
||||
|
||||
Teardown sends `RNS.Packet(link, b"", context=Link.PROOF, ...)` informing the peer; once teardown completes, the link is removed from `Transport.active_links` and from `LXMRouter.direct_links`, and the next DIRECT message to the same destination has to repeat the full handshake from step 3.
|
||||
Teardown sends an encrypted `LINKCLOSE (0xFC)` packet whose plaintext body is `link_id`; once teardown completes, the link is removed from `Transport.active_links` and from `LXMRouter.direct_links`, and the next DIRECT message to the same destination has to repeat the full handshake from step 3.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -198,6 +198,10 @@ DATA on link (either direction), Token-encrypted (no eph_pub prefix):
|
|||
(plaintext = full LXMF body: dest_hash || src_hash || signature || msgpack_payload)
|
||||
```
|
||||
|
||||
At the default 319-byte computed-content boundary, the full LXMF plaintext is
|
||||
431 bytes and the complete Link DATA wire packet is 499 bytes. At 320,
|
||||
upstream switches to Resource. See `test-vectors/link-lxmf.json`.
|
||||
|
||||
Per SPEC.md §6.5 the receiver of any CTX_NONE DATA packet on the link MUST emit a PROOF receipt back; this is the mandatory `Packet.prove_packet` step on the receiving side, and is what resolves the sender's `PacketReceipt`.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ Populated against RNS 1.2.4 / LXMF 0.9.7:
|
|||
- ✅ `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, 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`).
|
||||
- ✅ `resources.json` — deterministic multi-part Resource ciphertext, part packets, hashmap, advertisement, and proof body (regenerator: `../tools/regen_resources.py`, verifier: `../tools/verify_resource.py`).
|
||||
- ✅ `link-lxmf.json` — DIRECT LXMF PACKET and Resource vectors at the exact 319/320 computed-content boundary (regenerator: `../tools/regen_link_lxmf.py`, verifier: `../tools/verify_link_lxmf.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.
|
||||
All six files are byte-deterministic across runs: regenerators pin every random source (ephemeral keys, IVs, `random_hash` values, timestamps) so the output is reproducible against a fixed upstream RNS / LXMF version.
|
||||
|
||||
See [`../agent.md`](../agent.md) §3 and [`../todo.md`](../todo.md) for the evidence model and remaining task list.
|
||||
|
||||
|
|
@ -23,6 +25,8 @@ Each vector lives in a per-domain JSON file, e.g.:
|
|||
- `announces.json` — full hex of a signed announce packet, plus the inputs that produced it (display_name, ratchetPub, etc.)
|
||||
- `lxmf.json` — sender + recipient identity, plaintext, expected ciphertext bytes
|
||||
- `links.json` — LINKREQUEST + LRPROOF + derived session keys
|
||||
- `resources.json` — Resource plaintext, encrypted stream, parts, hashmap, advertisement, and proof
|
||||
- `link-lxmf.json` — DIRECT LXMF Link DATA and Resource boundary forms
|
||||
|
||||
Each entry should include:
|
||||
|
||||
|
|
@ -47,6 +51,7 @@ For the spec to claim "an implementation that passes all test vectors interopera
|
|||
3. **Token encrypt + decrypt** — bidirectional, with both ratchet and long-term keys.
|
||||
4. **Opportunistic LXMF** — full plaintext → ciphertext → plaintext round-trip, signature valid both ways.
|
||||
5. **Link handshake** — LINKREQUEST built by client A, LRPROOF computed by upstream as B, both arrive at the same `link_id` and session keys.
|
||||
6. **Link-delivered LXMF** — body packed by client, decrypted + parsed by upstream.
|
||||
6. **Link-delivered LXMF** — body packed by client, decrypted + parsed by upstream. Covered by `link-lxmf.json`.
|
||||
7. **Resource transfer** — encrypt once, split into parts, validate ADV/hashmap, assemble, and emit the expected proof.
|
||||
|
||||
A separate vector set for FAILURE cases is also useful: malformed announces, expired ratchets, mismatched signatures. An implementation should reject those as a regression-prevention measure.
|
||||
|
|
|
|||
80
test-vectors/link-lxmf.json
Normal file
80
test-vectors/link-lxmf.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"_about": "DIRECT LXMF vectors at the exact upstream PACKET/RESOURCE boundary. Both carry the complete canonical LXMF body over the deterministic Link session from links.json. `content_size` is upstream's computed LXMF size, not necessarily the raw content field length.",
|
||||
"vectors": [
|
||||
{
|
||||
"label": "alice_to_bob_direct_packet_boundary",
|
||||
"inputs": {
|
||||
"src_identity_label": "alice",
|
||||
"dst_identity_label": "bob",
|
||||
"content_size": 319,
|
||||
"lxmf_timestamp": 1700000000.0,
|
||||
"link_vector_label": "alice_to_bob_aes256cbc",
|
||||
"token_iv_hex": "5152535455565758595a5b5c5d5e5f60"
|
||||
},
|
||||
"expected": {
|
||||
"method": 2,
|
||||
"representation": 1,
|
||||
"lxmf_packed_hex": "9695d17f22fa6e45d2b0cd3439a7ca7ec33c40a5b030596d95617dc4ca163aae05036402d951b1c68131b516673bcc04206ca5adb586ac7da3e63226e0febd19de6563326d055adbeb519e1d3418f887e0ef5d0ef5099c980e5e2b603555180094cb41d954fc40000000c400c5013f7878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787880",
|
||||
"link_packet_ciphertext_hex": "5152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264",
|
||||
"link_packet_raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474005152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264",
|
||||
"link_id_hex": "7ee5fe3e4952c9ac4519b537f6278474"
|
||||
},
|
||||
"rns_version_at_generation": "1.2.4",
|
||||
"lxmf_version_at_generation": "0.9.7",
|
||||
"generator_script": "tools/regen_link_lxmf.py",
|
||||
"verifies_spec_sections": [
|
||||
"5.2",
|
||||
"5.5",
|
||||
"5.6",
|
||||
"6.4.3",
|
||||
"10.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "alice_to_bob_direct_resource_boundary",
|
||||
"inputs": {
|
||||
"src_identity_label": "alice",
|
||||
"dst_identity_label": "bob",
|
||||
"content_size": 320,
|
||||
"lxmf_timestamp": 1700000000.0,
|
||||
"link_vector_label": "alice_to_bob_aes256cbc",
|
||||
"token_iv_hex": "6162636465666768696a6b6c6d6e6f70",
|
||||
"throwaway_prefix_hex": "71727374",
|
||||
"resource_random_hash_hex": "81828384",
|
||||
"auto_compress": false
|
||||
},
|
||||
"expected": {
|
||||
"method": 2,
|
||||
"representation": 2,
|
||||
"lxmf_packed_hex": "9695d17f22fa6e45d2b0cd3439a7ca7ec33c40a5b030596d95617dc4ca163aae1c7919c2ec56920071ec83d67bc4eecf89164eb35d124c70d083ea76c5f8439154586dcbbdfb9ea6a7697085a2d0ddb07f735a849c8267adc157064ed9cd8b0294cb41d954fc40000000c400c50140787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787880",
|
||||
"resource_hash_hex": "28adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64",
|
||||
"resource_expected_proof_hex": "9ec0ee7a5aaae728c1480bbe2b449e07328ef223caed65f386b84209749efe4d",
|
||||
"resource_advertisement_plaintext_hex": "8ba174cd01f0a164cd01b0a16e02a168c42028adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64a172c40481828384a16fc42028adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64a16901a16c01a171c0a16601a16dc40837cd98014cd2769c",
|
||||
"resource_parts": [
|
||||
{
|
||||
"body_hex": "6162636465666768696a6b6c6d6e6f70951cef742577ef844c26d338a9b873db7510b7733b592bf353089ef09d06dceccf084587a257101f5db226c1270441f50c799b7e79d09a8dce6ed3b6dc40447607e56756b9bb6164abb87f7c0d9fa51cc46f8ff7dc99e76b3adddf6d045e2b3ffc575ccc97123b9bf7d206eb7b67d6f3c9f8575a796a5401813ce5f0a66269dfc3693e8edc4b749a845cb0ae6e12bbc6a2e0dc00f448fec88998303ae77dc0b4d53e3b8bb4912ddff0a4f456df3c5ca3f227f7e764bb5e264211a224929cc8da96160b5589f366ab2ee08f10d3851f94b88fcd3de232665b1c1d1bd55cec99665dd9b667c72b8fe2908bffa6d5264fd21a7f3809c70cbb7342536fab74fb855103b7375630b724d4abd6a3f36864ddf15f4c6e6bb6f554a75fdb4f286cc089455589021320abef585e80caee8ea94f0c705ce928b8088055e1f98c441238bcac024eea36bc7728e7b81d34fa3f2d1d0c722dc77d234e271c9ece67361400db88775899039de948548f3826f6fed969915f041d404a42c3e56bcdb0f2d8e57f1be27e128ad73a4011745dca50ef042cd9eb853e9fe3d7cc1ab9b8809ff073700dabd9c14a71fb08980750b68d970ec9853cc37d8b179c436abf26bbbf7477891e",
|
||||
"map_hash_hex": "37cd9801",
|
||||
"raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474016162636465666768696a6b6c6d6e6f70951cef742577ef844c26d338a9b873db7510b7733b592bf353089ef09d06dceccf084587a257101f5db226c1270441f50c799b7e79d09a8dce6ed3b6dc40447607e56756b9bb6164abb87f7c0d9fa51cc46f8ff7dc99e76b3adddf6d045e2b3ffc575ccc97123b9bf7d206eb7b67d6f3c9f8575a796a5401813ce5f0a66269dfc3693e8edc4b749a845cb0ae6e12bbc6a2e0dc00f448fec88998303ae77dc0b4d53e3b8bb4912ddff0a4f456df3c5ca3f227f7e764bb5e264211a224929cc8da96160b5589f366ab2ee08f10d3851f94b88fcd3de232665b1c1d1bd55cec99665dd9b667c72b8fe2908bffa6d5264fd21a7f3809c70cbb7342536fab74fb855103b7375630b724d4abd6a3f36864ddf15f4c6e6bb6f554a75fdb4f286cc089455589021320abef585e80caee8ea94f0c705ce928b8088055e1f98c441238bcac024eea36bc7728e7b81d34fa3f2d1d0c722dc77d234e271c9ece67361400db88775899039de948548f3826f6fed969915f041d404a42c3e56bcdb0f2d8e57f1be27e128ad73a4011745dca50ef042cd9eb853e9fe3d7cc1ab9b8809ff073700dabd9c14a71fb08980750b68d970ec9853cc37d8b179c436abf26bbbf7477891e"
|
||||
},
|
||||
{
|
||||
"body_hex": "ffa6985bcc6b2709ea244b5951c90828c7758f63bf7ebf9c92d029aec4c32b24",
|
||||
"map_hash_hex": "4cd2769c",
|
||||
"raw_hex": "0c007ee5fe3e4952c9ac4519b537f627847401ffa6985bcc6b2709ea244b5951c90828c7758f63bf7ebf9c92d029aec4c32b24"
|
||||
}
|
||||
],
|
||||
"link_id_hex": "7ee5fe3e4952c9ac4519b537f6278474"
|
||||
},
|
||||
"rns_version_at_generation": "1.2.4",
|
||||
"lxmf_version_at_generation": "0.9.7",
|
||||
"generator_script": "tools/regen_link_lxmf.py",
|
||||
"verifies_spec_sections": [
|
||||
"5.2",
|
||||
"5.5",
|
||||
"5.6",
|
||||
"10.1",
|
||||
"10.2",
|
||||
"10.4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
test-vectors/resources.json
Normal file
52
test-vectors/resources.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"_about": "Deterministic Resource transfer vector over a fixed Link Token key. The Resource is uncompressed and spans multiple RESOURCE packets. A clean-room implementation should encrypt `throwaway_prefix || plaintext` once with the recorded Token key and IV, slice the ciphertext at the Resource SDU, reproduce every part and map hash, pack the advertisement, then assemble and emit the recorded PRF body.",
|
||||
"vectors": [
|
||||
{
|
||||
"label": "fixed_link_uncompressed_multi_part",
|
||||
"inputs": {
|
||||
"plaintext_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||
"token_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
||||
"token_iv_hex": "404142434445464748494a4b4c4d4e4f",
|
||||
"throwaway_prefix_hex": "10111213",
|
||||
"random_hash_hex": "20212223",
|
||||
"link_hash_hex": "00112233445566778899aabbccddeeff",
|
||||
"mtu": 500,
|
||||
"auto_compress": false
|
||||
},
|
||||
"expected": {
|
||||
"encrypted_stream_hex": "404142434445464748494a4b4c4d4e4f2e9d382a37451544df61eee81f38350c4883c306763ae9491e392fc51bb2579c295964e92e36cd787d899e959ae7c3bd83607c4a45ebabd30a4de196a2f7ba9d77aabd00ca89fc711c072fbef55dc346c51754955bf4616427b2917de04247985af831059d14a52654ed50a0b01e99daa097f369609cfd31957dc8d2737e603f954e7e0ea70a65f7fee60c9b8b8f2482cb00469a155a9e0601111de46004ae4cde1fc14ac9f3be5b37a8308f47a7a7524e9638af44100573cb35a3d55a436c6080ccdcac5bcce07fae741f6049b4f06c011abba11f4647917ecbd7da7071a7936606bfbfd69a4c264060e7e1a2e093b704c76e0b09dc6200e761fd711489fda28679a375fad63df1e732f66ac47fc2650a6db3ee818b201e93b8b7325d375c0dfd850db44215c5fdc4189d199a2758e067c8d2ce29737acc4b81e9d22604b1a3f6d6315b3003b11dcbad4b761fbc4b43a9e3446c6083a4a32293458c843aa57fd829b21afaa600fc438d292a00c1bc651f95291586414bc4e67fa2740da4def4ed2caaac74f39d8cecf00e957f848c1adea9bf78556e55d17c94fd550a99d328bacf6704d7db090a9759aabac010322f539019b28cea0238992638a358582386065fa79a46947f5028d9e884d9fc98304a43f318b13b2a9ac14ddbf7d68d898be03d7881bf347e5cdc209ab155d23cbe922430c71ae14902642310c50d7d9617f462b4674517abf1430b6bc7763299c6556e52187d6950f0ff71ca6a1dcffbce2415f074492dc511c6c489b1ea8ed5d34c46d8ce32cffb71567411b7f8da3a84b53b16bc4384bfbe3238e5487b18f4faf3b09ee83e53f537c699be3f9167fc27a7df92bea5ec4a20646100016cb6728a7a94d51184acbcfffab0b1ccf7f6ee61fc34df1dbfc98cc801e141111a88c5713748d929cbbd14d69613bacf12d60df4f4da0799f7b9b6a1200ad84732789ff629f63aef6e5ffd79741e6d9160a1350882de9a1c76aae13a2c70ed972200af3f4078837882a06501b514081cb465c6b69661c9a82459a59086d6bbda247d4eac9656d413fe88558ae6a4e6d94ed1d2bb87bfc75b68256291d7208bb5e890d30f4c53226e222288de73106b1d4ba3fb01addfb7b0b1b9fed386cfa5afdb8789f22e7cfe7f0b39c8c797d0b70fd33a89293df048820c86b80cfd6f8ed829483d9fadbb05b8b6b4c8680931d74ffc263a23ab7845fe92ab850638fae9c267a623e78afc26055864e0504a951451933f60a357e17d8471653dca6cc97b3ef92c1715c959c4df40bdacf257b106d1fc6e635c82208f4235621458afd2ad23ce1e799f596df9be514f25cfff1f65bebf46ff52c2f43cf7c2edae2873e70a938cb417aea136c2a3b9521863eb0ae97e7093b99ad9059b00ba08463ad3b68bf2a91b053c6119117aee992020cc288f0f1ab9ae3ee3618f83d03378a62282bc9fa93a47dfa5426114551bbf610b5549ba57d548001e8b67bf09a40878799321832ef266b588a4226854366f22822fd36a79443c17f52bfc88341405eef4b6f821ef255270e14a21ae11570f205b550268c69e9ea071f21cd599774ef2e0fe20ec7ab2146d36237b922b7e59b8b78d621872ff682d703829e627b04f23315c93d5304e991e584d29facf6704c4d444418b3af4c6514cc9aa63e396e976713077755f721ebb022013bb75b071308a71ac578dd5c124a0af521c7404f166c26a63116b377928cc5d971d230f1bdf445202869d5af5304fd5e743165c1d65f86cf46247898b43d6cbf65f2be6217421c6a6ba95df7fd0fda278855fcea67916d710511da70eaa8cfb0d630b21e725f5cb304d13d5ca39440edefecc3f394482c08399a6d62cf8",
|
||||
"resource_hash_hex": "a60281a93eccd1a9473ee0e72e3224bcf76792857bc003f1374facb56b0f3991",
|
||||
"expected_proof_hex": "1c0e68442e09a54939291962b45ff7a10c9871aee1c7a769c338ba483bd1a256",
|
||||
"resource_prf_body_hex": "a60281a93eccd1a9473ee0e72e3224bcf76792857bc003f1374facb56b0f39911c0e68442e09a54939291962b45ff7a10c9871aee1c7a769c338ba483bd1a256",
|
||||
"hashmap_hex": "94abb0d50fcf34cdd9769774",
|
||||
"advertisement_plaintext_hex": "8ba174cd0540a164cd0500a16e03a168c420a60281a93eccd1a9473ee0e72e3224bcf76792857bc003f1374facb56b0f3991a172c40420212223a16fc420a60281a93eccd1a9473ee0e72e3224bcf76792857bc003f1374facb56b0f3991a16901a16c01a171c0a16601a16dc40c94abb0d50fcf34cdd9769774",
|
||||
"parts": [
|
||||
{
|
||||
"body_hex": "404142434445464748494a4b4c4d4e4f2e9d382a37451544df61eee81f38350c4883c306763ae9491e392fc51bb2579c295964e92e36cd787d899e959ae7c3bd83607c4a45ebabd30a4de196a2f7ba9d77aabd00ca89fc711c072fbef55dc346c51754955bf4616427b2917de04247985af831059d14a52654ed50a0b01e99daa097f369609cfd31957dc8d2737e603f954e7e0ea70a65f7fee60c9b8b8f2482cb00469a155a9e0601111de46004ae4cde1fc14ac9f3be5b37a8308f47a7a7524e9638af44100573cb35a3d55a436c6080ccdcac5bcce07fae741f6049b4f06c011abba11f4647917ecbd7da7071a7936606bfbfd69a4c264060e7e1a2e093b704c76e0b09dc6200e761fd711489fda28679a375fad63df1e732f66ac47fc2650a6db3ee818b201e93b8b7325d375c0dfd850db44215c5fdc4189d199a2758e067c8d2ce29737acc4b81e9d22604b1a3f6d6315b3003b11dcbad4b761fbc4b43a9e3446c6083a4a32293458c843aa57fd829b21afaa600fc438d292a00c1bc651f95291586414bc4e67fa2740da4def4ed2caaac74f39d8cecf00e957f848c1adea9bf78556e55d17c94fd550a99d328bacf6704d7db090a9759aabac010322f539019b28cea0238992638a358582386",
|
||||
"map_hash_hex": "94abb0d5",
|
||||
"raw_hex": "0c0000112233445566778899aabbccddeeff01404142434445464748494a4b4c4d4e4f2e9d382a37451544df61eee81f38350c4883c306763ae9491e392fc51bb2579c295964e92e36cd787d899e959ae7c3bd83607c4a45ebabd30a4de196a2f7ba9d77aabd00ca89fc711c072fbef55dc346c51754955bf4616427b2917de04247985af831059d14a52654ed50a0b01e99daa097f369609cfd31957dc8d2737e603f954e7e0ea70a65f7fee60c9b8b8f2482cb00469a155a9e0601111de46004ae4cde1fc14ac9f3be5b37a8308f47a7a7524e9638af44100573cb35a3d55a436c6080ccdcac5bcce07fae741f6049b4f06c011abba11f4647917ecbd7da7071a7936606bfbfd69a4c264060e7e1a2e093b704c76e0b09dc6200e761fd711489fda28679a375fad63df1e732f66ac47fc2650a6db3ee818b201e93b8b7325d375c0dfd850db44215c5fdc4189d199a2758e067c8d2ce29737acc4b81e9d22604b1a3f6d6315b3003b11dcbad4b761fbc4b43a9e3446c6083a4a32293458c843aa57fd829b21afaa600fc438d292a00c1bc651f95291586414bc4e67fa2740da4def4ed2caaac74f39d8cecf00e957f848c1adea9bf78556e55d17c94fd550a99d328bacf6704d7db090a9759aabac010322f539019b28cea0238992638a358582386"
|
||||
},
|
||||
{
|
||||
"body_hex": "065fa79a46947f5028d9e884d9fc98304a43f318b13b2a9ac14ddbf7d68d898be03d7881bf347e5cdc209ab155d23cbe922430c71ae14902642310c50d7d9617f462b4674517abf1430b6bc7763299c6556e52187d6950f0ff71ca6a1dcffbce2415f074492dc511c6c489b1ea8ed5d34c46d8ce32cffb71567411b7f8da3a84b53b16bc4384bfbe3238e5487b18f4faf3b09ee83e53f537c699be3f9167fc27a7df92bea5ec4a20646100016cb6728a7a94d51184acbcfffab0b1ccf7f6ee61fc34df1dbfc98cc801e141111a88c5713748d929cbbd14d69613bacf12d60df4f4da0799f7b9b6a1200ad84732789ff629f63aef6e5ffd79741e6d9160a1350882de9a1c76aae13a2c70ed972200af3f4078837882a06501b514081cb465c6b69661c9a82459a59086d6bbda247d4eac9656d413fe88558ae6a4e6d94ed1d2bb87bfc75b68256291d7208bb5e890d30f4c53226e222288de73106b1d4ba3fb01addfb7b0b1b9fed386cfa5afdb8789f22e7cfe7f0b39c8c797d0b70fd33a89293df048820c86b80cfd6f8ed829483d9fadbb05b8b6b4c8680931d74ffc263a23ab7845fe92ab850638fae9c267a623e78afc26055864e0504a951451933f60a357e17d8471653dca6cc97b3ef92c1715",
|
||||
"map_hash_hex": "0fcf34cd",
|
||||
"raw_hex": "0c0000112233445566778899aabbccddeeff01065fa79a46947f5028d9e884d9fc98304a43f318b13b2a9ac14ddbf7d68d898be03d7881bf347e5cdc209ab155d23cbe922430c71ae14902642310c50d7d9617f462b4674517abf1430b6bc7763299c6556e52187d6950f0ff71ca6a1dcffbce2415f074492dc511c6c489b1ea8ed5d34c46d8ce32cffb71567411b7f8da3a84b53b16bc4384bfbe3238e5487b18f4faf3b09ee83e53f537c699be3f9167fc27a7df92bea5ec4a20646100016cb6728a7a94d51184acbcfffab0b1ccf7f6ee61fc34df1dbfc98cc801e141111a88c5713748d929cbbd14d69613bacf12d60df4f4da0799f7b9b6a1200ad84732789ff629f63aef6e5ffd79741e6d9160a1350882de9a1c76aae13a2c70ed972200af3f4078837882a06501b514081cb465c6b69661c9a82459a59086d6bbda247d4eac9656d413fe88558ae6a4e6d94ed1d2bb87bfc75b68256291d7208bb5e890d30f4c53226e222288de73106b1d4ba3fb01addfb7b0b1b9fed386cfa5afdb8789f22e7cfe7f0b39c8c797d0b70fd33a89293df048820c86b80cfd6f8ed829483d9fadbb05b8b6b4c8680931d74ffc263a23ab7845fe92ab850638fae9c267a623e78afc26055864e0504a951451933f60a357e17d8471653dca6cc97b3ef92c1715"
|
||||
},
|
||||
{
|
||||
"body_hex": "c959c4df40bdacf257b106d1fc6e635c82208f4235621458afd2ad23ce1e799f596df9be514f25cfff1f65bebf46ff52c2f43cf7c2edae2873e70a938cb417aea136c2a3b9521863eb0ae97e7093b99ad9059b00ba08463ad3b68bf2a91b053c6119117aee992020cc288f0f1ab9ae3ee3618f83d03378a62282bc9fa93a47dfa5426114551bbf610b5549ba57d548001e8b67bf09a40878799321832ef266b588a4226854366f22822fd36a79443c17f52bfc88341405eef4b6f821ef255270e14a21ae11570f205b550268c69e9ea071f21cd599774ef2e0fe20ec7ab2146d36237b922b7e59b8b78d621872ff682d703829e627b04f23315c93d5304e991e584d29facf6704c4d444418b3af4c6514cc9aa63e396e976713077755f721ebb022013bb75b071308a71ac578dd5c124a0af521c7404f166c26a63116b377928cc5d971d230f1bdf445202869d5af5304fd5e743165c1d65f86cf46247898b43d6cbf65f2be6217421c6a6ba95df7fd0fda278855fcea67916d710511da70eaa8cfb0d630b21e725f5cb304d13d5ca39440edefecc3f394482c08399a6d62cf8",
|
||||
"map_hash_hex": "d9769774",
|
||||
"raw_hex": "0c0000112233445566778899aabbccddeeff01c959c4df40bdacf257b106d1fc6e635c82208f4235621458afd2ad23ce1e799f596df9be514f25cfff1f65bebf46ff52c2f43cf7c2edae2873e70a938cb417aea136c2a3b9521863eb0ae97e7093b99ad9059b00ba08463ad3b68bf2a91b053c6119117aee992020cc288f0f1ab9ae3ee3618f83d03378a62282bc9fa93a47dfa5426114551bbf610b5549ba57d548001e8b67bf09a40878799321832ef266b588a4226854366f22822fd36a79443c17f52bfc88341405eef4b6f821ef255270e14a21ae11570f205b550268c69e9ea071f21cd599774ef2e0fe20ec7ab2146d36237b922b7e59b8b78d621872ff682d703829e627b04f23315c93d5304e991e584d29facf6704c4d444418b3af4c6514cc9aa63e396e976713077755f721ebb022013bb75b071308a71ac578dd5c124a0af521c7404f166c26a63116b377928cc5d971d230f1bdf445202869d5af5304fd5e743165c1d65f86cf46247898b43d6cbf65f2be6217421c6a6ba95df7fd0fda278855fcea67916d710511da70eaa8cfb0d630b21e725f5cb304d13d5ca39440edefecc3f394482c08399a6d62cf8"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rns_version_at_generation": "1.2.4",
|
||||
"generator_script": "tools/regen_resources.py",
|
||||
"verifies_spec_sections": [
|
||||
"10.2",
|
||||
"10.3",
|
||||
"10.4",
|
||||
"10.8",
|
||||
"10.12"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
todo.md
17
todo.md
|
|
@ -21,12 +21,13 @@ Outstanding work for the spec repo.
|
|||
|
||||
## Test infrastructure
|
||||
|
||||
- [ ] **Deterministic Resource vectors and negative cases.** The Tier 1 audit
|
||||
- [x] **Deterministic Resource vectors and negative cases.** The Tier 1 audit
|
||||
is recorded in `audits/resource-tier1-rns-1.2.4.md`;
|
||||
`tools/verify_resource.py` now runtime-locks the first focused claim
|
||||
set, and confirmed corrections are promoted into SPEC.md §10. Add
|
||||
`test-vectors/resources.json` plus malformed ADV, wrong-`r`, corrupt
|
||||
part, invalid-HMU-boundary, and oversized-decompression rejection cases.
|
||||
`tools/verify_resource.py` runtime-locks sender and receiver behavior,
|
||||
proof validation, multi-segment sizing, and malformed ADV, wrong-`r`,
|
||||
corrupt-part, invalid-HMU-boundary, and oversized-decompression
|
||||
rejection cases. `tools/regen_resources.py` regenerates the deterministic
|
||||
`test-vectors/resources.json`.
|
||||
- [x] **Bootstrap `test-vectors/identities.json`** — Alice + Bob
|
||||
identities populated against RNS 1.2.0. Regenerator at
|
||||
`tools/regen_identities.py`.
|
||||
|
|
@ -50,6 +51,12 @@ Outstanding work for the spec repo.
|
|||
`verify_stamps.py`, `verify_ratchet_dedup.py`). Status table
|
||||
lives in `tools/README.md`.
|
||||
|
||||
- [x] **Deterministic link-delivered LXMF vectors.** Added
|
||||
`test-vectors/link-lxmf.json`, `tools/regen_link_lxmf.py`, and
|
||||
`tools/verify_link_lxmf.py`. Covers the exact computed-content
|
||||
PACKET/Resource boundary, Link decrypt/parse, wrong-key rejection, and
|
||||
DIRECT receive dispatch.
|
||||
|
||||
## Open `⚠️ UNVERIFIED` items in SPEC.md
|
||||
|
||||
These need either a runtime test or a stronger upstream source citation
|
||||
|
|
|
|||
|
|
@ -51,15 +51,18 @@ Populated against RNS 1.2.4 / LXMF 0.9.7:
|
|||
| `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_link_lxmf.py` | §5.2, §5.5, §5.6, §6.4.3, §10.1 — DIRECT PACKET/Resource boundary, Link decrypt/parse, wrong-key rejection, receive dispatch | ✅ |
|
||||
| `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 | ✅ |
|
||||
| `verify_stamps.py` | §5.7 — workblock determinism, PoW stamp search/validate, ticket shortcut | ✅ |
|
||||
| `verify_ratchet_dedup.py` | §7.3 / §4.5 step 6.3 — confirms replay defence is keyed on `random_blob`, NOT on `(dest_hash, ratchet_pub)` | ✅ |
|
||||
| `verify_resource.py` | §10.2, §10.4, §10.6, §10.7, §10.9, §10.11 — whole-stream encryption/slicing, hashes, ADV fields, exhausted REQ behavior, rejection/cancel distinction, multi-segment total size | ✅ |
|
||||
| `verify_resource.py` | §10.2, §10.4, §10.6-§10.9, §10.11, §10.12 — vectors, whole-stream encryption/slicing, receiver assembly/proof, control behavior, multi-segment size, and negative cases | ✅ |
|
||||
| `regen_identities.py` | regenerates `test-vectors/identities.json` | ✅ |
|
||||
| `regen_announces.py` | regenerates `test-vectors/announces.json` (deterministic announce wire bytes, with and without ratchet) | ✅ |
|
||||
| `regen_lxmf.py` | regenerates `test-vectors/lxmf.json` (deterministic opportunistic-LXMF plaintext + Token ciphertext) | ✅ |
|
||||
| `regen_links.py` | regenerates `test-vectors/links.json` (deterministic LINKREQUEST + LRPROOF + derived session key) | ✅ |
|
||||
| `regen_link_lxmf.py` | regenerates `test-vectors/link-lxmf.json` (deterministic DIRECT PACKET and Resource boundary vectors) | ✅ |
|
||||
| `regen_resources.py` | regenerates `test-vectors/resources.json` (deterministic Resource ciphertext, parts, ADV, and PRF body) | ✅ |
|
||||
|
||||
See [`../agent.md`](../agent.md) §3 and [`../todo.md`](../todo.md) for the evidence model and remaining priority order.
|
||||
|
|
|
|||
268
tools/regen_link_lxmf.py
Normal file
268
tools/regen_link_lxmf.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""
|
||||
Regenerator for test-vectors/link-lxmf.json.
|
||||
|
||||
Builds deterministic DIRECT LXMF vectors at the PACKET/RESOURCE boundary:
|
||||
|
||||
- computed LXMF content_size 319 -> one encrypted Link DATA packet
|
||||
- computed LXMF content_size 320 -> one Resource over the same Link
|
||||
|
||||
The vectors reuse the deterministic Link session key from links.json and the
|
||||
Alice/Bob identities from identities.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import LXMF
|
||||
import RNS
|
||||
from LXMF.LXMessage import LXMessage
|
||||
from RNS.Cryptography.Token import Token
|
||||
from RNS.Resource import Resource, ResourceAdvertisement
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT_PATH = os.path.join(REPO_ROOT, "test-vectors", "link-lxmf.json")
|
||||
IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json")
|
||||
LINKS_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json")
|
||||
|
||||
FIXED_TIMESTAMP = 1700000000.0
|
||||
PACKET_IV = bytes.fromhex("5152535455565758595a5b5c5d5e5f60")
|
||||
RESOURCE_IV = bytes.fromhex("6162636465666768696a6b6c6d6e6f70")
|
||||
RESOURCE_PREFIX = bytes.fromhex("71727374")
|
||||
RESOURCE_RANDOM_HASH = bytes.fromhex("81828384")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def init_minimal_rns():
|
||||
config_dir = tempfile.mkdtemp(prefix="rns-regen-link-lxmf-")
|
||||
config_path = os.path.join(config_dir, "config")
|
||||
with open(config_path, "w", encoding="utf-8") as config:
|
||||
config.write("[reticulum]\nenable_transport = No\nshare_instance = No\n")
|
||||
return RNS.Reticulum(configdir=config_dir, loglevel=0)
|
||||
|
||||
|
||||
def load_inputs():
|
||||
with open(IDS_PATH, "r", encoding="utf-8") as identities_file:
|
||||
identities = json.load(identities_file)["vectors"]
|
||||
with open(LINKS_PATH, "r", encoding="utf-8") as links_file:
|
||||
link_vector = json.load(links_file)["vectors"][0]
|
||||
|
||||
alice = next(vector for vector in identities if vector["label"] == "alice")
|
||||
bob = next(vector for vector in identities if vector["label"] == "bob")
|
||||
alice_identity = RNS.Identity.from_bytes(bytes.fromhex(alice["inputs"]["private_key_hex"]))
|
||||
bob_identity = RNS.Identity.from_bytes(bytes.fromhex(bob["inputs"]["private_key_hex"]))
|
||||
derived_key = bytes.fromhex(link_vector["expected"]["derived_key_hex"])
|
||||
link_id = bytes.fromhex(link_vector["expected"]["link_id_hex"])
|
||||
return alice_identity, bob_identity, derived_key, link_id
|
||||
|
||||
|
||||
class FakeLink:
|
||||
"""Active Link surface needed by LXMessage, Packet, and Resource."""
|
||||
|
||||
def __init__(self, derived_key: bytes, link_id: bytes):
|
||||
self.type = RNS.Destination.LINK
|
||||
self.status = RNS.Link.ACTIVE
|
||||
self.hash = link_id
|
||||
self.link_id = link_id
|
||||
self.mtu = RNS.Reticulum.MTU
|
||||
self.mdu = RNS.Link.MDU
|
||||
self.rtt = 0.1
|
||||
self.traffic_timeout_factor = 1
|
||||
self.last_outbound = 0
|
||||
self.tx = 0
|
||||
self.txbytes = 0
|
||||
self._token = Token(derived_key)
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
return self._token.encrypt(data)
|
||||
|
||||
def decrypt(self, data: bytes) -> bytes:
|
||||
return self._token.decrypt(data)
|
||||
|
||||
|
||||
def make_message(destination, source, content_size: int) -> LXMessage:
|
||||
message = LXMessage(
|
||||
destination=destination,
|
||||
source=source,
|
||||
title=b"",
|
||||
content=b"x" * content_size,
|
||||
fields={},
|
||||
desired_method=LXMessage.DIRECT,
|
||||
)
|
||||
message.timestamp = FIXED_TIMESTAMP
|
||||
message.pack()
|
||||
computed_content_size = (
|
||||
len(message.packed[2 * LXMessage.DESTINATION_LENGTH + LXMessage.SIGNATURE_LENGTH :])
|
||||
- LXMessage.TIMESTAMP_SIZE
|
||||
- LXMessage.STRUCT_OVERHEAD
|
||||
)
|
||||
if computed_content_size != content_size:
|
||||
fail(f"fixture computed content_size {computed_content_size}, want {content_size}")
|
||||
return message
|
||||
|
||||
|
||||
def patch_token_iv(iv: bytes):
|
||||
token_mod = sys.modules["RNS.Cryptography.Token"]
|
||||
real_urandom = token_mod.os.urandom
|
||||
|
||||
def fixed_urandom(length: int) -> bytes:
|
||||
if length == 16:
|
||||
return iv
|
||||
return real_urandom(length)
|
||||
|
||||
token_mod.os.urandom = fixed_urandom
|
||||
return token_mod, real_urandom
|
||||
|
||||
|
||||
def build_packet_vector(message: LXMessage, link: FakeLink) -> dict:
|
||||
message.set_delivery_destination(link)
|
||||
token_mod, real_urandom = patch_token_iv(PACKET_IV)
|
||||
try:
|
||||
packet = message._LXMessage__as_packet()
|
||||
packet.pack()
|
||||
finally:
|
||||
token_mod.os.urandom = real_urandom
|
||||
|
||||
return {
|
||||
"label": "alice_to_bob_direct_packet_boundary",
|
||||
"inputs": {
|
||||
"src_identity_label": "alice",
|
||||
"dst_identity_label": "bob",
|
||||
"content_size": 319,
|
||||
"lxmf_timestamp": FIXED_TIMESTAMP,
|
||||
"link_vector_label": "alice_to_bob_aes256cbc",
|
||||
"token_iv_hex": PACKET_IV.hex(),
|
||||
},
|
||||
"expected": {
|
||||
"method": message.method,
|
||||
"representation": message.representation,
|
||||
"lxmf_packed_hex": message.packed.hex(),
|
||||
"link_packet_ciphertext_hex": packet.ciphertext.hex(),
|
||||
"link_packet_raw_hex": packet.raw.hex(),
|
||||
"link_id_hex": link.link_id.hex(),
|
||||
},
|
||||
"rns_version_at_generation": RNS.__version__,
|
||||
"lxmf_version_at_generation": LXMF.__version__,
|
||||
"generator_script": "tools/regen_link_lxmf.py",
|
||||
"verifies_spec_sections": ["5.2", "5.5", "5.6", "6.4.3", "10.1"],
|
||||
}
|
||||
|
||||
|
||||
def build_resource_vector(message: LXMessage, link: FakeLink) -> dict:
|
||||
message.set_delivery_destination(link)
|
||||
message.auto_compress = False
|
||||
token_mod, real_urandom = patch_token_iv(RESOURCE_IV)
|
||||
real_get_random_hash = RNS.Identity.get_random_hash
|
||||
real_advertise = Resource.advertise
|
||||
hashes = [
|
||||
RESOURCE_PREFIX + bytes(28),
|
||||
RESOURCE_RANDOM_HASH + bytes(28),
|
||||
]
|
||||
|
||||
def fixed_random_hash() -> bytes:
|
||||
if not hashes:
|
||||
raise RuntimeError("unexpected extra Resource random-hash request")
|
||||
return hashes.pop(0)
|
||||
|
||||
RNS.Identity.get_random_hash = staticmethod(fixed_random_hash)
|
||||
Resource.advertise = lambda self: None
|
||||
try:
|
||||
resource = message._LXMessage__as_resource()
|
||||
finally:
|
||||
token_mod.os.urandom = real_urandom
|
||||
RNS.Identity.get_random_hash = staticmethod(real_get_random_hash)
|
||||
Resource.advertise = real_advertise
|
||||
|
||||
return {
|
||||
"label": "alice_to_bob_direct_resource_boundary",
|
||||
"inputs": {
|
||||
"src_identity_label": "alice",
|
||||
"dst_identity_label": "bob",
|
||||
"content_size": 320,
|
||||
"lxmf_timestamp": FIXED_TIMESTAMP,
|
||||
"link_vector_label": "alice_to_bob_aes256cbc",
|
||||
"token_iv_hex": RESOURCE_IV.hex(),
|
||||
"throwaway_prefix_hex": RESOURCE_PREFIX.hex(),
|
||||
"resource_random_hash_hex": RESOURCE_RANDOM_HASH.hex(),
|
||||
"auto_compress": False,
|
||||
},
|
||||
"expected": {
|
||||
"method": message.method,
|
||||
"representation": message.representation,
|
||||
"lxmf_packed_hex": message.packed.hex(),
|
||||
"resource_hash_hex": resource.hash.hex(),
|
||||
"resource_expected_proof_hex": resource.expected_proof.hex(),
|
||||
"resource_advertisement_plaintext_hex": ResourceAdvertisement(resource).pack().hex(),
|
||||
"resource_parts": [
|
||||
{
|
||||
"body_hex": part.data.hex(),
|
||||
"map_hash_hex": part.map_hash.hex(),
|
||||
"raw_hex": part.raw.hex(),
|
||||
}
|
||||
for part in resource.parts
|
||||
],
|
||||
"link_id_hex": link.link_id.hex(),
|
||||
},
|
||||
"rns_version_at_generation": RNS.__version__,
|
||||
"lxmf_version_at_generation": LXMF.__version__,
|
||||
"generator_script": "tools/regen_link_lxmf.py",
|
||||
"verifies_spec_sections": ["5.2", "5.5", "5.6", "10.1", "10.2", "10.4"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"regen_link_lxmf.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
|
||||
init_minimal_rns()
|
||||
try:
|
||||
alice_identity, bob_identity, derived_key, link_id = load_inputs()
|
||||
alice = RNS.Destination(
|
||||
alice_identity, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery"
|
||||
)
|
||||
bob = RNS.Destination(
|
||||
bob_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery"
|
||||
)
|
||||
RNS.Identity.remember(bytes(32), alice.hash, alice_identity.get_public_key(), None)
|
||||
RNS.Identity.remember(bytes(32), bob.hash, bob_identity.get_public_key(), None)
|
||||
link = FakeLink(derived_key, link_id)
|
||||
|
||||
packet_message = make_message(bob, alice, 319)
|
||||
resource_message = make_message(bob, alice, 320)
|
||||
if packet_message.representation != LXMessage.PACKET:
|
||||
fail("content_size 319 did not select PACKET")
|
||||
if resource_message.representation != LXMessage.RESOURCE:
|
||||
fail("content_size 320 did not select RESOURCE")
|
||||
|
||||
payload = {
|
||||
"_about": (
|
||||
"DIRECT LXMF vectors at the exact upstream PACKET/RESOURCE boundary. "
|
||||
"Both carry the complete canonical LXMF body over the deterministic "
|
||||
"Link session from links.json. `content_size` is upstream's computed "
|
||||
"LXMF size, not necessarily the raw content field length."
|
||||
),
|
||||
"vectors": [
|
||||
build_packet_vector(packet_message, link),
|
||||
build_resource_vector(resource_message, link),
|
||||
],
|
||||
}
|
||||
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(payload, output, indent=2, sort_keys=False)
|
||||
output.write("\n")
|
||||
print(f"Wrote {OUT_PATH} with 2 vectors")
|
||||
print("ALL PASS")
|
||||
finally:
|
||||
try:
|
||||
RNS.Reticulum.exit_handler()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
146
tools/regen_resources.py
Normal file
146
tools/regen_resources.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""
|
||||
Regenerator for test-vectors/resources.json.
|
||||
|
||||
Builds a deterministic multi-part Resource over a fixed fake Link. The link
|
||||
Token key, Token IV, throwaway prefix, and Resource random-hash salt are pinned
|
||||
so every ciphertext byte, part packet, map hash, advertisement, and proof is
|
||||
reproducible against the pinned RNS version.
|
||||
|
||||
Run from repo root:
|
||||
|
||||
python tools/regen_resources.py
|
||||
|
||||
Updates `test-vectors/resources.json` in place. Exit 0 on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import RNS
|
||||
from RNS.Cryptography.Token import Token
|
||||
from RNS.Resource import Resource, ResourceAdvertisement
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT_PATH = os.path.join(REPO_ROOT, "test-vectors", "resources.json")
|
||||
|
||||
FIXED_TOKEN_KEY = bytes(range(64))
|
||||
FIXED_TOKEN_IV = bytes.fromhex("404142434445464748494a4b4c4d4e4f")
|
||||
FIXED_PREFIX = bytes.fromhex("10111213")
|
||||
FIXED_RANDOM_HASH = bytes.fromhex("20212223")
|
||||
PLAINTEXT = bytes(range(256)) * 5
|
||||
|
||||
|
||||
class FakeLink:
|
||||
"""Minimum deterministic Link surface needed by Resource construction."""
|
||||
|
||||
def __init__(self):
|
||||
self.type = RNS.Destination.LINK
|
||||
self.status = RNS.Link.ACTIVE
|
||||
self.mtu = RNS.Reticulum.MTU
|
||||
self.mdu = RNS.Link.MDU
|
||||
self.hash = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
self.link_id = self.hash
|
||||
self.rtt = 0.1
|
||||
self.traffic_timeout_factor = 1
|
||||
self.last_outbound = 0
|
||||
self.tx = 0
|
||||
self.txbytes = 0
|
||||
self._token = Token(FIXED_TOKEN_KEY)
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
return self._token.encrypt(data)
|
||||
|
||||
def decrypt(self, data: bytes) -> bytes:
|
||||
return self._token.decrypt(data)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"regen_resources.py against RNS {RNS.__version__}")
|
||||
token_mod = sys.modules["RNS.Cryptography.Token"]
|
||||
real_urandom = token_mod.os.urandom
|
||||
real_get_random_hash = RNS.Identity.get_random_hash
|
||||
random_hashes = [
|
||||
FIXED_PREFIX + bytes(28),
|
||||
FIXED_RANDOM_HASH + bytes(28),
|
||||
]
|
||||
|
||||
def fixed_urandom(length: int) -> bytes:
|
||||
if length == 16:
|
||||
return FIXED_TOKEN_IV
|
||||
return real_urandom(length)
|
||||
|
||||
def fixed_random_hash() -> bytes:
|
||||
if not random_hashes:
|
||||
raise RuntimeError("unexpected additional Resource random-hash request")
|
||||
return random_hashes.pop(0)
|
||||
|
||||
token_mod.os.urandom = fixed_urandom
|
||||
RNS.Identity.get_random_hash = staticmethod(fixed_random_hash)
|
||||
try:
|
||||
link = FakeLink()
|
||||
resource = Resource(PLAINTEXT, link, advertise=False, auto_compress=False)
|
||||
finally:
|
||||
token_mod.os.urandom = real_urandom
|
||||
RNS.Identity.get_random_hash = staticmethod(real_get_random_hash)
|
||||
|
||||
encrypted_stream = b"".join(part.data for part in resource.parts)
|
||||
advertisement = ResourceAdvertisement(resource).pack()
|
||||
proof = resource.hash + resource.expected_proof
|
||||
|
||||
vector = {
|
||||
"label": "fixed_link_uncompressed_multi_part",
|
||||
"inputs": {
|
||||
"plaintext_hex": PLAINTEXT.hex(),
|
||||
"token_key_hex": FIXED_TOKEN_KEY.hex(),
|
||||
"token_iv_hex": FIXED_TOKEN_IV.hex(),
|
||||
"throwaway_prefix_hex": FIXED_PREFIX.hex(),
|
||||
"random_hash_hex": FIXED_RANDOM_HASH.hex(),
|
||||
"link_hash_hex": link.hash.hex(),
|
||||
"mtu": link.mtu,
|
||||
"auto_compress": False,
|
||||
},
|
||||
"expected": {
|
||||
"encrypted_stream_hex": encrypted_stream.hex(),
|
||||
"resource_hash_hex": resource.hash.hex(),
|
||||
"expected_proof_hex": resource.expected_proof.hex(),
|
||||
"resource_prf_body_hex": proof.hex(),
|
||||
"hashmap_hex": resource.hashmap.hex(),
|
||||
"advertisement_plaintext_hex": advertisement.hex(),
|
||||
"parts": [
|
||||
{
|
||||
"body_hex": part.data.hex(),
|
||||
"map_hash_hex": part.map_hash.hex(),
|
||||
"raw_hex": part.raw.hex(),
|
||||
}
|
||||
for part in resource.parts
|
||||
],
|
||||
},
|
||||
"rns_version_at_generation": RNS.__version__,
|
||||
"generator_script": "tools/regen_resources.py",
|
||||
"verifies_spec_sections": ["10.2", "10.3", "10.4", "10.8", "10.12"],
|
||||
}
|
||||
payload = {
|
||||
"_about": (
|
||||
"Deterministic Resource transfer vector over a fixed Link Token key. "
|
||||
"The Resource is uncompressed and spans multiple RESOURCE packets. "
|
||||
"A clean-room implementation should encrypt `throwaway_prefix || "
|
||||
"plaintext` once with the recorded Token key and IV, slice the "
|
||||
"ciphertext at the Resource SDU, reproduce every part and map hash, "
|
||||
"pack the advertisement, then assemble and emit the recorded PRF body."
|
||||
),
|
||||
"vectors": [vector],
|
||||
}
|
||||
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(payload, output, indent=2, sort_keys=False)
|
||||
output.write("\n")
|
||||
|
||||
print(f"Wrote {OUT_PATH} with 1 vector")
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
205
tools/verify_link_lxmf.py
Normal file
205
tools/verify_link_lxmf.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""
|
||||
Verifier for DIRECT LXMF delivery over an established Reticulum Link.
|
||||
|
||||
Validates deterministic PACKET and Resource vectors, the exact upstream
|
||||
content_size boundary (319/320), full-body DIRECT parsing, wrong-link-key
|
||||
rejection, and LXMRouter's DIRECT receive dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import LXMF
|
||||
import RNS
|
||||
from LXMF.LXMessage import LXMessage
|
||||
from LXMF.LXMRouter import LXMRouter
|
||||
from RNS.Cryptography.Token import Token
|
||||
from RNS.Resource import Resource, ResourceAdvertisement
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VECTORS_PATH = os.path.join(REPO_ROOT, "test-vectors", "link-lxmf.json")
|
||||
IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json")
|
||||
LINKS_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"FAIL: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def init_minimal_rns():
|
||||
config_dir = tempfile.mkdtemp(prefix="rns-verify-link-lxmf-")
|
||||
config_path = os.path.join(config_dir, "config")
|
||||
with open(config_path, "w", encoding="utf-8") as config:
|
||||
config.write("[reticulum]\nenable_transport = No\nshare_instance = No\n")
|
||||
return RNS.Reticulum(configdir=config_dir, loglevel=0)
|
||||
|
||||
|
||||
def load_json(path: str):
|
||||
with open(path, "r", encoding="utf-8") as input_file:
|
||||
return json.load(input_file)
|
||||
|
||||
|
||||
def content_size(packed: bytes) -> int:
|
||||
payload = packed[2 * LXMessage.DESTINATION_LENGTH + LXMessage.SIGNATURE_LENGTH :]
|
||||
return len(payload) - LXMessage.TIMESTAMP_SIZE - LXMessage.STRUCT_OVERHEAD
|
||||
|
||||
|
||||
def parse_and_check(packed: bytes, expected_size: int) -> LXMessage:
|
||||
parsed = LXMessage.unpack_from_bytes(packed)
|
||||
if not parsed.signature_validated:
|
||||
fail(f"DIRECT LXMF signature invalid at content_size {expected_size}")
|
||||
if len(parsed.content) != expected_size or parsed.content != b"x" * expected_size:
|
||||
fail(f"DIRECT LXMF content mismatch at content_size {expected_size}")
|
||||
if parsed.method != LXMessage.UNKNOWN:
|
||||
fail("unpack_from_bytes unexpectedly assigned a delivery method")
|
||||
return parsed
|
||||
|
||||
|
||||
def verify_vectors(derived_key: bytes, link_id: bytes) -> tuple[bytes, bytes]:
|
||||
vectors = load_json(VECTORS_PATH)["vectors"]
|
||||
packet_vector = next(vector for vector in vectors if vector["expected"]["representation"] == LXMessage.PACKET)
|
||||
resource_vector = next(vector for vector in vectors if vector["expected"]["representation"] == LXMessage.RESOURCE)
|
||||
token = Token(derived_key)
|
||||
|
||||
packet_packed = bytes.fromhex(packet_vector["expected"]["lxmf_packed_hex"])
|
||||
packet_raw = bytes.fromhex(packet_vector["expected"]["link_packet_raw_hex"])
|
||||
packet_ciphertext = bytes.fromhex(packet_vector["expected"]["link_packet_ciphertext_hex"])
|
||||
if content_size(packet_packed) != 319 or len(packet_packed) != RNS.Link.MDU:
|
||||
fail("319 boundary vector does not fill the Link MDU exactly")
|
||||
if len(packet_raw) != 19 + len(packet_ciphertext) or len(packet_raw) > RNS.Reticulum.MTU:
|
||||
fail("319 boundary vector has an invalid Link packet wire length")
|
||||
if packet_raw[:2] != bytes([RNS.Destination.LINK << 2, 0]):
|
||||
fail("DIRECT PACKET vector flags/hops are not HEADER_1 DATA LINK")
|
||||
if packet_raw[2:18] != link_id or packet_raw[18] != RNS.Packet.NONE:
|
||||
fail("DIRECT PACKET vector link_id/context mismatch")
|
||||
if packet_raw[19:] != packet_ciphertext:
|
||||
fail("DIRECT PACKET raw body differs from recorded ciphertext")
|
||||
if token.decrypt(packet_ciphertext) != packet_packed:
|
||||
fail("DIRECT PACKET did not decrypt to the complete LXMF body")
|
||||
parse_and_check(packet_packed, 319)
|
||||
|
||||
resource_packed = bytes.fromhex(resource_vector["expected"]["lxmf_packed_hex"])
|
||||
parts = [bytes.fromhex(part["body_hex"]) for part in resource_vector["expected"]["resource_parts"]]
|
||||
stream = b"".join(parts)
|
||||
decrypted = token.decrypt(stream)
|
||||
prefix = bytes.fromhex(resource_vector["inputs"]["throwaway_prefix_hex"])
|
||||
if decrypted[: Resource.RANDOM_HASH_SIZE] != prefix:
|
||||
fail("DIRECT Resource throwaway prefix mismatch")
|
||||
if decrypted[Resource.RANDOM_HASH_SIZE :] != resource_packed:
|
||||
fail("DIRECT Resource did not decrypt to the complete LXMF body")
|
||||
if content_size(resource_packed) != 320:
|
||||
fail("Resource boundary vector computed content_size is not 320")
|
||||
parse_and_check(resource_packed, 320)
|
||||
|
||||
adv = ResourceAdvertisement.unpack(
|
||||
bytes.fromhex(resource_vector["expected"]["resource_advertisement_plaintext_hex"])
|
||||
)
|
||||
if adv.d != len(resource_packed) or adv.h.hex() != resource_vector["expected"]["resource_hash_hex"]:
|
||||
fail("DIRECT Resource advertisement size/hash mismatch")
|
||||
|
||||
try:
|
||||
Token(bytes(reversed(derived_key))).decrypt(packet_ciphertext)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
fail("DIRECT packet decrypted under the wrong Link key")
|
||||
|
||||
print("PASS S5.2/S10.1 DIRECT PACKET/RESOURCE boundary vectors: 319 -> PACKET, 320 -> RESOURCE")
|
||||
print("PASS S3/S5.5/S5.6 DIRECT Link decrypt and signed full-body parse; wrong Link key rejected")
|
||||
return packet_packed, resource_packed
|
||||
|
||||
|
||||
def verify_upstream_boundary(alice_identity, bob_identity) -> None:
|
||||
alice = RNS.Destination(
|
||||
alice_identity, RNS.Destination.IN, RNS.Destination.SINGLE, "verify_link_lxmf", "alice"
|
||||
)
|
||||
bob = RNS.Destination(
|
||||
bob_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "verify_link_lxmf", "bob"
|
||||
)
|
||||
for size, expected in [(319, LXMessage.PACKET), (320, LXMessage.RESOURCE)]:
|
||||
message = LXMessage(bob, alice, b"x" * size, b"", {}, LXMessage.DIRECT)
|
||||
message.timestamp = 1700000000.0
|
||||
message.pack()
|
||||
if content_size(message.packed) != size or message.representation != expected:
|
||||
fail(f"upstream boundary mismatch at {size}")
|
||||
print("PASS S10.1 upstream selection uses computed LXMF content_size at the 319/320 boundary")
|
||||
|
||||
|
||||
def verify_router_direct_dispatch(packet_packed: bytes) -> None:
|
||||
router = object.__new__(LXMRouter)
|
||||
captured = {}
|
||||
router.lxmf_delivery = lambda data, destination_type, **kwargs: captured.update(
|
||||
data=data, destination_type=destination_type, method=kwargs.get("method")
|
||||
)
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon=None):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
class FakePacket:
|
||||
destination_type = RNS.Destination.LINK
|
||||
rssi = -80
|
||||
snr = 7
|
||||
q = 90
|
||||
ratchet_id = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
proved = False
|
||||
|
||||
def prove(self):
|
||||
self.proved = True
|
||||
|
||||
packet = FakePacket()
|
||||
router_mod = sys.modules["LXMF.LXMRouter"]
|
||||
real_thread = router_mod.threading.Thread
|
||||
router_mod.threading.Thread = ImmediateThread
|
||||
try:
|
||||
router.delivery_packet(packet_packed, packet)
|
||||
finally:
|
||||
router_mod.threading.Thread = real_thread
|
||||
|
||||
if not packet.proved:
|
||||
fail("LXMRouter.delivery_packet did not prove DIRECT Link DATA")
|
||||
if captured.get("data") != packet_packed:
|
||||
fail("LXMRouter.delivery_packet altered or prepended DIRECT LXMF data")
|
||||
if captured.get("destination_type") != RNS.Destination.LINK:
|
||||
fail("LXMRouter.delivery_packet lost LINK destination type")
|
||||
if captured.get("method") != LXMessage.DIRECT:
|
||||
fail("LXMRouter.delivery_packet did not classify LINK data as DIRECT")
|
||||
print("PASS S5.2 DIRECT receive dispatch proves DATA and passes full LXMF body unchanged")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"verify_link_lxmf.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
|
||||
init_minimal_rns()
|
||||
try:
|
||||
identities = load_json(IDS_PATH)["vectors"]
|
||||
links = load_json(LINKS_PATH)["vectors"][0]
|
||||
alice = next(vector for vector in identities if vector["label"] == "alice")
|
||||
bob = next(vector for vector in identities if vector["label"] == "bob")
|
||||
alice_identity = RNS.Identity.from_bytes(bytes.fromhex(alice["inputs"]["private_key_hex"]))
|
||||
bob_identity = RNS.Identity.from_bytes(bytes.fromhex(bob["inputs"]["private_key_hex"]))
|
||||
RNS.Identity.remember(bytes(32), bytes.fromhex(alice["expected"]["destination_hash_hex"]), alice_identity.get_public_key(), None)
|
||||
derived_key = bytes.fromhex(links["expected"]["derived_key_hex"])
|
||||
link_id = bytes.fromhex(links["expected"]["link_id_hex"])
|
||||
|
||||
packet_packed, _ = verify_vectors(derived_key, link_id)
|
||||
verify_upstream_boundary(alice_identity, bob_identity)
|
||||
verify_router_direct_dispatch(packet_packed)
|
||||
print("ALL PASS")
|
||||
finally:
|
||||
try:
|
||||
RNS.Reticulum.exit_handler()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -14,14 +14,21 @@ Link and verifies:
|
|||
requested parts and emits RESOURCE_HMU.
|
||||
7. RESOURCE_RCL is emitted for advertisement rejection, while an ordinary
|
||||
receiver-side cancel is local-only.
|
||||
8. Deterministic Resource vector bytes round-trip.
|
||||
9. Receiver assembly decrypts once, strips the prefix, handles metadata,
|
||||
validates integrity, and emits RESOURCE_PRF.
|
||||
10. Malformed ADV, wrong-r, corrupt-part, invalid-HMU-boundary,
|
||||
oversized-decompression, and incorrect-proof cases are rejected.
|
||||
|
||||
Exit code 0 on PASS, non-zero on FAIL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import LXMF
|
||||
|
|
@ -32,6 +39,8 @@ from RNS.Resource import Resource, ResourceAdvertisement
|
|||
|
||||
|
||||
FIXED_TOKEN_KEY = bytes(range(64))
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VECTORS_PATH = os.path.join(REPO_ROOT, "test-vectors", "resources.json")
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
|
|
@ -57,6 +66,8 @@ class FakeLink:
|
|||
self._token = Token(FIXED_TOKEN_KEY)
|
||||
self.cancelled_incoming = []
|
||||
self.cancelled_outgoing = []
|
||||
self.concluded = []
|
||||
self.torn_down = False
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
return self._token.encrypt(data)
|
||||
|
|
@ -71,7 +82,10 @@ class FakeLink:
|
|||
self.cancelled_outgoing.append(resource)
|
||||
|
||||
def resource_concluded(self, resource) -> None:
|
||||
pass
|
||||
self.concluded.append(resource)
|
||||
|
||||
def teardown(self) -> None:
|
||||
self.torn_down = True
|
||||
|
||||
|
||||
def verify_default_lxmf_threshold() -> None:
|
||||
|
|
@ -83,7 +97,7 @@ def verify_default_lxmf_threshold() -> None:
|
|||
)
|
||||
if expected != 319:
|
||||
fail(f"S10 default direct-LXMF threshold changed: got {expected}, want 319")
|
||||
print("PASS S10 default direct-LXMF Resource threshold: content > 319 bytes")
|
||||
print("PASS S10 default direct-LXMF Resource threshold: computed content_size > 319")
|
||||
|
||||
|
||||
def verify_preparation_and_advertisement() -> None:
|
||||
|
|
@ -239,6 +253,190 @@ def verify_receiver_reject_vs_cancel() -> None:
|
|||
print("PASS S10.9 RESOURCE_RCL rejects advertisements; ordinary receiver cancel is local-only")
|
||||
|
||||
|
||||
def load_vector() -> dict:
|
||||
try:
|
||||
with open(VECTORS_PATH, "r", encoding="utf-8") as vector_file:
|
||||
return json.load(vector_file)["vectors"][0]
|
||||
except (OSError, KeyError, IndexError, json.JSONDecodeError) as exc:
|
||||
fail(f"S10 Resource vector could not be loaded: {exc}")
|
||||
|
||||
|
||||
def verify_deterministic_vector() -> None:
|
||||
vector = load_vector()
|
||||
inputs = vector["inputs"]
|
||||
expected = vector["expected"]
|
||||
plaintext = bytes.fromhex(inputs["plaintext_hex"])
|
||||
prefix = bytes.fromhex(inputs["throwaway_prefix_hex"])
|
||||
random_hash = bytes.fromhex(inputs["random_hash_hex"])
|
||||
encrypted_stream = bytes.fromhex(expected["encrypted_stream_hex"])
|
||||
|
||||
token = Token(bytes.fromhex(inputs["token_key_hex"]))
|
||||
if token.decrypt(encrypted_stream) != prefix + plaintext:
|
||||
fail("S10 vector ciphertext did not decrypt to prefix || plaintext")
|
||||
|
||||
resource_hash = RNS.Identity.full_hash(plaintext + random_hash)
|
||||
proof = RNS.Identity.full_hash(plaintext + resource_hash)
|
||||
if resource_hash.hex() != expected["resource_hash_hex"]:
|
||||
fail("S10 vector resource hash mismatch")
|
||||
if proof.hex() != expected["expected_proof_hex"]:
|
||||
fail("S10 vector expected proof mismatch")
|
||||
if (resource_hash + proof).hex() != expected["resource_prf_body_hex"]:
|
||||
fail("S10 vector RESOURCE_PRF body mismatch")
|
||||
|
||||
part_bodies = [bytes.fromhex(part["body_hex"]) for part in expected["parts"]]
|
||||
if b"".join(part_bodies) != encrypted_stream:
|
||||
fail("S10 vector part bodies do not concatenate to encrypted stream")
|
||||
map_hashes = b"".join(
|
||||
RNS.Identity.full_hash(body + random_hash)[:Resource.MAPHASH_LEN]
|
||||
for body in part_bodies
|
||||
)
|
||||
if map_hashes.hex() != expected["hashmap_hex"]:
|
||||
fail("S10 vector hashmap mismatch")
|
||||
|
||||
adv = ResourceAdvertisement.unpack(bytes.fromhex(expected["advertisement_plaintext_hex"]))
|
||||
if adv.h != resource_hash or adv.r != random_hash or adv.m != map_hashes:
|
||||
fail("S10 vector advertisement hash, random-hash, or hashmap mismatch")
|
||||
if adv.n != len(part_bodies) or adv.d != len(plaintext):
|
||||
fail("S10 vector advertisement part count or logical size mismatch")
|
||||
|
||||
print("PASS S10.2/S10.4/S10.8 deterministic Resource vector round-trip")
|
||||
|
||||
|
||||
def make_incoming(outgoing: Resource, link: FakeLink, directory: str, parts=None) -> Resource:
|
||||
incoming = Resource(None, link)
|
||||
incoming.status = Resource.TRANSFERRING
|
||||
incoming.initiator = False
|
||||
incoming.callback = None
|
||||
incoming.parts = parts if parts is not None else [part.data for part in outgoing.parts]
|
||||
incoming.encrypted = outgoing.encrypted
|
||||
incoming.compressed = outgoing.compressed
|
||||
incoming.random_hash = outgoing.random_hash
|
||||
incoming.hash = outgoing.hash
|
||||
incoming.has_metadata = outgoing.has_metadata
|
||||
incoming.segment_index = outgoing.segment_index
|
||||
incoming.total_segments = outgoing.total_segments
|
||||
incoming.storagepath = os.path.join(directory, "resource.data")
|
||||
incoming.meta_storagepath = os.path.join(directory, "resource.meta")
|
||||
incoming.max_decompressed_size = Resource.AUTO_COMPRESS_MAX_SIZE
|
||||
incoming.data = None
|
||||
|
||||
class AdvertisementPacket:
|
||||
pass
|
||||
|
||||
AdvertisementPacket.link = link
|
||||
AdvertisementPacket.plaintext = ResourceAdvertisement(outgoing).pack()
|
||||
incoming.advertisement_packet = AdvertisementPacket
|
||||
return incoming
|
||||
|
||||
|
||||
def verify_receiver_assembly_and_proof() -> None:
|
||||
link = FakeLink()
|
||||
payload = b"receiver-assembly-" * 160
|
||||
metadata = {"name": "fixture.bin", "kind": "resource"}
|
||||
outgoing = Resource(payload, link, metadata=metadata, advertise=False, auto_compress=True)
|
||||
captured: list[RNS.Packet] = []
|
||||
callback_result = {}
|
||||
real_outbound = RNS.Transport.outbound
|
||||
real_cache = RNS.Transport.cache
|
||||
|
||||
def fake_outbound(packet):
|
||||
captured.append(packet)
|
||||
return True
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="verify-resource-") as directory:
|
||||
incoming = make_incoming(outgoing, link, directory)
|
||||
|
||||
def assembled(resource):
|
||||
callback_result["data"] = resource.data.read()
|
||||
callback_result["metadata"] = resource.metadata
|
||||
|
||||
incoming.callback = assembled
|
||||
RNS.Transport.outbound = staticmethod(fake_outbound)
|
||||
RNS.Transport.cache = staticmethod(lambda packet, force_cache=False: None)
|
||||
try:
|
||||
incoming.assemble()
|
||||
finally:
|
||||
RNS.Transport.outbound = real_outbound
|
||||
RNS.Transport.cache = real_cache
|
||||
|
||||
proofs = [packet for packet in captured if packet.context == RNS.Packet.RESOURCE_PRF]
|
||||
if incoming.status != Resource.COMPLETE:
|
||||
fail(f"S10.8 receiver assembly status is {incoming.status}, want COMPLETE")
|
||||
if callback_result != {"data": payload, "metadata": metadata}:
|
||||
fail("S10.8 receiver assembly did not recover payload and metadata")
|
||||
if len(proofs) != 1 or proofs[0].data != outgoing.hash + outgoing.expected_proof:
|
||||
fail("S10.8 receiver did not emit the expected RESOURCE_PRF body")
|
||||
|
||||
outgoing.status = Resource.AWAITING_PROOF
|
||||
outgoing.validate_proof(bytes(64))
|
||||
if outgoing.status != Resource.AWAITING_PROOF:
|
||||
fail("S10.8 initiator accepted an incorrect Resource proof")
|
||||
outgoing.validate_proof(outgoing.hash + outgoing.expected_proof)
|
||||
if outgoing.status != Resource.COMPLETE:
|
||||
fail("S10.8 initiator rejected the correct Resource proof")
|
||||
|
||||
print("PASS S10.8 receiver assembly, metadata recovery, PRF emission, and proof validation")
|
||||
|
||||
|
||||
def verify_negative_cases() -> None:
|
||||
try:
|
||||
ResourceAdvertisement.unpack(b"\x81\xa1t\x01")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
fail("S10.4 malformed Resource advertisement was accepted")
|
||||
|
||||
link = FakeLink()
|
||||
outgoing = Resource(b"integrity-check-" * 100, link, advertise=False, auto_compress=False)
|
||||
real_outbound = RNS.Transport.outbound
|
||||
captured: list[RNS.Packet] = []
|
||||
RNS.Transport.outbound = staticmethod(lambda packet: captured.append(packet) or True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
||||
wrong_r = make_incoming(outgoing, link, directory)
|
||||
wrong_r.random_hash = bytes(Resource.RANDOM_HASH_SIZE)
|
||||
wrong_r.assemble()
|
||||
if wrong_r.status != Resource.CORRUPT:
|
||||
fail("S10.8 Resource with wrong r was not marked CORRUPT")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
||||
corrupt_parts = [part.data for part in outgoing.parts]
|
||||
corrupt_parts[0] = bytes([corrupt_parts[0][0] ^ 1]) + corrupt_parts[0][1:]
|
||||
corrupt = make_incoming(outgoing, link, directory, corrupt_parts)
|
||||
corrupt.assemble()
|
||||
if corrupt.status != Resource.CORRUPT:
|
||||
fail("S10.8 Resource with corrupt part was not marked CORRUPT")
|
||||
|
||||
compressed = Resource(b"Z" * 5000, link, advertise=False, auto_compress=True)
|
||||
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
||||
oversized = make_incoming(compressed, link, directory)
|
||||
oversized.max_decompressed_size = 128
|
||||
oversized.assemble()
|
||||
if oversized.status != Resource.CORRUPT or not link.torn_down:
|
||||
fail("S10.8 oversized decompression was not rejected and link torn down")
|
||||
|
||||
payload_size = ResourceAdvertisement.HASHMAP_MAX_LEN * outgoing.sdu
|
||||
boundary = Resource(b"B" * payload_size, link, advertise=False, auto_compress=False)
|
||||
boundary.status = Resource.TRANSFERRING
|
||||
boundary.adv_sent = time.time()
|
||||
boundary.rtt = 0.1
|
||||
invalid_request = (
|
||||
bytes([Resource.HASHMAP_IS_EXHAUSTED])
|
||||
+ boundary.parts[0].map_hash
|
||||
+ boundary.hash
|
||||
)
|
||||
captured.clear()
|
||||
boundary.request(invalid_request)
|
||||
if boundary.status != Resource.FAILED:
|
||||
fail("S10.7 invalid HMU boundary did not cancel the Resource")
|
||||
if any(packet.context == RNS.Packet.RESOURCE_HMU for packet in captured):
|
||||
fail("S10.7 invalid HMU boundary emitted RESOURCE_HMU")
|
||||
finally:
|
||||
RNS.Transport.outbound = real_outbound
|
||||
|
||||
print("PASS S10.4/S10.7/S10.8 malformed and corrupt Resource cases are rejected")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"verify_resource.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
|
||||
verify_default_lxmf_threshold()
|
||||
|
|
@ -246,6 +444,9 @@ def main() -> None:
|
|||
verify_multisegment_total_size()
|
||||
verify_exhausted_request_with_parts()
|
||||
verify_receiver_reject_vs_cancel()
|
||||
verify_deterministic_vector()
|
||||
verify_receiver_assembly_and_proof()
|
||||
verify_negative_cases()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -151,10 +151,17 @@ def verify_ticket_shortcut(target_cost=4):
|
|||
|
||||
lxm = LXMessage(destination=dst, source=src, content=b"x", title=b"",
|
||||
fields={}, desired_method=LXMessage.OPPORTUNISTIC)
|
||||
lxm.message_id = hashlib.sha256(b"ticket-shortcut-test").digest()
|
||||
|
||||
ticket = os.urandom(LXMessage.TICKET_LENGTH) # 16B
|
||||
expected_stamp = RNS.Identity.truncated_hash(ticket + lxm.message_id)
|
||||
ticket = bytes(range(LXMessage.TICKET_LENGTH))
|
||||
nonce = 0
|
||||
while True:
|
||||
lxm.message_id = hashlib.sha256(
|
||||
b"ticket-shortcut-test" + nonce.to_bytes(4, "big")
|
||||
).digest()
|
||||
expected_stamp = RNS.Identity.truncated_hash(ticket + lxm.message_id)
|
||||
workblock = LXStamper.stamp_workblock(lxm.message_id)
|
||||
if not LXStamper.stamp_valid(expected_stamp, target_cost, workblock):
|
||||
break
|
||||
nonce += 1
|
||||
lxm.stamp = expected_stamp
|
||||
|
||||
if not lxm.validate_stamp(target_cost, tickets=[ticket]):
|
||||
|
|
@ -164,9 +171,9 @@ def verify_ticket_shortcut(target_cost=4):
|
|||
print("PASS S5.7.3 LXMessage.validate_stamp accepts ticket stamp shortcut")
|
||||
|
||||
# With wrong ticket — must NOT match the ticket shortcut, and the
|
||||
# PoW path won't validate either (because expected_stamp wasn't
|
||||
# generated against the workblock). validate_stamp returns False.
|
||||
wrong_ticket = os.urandom(LXMessage.TICKET_LENGTH)
|
||||
# PoW path is known not to validate either; the deterministic fixture
|
||||
# above explicitly selects a ticket stamp below target_cost.
|
||||
wrong_ticket = bytes(reversed(ticket))
|
||||
lxm.stamp_value = None
|
||||
if lxm.validate_stamp(target_cost, tickets=[wrong_ticket]):
|
||||
fail("S5.7.3 validate_stamp accepted with wrong ticket")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue