* §11/§10: NomadNet conventions + REQUEST/RESPONSE security clarifications
Expands the NomadNet-specific conventions documented in the spec
based on bytes-on-the-wire findings from a clean-room Kotlin port.
Three motivating bug classes from upstream interop:
1. Element [2] of the REQUEST envelope. Pre-clarification text said
"application-defined bytes (often msgpack itself, or None)" which
reads as "you can pre-msgpack-encode a dict and pass the bytes."
Doing so produces a wire envelope where decode yields a `bytes`
object for [2], not the dict the upstream `Node.py:109` /
`LXMRouter.__get_handler` `isinstance(data, dict)` /
`isinstance(data, list)` checks expect. Result: silent no-op
on every form submission and every propagation /get round.
§11.1 now spells out that the whole envelope is msgpacked once
with `data` as a native msgpack value, with a worked example.
2. RESPONSE element [0] verification. The spec already documented
request_id correlation but didn't flag it as a MUST for security.
Without the check, a misbehaving / compromised transit relay can
replay a stale RESPONSE from a prior request and the initiator
accepts it as the answer to whatever's currently pending. Latent
today on implementations that drive one in-flight request per
link, but a real footgun the moment they add link reuse,
partials, or pipelining. §11.2 now calls this out as a security
requirement.
3. Resource size cap (§10.4). Today implementations pre-allocate
buffers from `t` / `d` and have no cap on bz2 decompression
output. A small (~tens of KB) compressed payload can legitimately
expand to gigabytes. The HASHMAP_MAX_LEN chunk-count limit
bounds raw on-wire chunks but does NOT bound post-decompression.
§10.4 now recommends a per-application cap and notes that
decompressors MUST also abort if the running output total
exceeds the cap (defense in depth — a sender that lies about
`d` would otherwise bypass the parse-time check).
Substantially expands §11.6 NomadNet specifics from a 4-bullet
informational paragraph to eight sub-sections covering:
- §11.6.1 Paths and the `nomadnetwork.node` aspect.
- §11.6.2 Form-data dict shape: `field_<name>` (widget values) and
`var_<name>` (URL-query-style link parameters), both mapped to
env vars by `Node.py:109-111`. Includes checkbox semantics
(omit-unchecked, comma-join multi-select).
- §11.6.3 Link target syntax: same-node `/path`, cross-node
`<32hex>:/path`, bare-hash default, `nnn@`/`lxmf@` shorthands
with the `expand_shorthands` table. Notes hash-hex case
normalization and rejection of separator-laden variants.
- §11.6.4 Page-level header conventions: `#!c=N` cache-TTL,
`#!bg=` / `#!fg=` colors.
- §11.6.5 File downloads via `/file/...` returning
`(file_handle, metadata_dict)`.
- §11.6.6 ALLOW_ALL vs ALLOW_LIST + the LINKIDENTIFY (0xFB)
precondition for ALLOW_LIST pages, plus a privacy note that
identify on every link pins long-term identity to the page
operator.
- §11.6.7 Partial pages (server-side includes via `` `{path} ``).
- §11.6.8 Source map of NomadNet ↔ wire concept references.
All citations are to upstream `markqvist/NomadNet` master fetched
2026-05-04; the spec text is informational (not normative) since
the wire layer is §11 itself. The expansion is for clean-room
implementers who'd otherwise need to read several thousand lines
of `Browser.py` + `Node.py` to know what wire shape a NomadNet
server expects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* §10/§11.1/§11.2: Resource pipeline + request_id corrections
Found by sideloading a clean-room Kotlin port (`thatSFguy/reticulum-mobile-app`)
on a phone and watching multi-packet NomadNet pages fail to load.
Each correction below was the difference between "spec implementer
got it wrong silently" and "page actually loads."
§11.1 — request_id formula precision:
Prior text "16-byte truncated hash of `packed_request`" reads
ambiguously. Several implementations (including the v0.1.54
build of the Kotlin port) hashed the inner plaintext bytes —
formula matches nothing the server sent, every RESPONSE drops.
Upstream `Link.handle_request:1286` is `packet.getTruncatedHash()`
i.e. SHA-256 of the on-the-wire packet hashable_part:
`(raw[0] & 0x0F) || raw[2:]` (HEADER_1) / `... || raw[18:]` (HEADER_2).
For Resource REQUESTs, the request_id IS plaintext-derived
(carried in adv.q, set by initiator in `Resource.__init__:478`)
because there's no single packet to hash. Updated text spells
out both forms explicitly.
§11.2 — security note matched to corrected formula:
Same fix in the implementer-gotcha box. Was telling clean-room
ports to compute the wrong thing.
§10.6 — chunks are not individually encrypted:
Per §10.2 step 4 the entire `random_hash || data` blob is link-
encrypted ONCE, then sliced at step 6. Each wire chunk is just
`outerToken[i*sdu : (i+1)*sdu]` with no per-chunk Token header.
Receivers MUST hand chunks to the hashmap match without per-
chunk decrypt. Spec text "parts are link-encrypted" reads
ambiguously enough that decrypting per-chunk feels reasonable —
added an explicit callout with the upstream slice loop and a
warning that per-chunk `link.decrypt(chunk)` will HMAC-fail on
every packet.
§10.8 — random_hash prefix is stripped, NOT compared to adv.r:
Sender at `Resource.py:567` uses
`RNS.Identity.get_random_hash()[:4]` for the prefix — a fresh
random call, deliberately distinct from `self.random_hash`
(the value `r` carries). A receiver that does
`assert prefix == adv.r` rejects every legitimate Resource
as corrupt. Step 3 of the assemble flow now says "strip and
discard"; integrity is proven exclusively by step 5's
`SHA256(data || r) == h`.
§11.6 (NomadNet specifics) and §10.4 (Resource size cap) carried
over from the closed PR #2 unchanged — those parts of #2 were
correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61bfc03413
commit
1e375e52ea
1 changed files with 270 additions and 8 deletions
278
SPEC.md
278
SPEC.md
|
|
@ -1937,6 +1937,31 @@ bit 5 : x — has_metadata
|
|||
|
||||
The advertisement is sent once on `Resource.advertise()`; if no part requests arrive within the watchdog timeout, it is retransmitted up to `MAX_ADV_RETRIES = 4` times before the resource is cancelled (`Resource.py:573-590`).
|
||||
|
||||
> **Security: cap `t` and `d` at receive time.** `t` and `d` are the
|
||||
> sender's claims about how big the resource will be. A misbehaving
|
||||
> or hostile peer can advertise multi-gigabyte values that a naïve
|
||||
> receiver will then try to allocate buffers for. Two attack shapes
|
||||
> matter:
|
||||
>
|
||||
> 1. **Direct allocation bomb.** Receiver pre-allocates an output
|
||||
> buffer sized from `t` or `d` and OOMs before any chunk arrives.
|
||||
> 2. **Decompression bomb (when `c = 1`).** A small (~tens of KB)
|
||||
> bz2 input legitimately expands to gigabytes. The chunk-count
|
||||
> cap from `HASHMAP_MAX_LEN` (§10.4) bounds raw on-wire chunks
|
||||
> but does NOT bound the post-decompression buffer.
|
||||
>
|
||||
> Implementations SHOULD enforce a per-application cap (a few MiB is
|
||||
> reasonable for NomadNet pages and propagation `/get` blobs; file
|
||||
> downloads MAY allow more if the receiver has the budget) and
|
||||
> reject advertisements with `t` or `d` over the cap before
|
||||
> responding with the first RESOURCE_REQ. When `c = 1`, the
|
||||
> decompressor MUST also abort if the running output total exceeds
|
||||
> the cap (defense in depth — a sender that lies about `d` would
|
||||
> otherwise bypass the parse-time check). Reference: a receiver
|
||||
> implementing `delivery_resource_advertised(resource)` returning
|
||||
> `False` (§5.8.3 / §16.9) is the upstream-blessed way to refuse
|
||||
> oversized advertisements.
|
||||
|
||||
### 10.5 RESOURCE_REQ — receiver requests parts
|
||||
|
||||
Sent by the receiver to ask for a window's worth of specific parts (`Resource.py:934-983`). Body layout:
|
||||
|
|
@ -1965,6 +1990,38 @@ Two interop traps:
|
|||
1. **Map_hashes are not guaranteed unique across the whole resource** — only within `COLLISION_GUARD_SIZE` of any sliding-window position. A receiver that searches the entire hashmap for a matching part-hash can mis-place a part if two distant parts collide. The reference receiver searches only `hashmap[consecutive_completed_height : consecutive_completed_height + window]`.
|
||||
2. **Parts are link-encrypted but otherwise opaque** — the receiver has no way to validate a part beyond its 4-byte map_hash until the whole resource assembles and the SHA-256 over the reassembled data matches `h`.
|
||||
|
||||
> **Implementation gotcha: chunks are NOT individually encrypted —
|
||||
> they are raw slices of an already-encrypted whole.** Per §10.2 step
|
||||
> 4, the entire `random_hash || (compressed?) data` blob is link-
|
||||
> encrypted ONCE, *then* split into MTU-sized parts at step 6. Each
|
||||
> wire chunk is just `outerToken[i*sdu : (i+1)*sdu]` — a fragment
|
||||
> with no Token-form header (no IV, no HMAC) of its own. Receivers
|
||||
> MUST hand inbound chunk bytes directly to the hashmap match
|
||||
> (`SHA-256(chunk || random_hash)[:4]`) without attempting per-chunk
|
||||
> Token decrypt. The single decrypt step happens once over the
|
||||
> concatenated assembly inside `assemble()` (§10.8), not per packet.
|
||||
>
|
||||
> A receiver that calls `link.decrypt(chunk)` on each inbound
|
||||
> RESOURCE part will fail with HMAC verification errors on every
|
||||
> chunk — each slice is missing the Token header bytes the
|
||||
> decrypt expects. This is a common implementer mistake and the
|
||||
> spec text "parts are link-encrypted" reads ambiguously enough
|
||||
> that several clean-room ports have made it. Verbatim from
|
||||
> `Resource.py:607-625`:
|
||||
>
|
||||
> ```python
|
||||
> for i in range(0, hashmap_entries):
|
||||
> data = self.data[i*self.sdu : (i+1)*self.sdu] # slice ciphertext
|
||||
> map_hash = self.get_map_hash(data) # hash the SLICE
|
||||
> part = RNS.Packet(link, data, context=RNS.Packet.RESOURCE)
|
||||
> part.pack()
|
||||
> self.hashmap += part.map_hash
|
||||
> self.parts.append(part)
|
||||
> ```
|
||||
>
|
||||
> The body of each RESOURCE packet is `data` here — a raw slice of
|
||||
> the already-encrypted `self.data`. No re-encryption.
|
||||
|
||||
### 10.7 RESOURCE_HMU — hashmap update
|
||||
|
||||
When the sender receives a RESOURCE_REQ with `exhausted == 0xFF` and a `last_map_hash`, it locates the position of `last_map_hash` in its full hashmap, advances to the **next** `HASHMAP_MAX_LEN` window, and emits the hashmap continuation (`Resource.py:1030-1064`):
|
||||
|
|
@ -1983,12 +2040,26 @@ When the receiver has assembled the full resource (`received_count == total_part
|
|||
|
||||
1. Concatenate `parts[0..n]` to a single buffer.
|
||||
2. `link.decrypt(...)` to plaintext.
|
||||
3. Strip the 4-byte `random_hash` prefix.
|
||||
3. Strip the 4-byte `random_hash` prefix — **discard, do NOT compare to advertisement.r** (see callout below).
|
||||
4. If `compressed`: bz2-decompress.
|
||||
5. Recompute `SHA256(plaintext_with_random || random_hash)` and compare to `h`.
|
||||
6. If match: peel off metadata if `x` is set, write `data` to the destination; status = `COMPLETE`.
|
||||
7. If mismatch: status = `CORRUPT`; cancel.
|
||||
|
||||
> **Implementation gotcha: the leading 4 bytes are NOT
|
||||
> `advertisement.r`.** Step 3 reads "strip the 4-byte random_hash
|
||||
> prefix" — sender-side `Resource.py:567` writes those bytes via
|
||||
> `RNS.Identity.get_random_hash()[:4]`, a fresh random call. They
|
||||
> are deliberately distinct from `self.random_hash` (the value
|
||||
> the advertisement's `r` field carries — used only for the
|
||||
> hashmap formula `SHA256(chunk || r)[:4]` and the integrity
|
||||
> formula `SHA256(data || r)`). A receiver that does
|
||||
> `assert prefix == advertisement.r` will reject every legitimate
|
||||
> Resource as corrupt. Just strip and discard. Integrity is proven
|
||||
> exclusively by step 5's `SHA256(plaintext_with_random || random_hash)`
|
||||
> against `h` — that's the only check that matters; the prefix
|
||||
> bytes are scaffolding.
|
||||
|
||||
On `COMPLETE`, the receiver emits the proof:
|
||||
|
||||
```
|
||||
|
|
@ -2092,10 +2163,38 @@ The msgpack array layout:
|
|||
```
|
||||
[0] timestamp float (seconds since unix epoch, requester's clock)
|
||||
[1] request_path_hash bytes(16) — SHA-256 of the requested path string, truncated
|
||||
[2] data application-defined bytes (often msgpack itself, or None)
|
||||
[2] data application-defined value, encoded directly into the
|
||||
outer msgpack list — NOT a pre-msgpacked byte blob
|
||||
```
|
||||
|
||||
`request_id` is the 16-byte truncated hash of `packed_request` — used by the receiver to correlate the inbound RESPONSE with this REQUEST. For single-packet REQUESTs the request_id is computed receiver-side from the packet body bytes; for Resource REQUESTs the request_id is carried explicitly in the advertisement's `q` field (§10.4).
|
||||
> **Implementation gotcha: element [2] is encoded once, not twice.**
|
||||
> `data` is whatever the application wants to send: `None` (msgpack nil)
|
||||
> for plain GETs, a `dict` for NomadNet form posts (§11.6), a `list` for
|
||||
> LXMF propagation `/get` rounds (§11.6), or `bytes` for opaque
|
||||
> application blobs. **The whole `[time, path_hash, data]` list is
|
||||
> msgpacked exactly once.** Element [2] is NOT a pre-encoded byte blob
|
||||
> wrapped as msgpack `bin` — that's a common implementer mistake (see
|
||||
> below) and it silently corrupts every form submission and every
|
||||
> propagation poll because server-side handlers do
|
||||
> `isinstance(data, dict)` / `isinstance(data, list)` and the `bin`
|
||||
> form is `bytes`, falling through to the no-op branch.
|
||||
>
|
||||
> Concrete example for a NomadNet form post `field_message=hello`:
|
||||
>
|
||||
> ```python
|
||||
> data = {"field_message": "hello"} # native Python dict
|
||||
> envelope = [time.time(), path_hash, data]
|
||||
> packed = umsgpack.packb(envelope) # ONE pack call
|
||||
> # → on the wire, element [2] decodes back to a {} map, NOT to bytes
|
||||
> ```
|
||||
>
|
||||
> Pre-pack callers (`umsgpack.packb(data)` then passing the bytes as
|
||||
> element [2]) produce a wire envelope where decode yields `bytes` for
|
||||
> [2] — looks structurally similar but is semantically a different
|
||||
> type, and every NomadNet `Node.py:109` / LXMF `LXMRouter.__get_handler`
|
||||
> drops the request silently with no error response.
|
||||
|
||||
For single-packet REQUESTs, `request_id = SHA-256(packet.get_hashable_part())[:16]` — i.e. the 16-byte truncation of the **packet hash**, computed over the on-the-wire bytes (low nibble of flags || `raw[2:]` for HEADER_1 / `raw[18:]` for HEADER_2). NOT a hash of the inner plaintext or of the msgpack-encoded `packed_request` blob. The server side at `Link.handle_request:1286` literally calls `packet.getTruncatedHash()`. Both sides MUST hash the same bytes to match. For Resource REQUESTs the request_id is carried explicitly in the advertisement's `q` field (§10.4) and the initiator MUST set it to the truncated `SHA-256(packed_request)[:16]` of the inner plaintext per `Resource.py::__init__` line 478 (Resource path uses the plaintext-hash form because there is no single packet to hash). The receiver uses this id to correlate the inbound RESPONSE with this REQUEST.
|
||||
|
||||
### 11.2 Wire form — RESPONSE (server → initiator)
|
||||
|
||||
|
|
@ -2119,6 +2218,42 @@ else:
|
|||
|
||||
The `request_id` in element [0] of the response msgpack lets the initiator match the response to the original outbound REQUEST in `Link.pending_requests` even when several requests are in flight on the same Link (`Link.handle_response` line 906-925).
|
||||
|
||||
> **Security: initiators MUST verify element [0].** The request_id
|
||||
> check isn't decorative — without it, a misbehaving or compromised
|
||||
> transit relay can replay a stale RESPONSE from a prior request and
|
||||
> the initiator accepts it as the answer to whatever's currently
|
||||
> pending. An implementation that drives only one in-flight request
|
||||
> per link at a time is "lucky" today (the wrong-id RESPONSE just
|
||||
> happens to carry sane bytes for the application to display), but
|
||||
> as soon as it adds link reuse, partials, or any kind of pipelining
|
||||
> the bug becomes a silent confused-deputy.
|
||||
>
|
||||
> **Compute `expected_id` correctly.** Server-side
|
||||
> `Link.handle_request:1286` is:
|
||||
>
|
||||
> ```python
|
||||
> request_id = packet.getTruncatedHash()
|
||||
> ```
|
||||
>
|
||||
> i.e. **`SHA-256(packet.get_hashable_part())[:16]`** where
|
||||
> `get_hashable_part()` (`Packet.py:332-338`) is:
|
||||
>
|
||||
> ```
|
||||
> hashable = (raw[0] & 0x0F) || raw[2:] # HEADER_1
|
||||
> hashable = (raw[0] & 0x0F) || raw[18:] # HEADER_2 (skips transport_id slot)
|
||||
> ```
|
||||
>
|
||||
> NOT a hash of the inner plaintext. Compute the same on the
|
||||
> initiator from your outbound REQUEST packet's wire bytes; on every
|
||||
> inbound RESPONSE, drop the packet (and log) if `decoded[0]`
|
||||
> doesn't match. Many clean-room implementations have read this
|
||||
> section's prior wording (\"16-byte truncated hash of
|
||||
> `packed_request`\") as \"hash the inner plaintext bytes\" and
|
||||
> produced a formula that never matches what the server sent —
|
||||
> every RESPONSE gets dropped, every page-fetch and `/get` round
|
||||
> times out silently. The hashing is over the on-the-wire packet
|
||||
> bytes, not the encrypted-then-decrypted payload.
|
||||
|
||||
#### File responses
|
||||
|
||||
If the server's response generator returns a `(file_handle, metadata)` tuple, the response goes out as a Resource carrying the file's bytes with optional msgpack metadata in the Resource advertisement's `metadata` slot — `RNS/Link.py:888-895`:
|
||||
|
|
@ -2170,13 +2305,140 @@ Default timeout is `link.rtt × link.traffic_timeout_factor + Resource.RESPONSE_
|
|||
|
||||
### 11.6 NomadNet specifics (informational, not normative)
|
||||
|
||||
NomadNet pages are served over this protocol with these conventions:
|
||||
NomadNet pages are served over this protocol with these conventions. Source-of-truth for all of these is upstream `markqvist/NomadNet`: `nomadnet/Node.py` (server) and `nomadnet/ui/textui/Browser.py` (client).
|
||||
|
||||
- Path format: `/page/foo.mu` — the `.mu` extension marks "micron"-formatted pages (NomadNet's lightweight markup).
|
||||
- Request data: optional msgpack dict of form-field values (e.g. `{"username": "alice"}`).
|
||||
- Response: either inline page bytes (for static pages) or a file handle + metadata (for large pages or downloads).
|
||||
#### 11.6.1 Paths and the `nomadnetwork.node` aspect
|
||||
|
||||
None of these are wire-spec — they're caller conventions on top of §13. A Reticulum client that can't render micron markup can still fetch pages and display the raw bytes; the protocol layer doesn't care about content.
|
||||
- Server: hosts a destination at `nomadnetwork`/`node` aspects (`name_hash = 213e6311bcec54ab4fde`). Pages are registered as `register_request_handler(path="/page/<name>.mu", ...)`.
|
||||
- Client: default path is `/page/index.mu` (`Browser.py:67` `DEFAULT_PATH`).
|
||||
- Path format: `/page/<name>.mu` for micron pages, `/file/<name>` for static file downloads (§11.6.5).
|
||||
- Path hash on the wire is the §11.1 `SHA-256(path)[:16]` truncation — `/page/index.mu` and `/page/help.mu` are distinct request_handler keys.
|
||||
|
||||
#### 11.6.2 Form data and env-var convention
|
||||
|
||||
When a client tap on a micron link with form fields fires a REQUEST, element [2] of the envelope is a msgpack `dict` (NOT pre-msgpacked bytes — see §11.1). Two key prefixes are conventional and special-cased server-side:
|
||||
|
||||
| Prefix | Source | Server treatment |
|
||||
|---|---|---|
|
||||
| `field_<name>` | Form-input values typed by the user | Exported as env var `field_<name>=<value>` to the page's executable handler |
|
||||
| `var_<name>` | URL-query-style parameters embedded in the link itself | Exported as env var `var_<name>=<value>` |
|
||||
|
||||
`Node.py:109-111` (upstream master, fetched 2026-05-04):
|
||||
|
||||
```python
|
||||
if data != None and isinstance(data, dict):
|
||||
for e in data:
|
||||
if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")):
|
||||
env_map[e] = data[e]
|
||||
```
|
||||
|
||||
The `field_` vs `var_` distinction is purely cosmetic on the wire (both become env vars), but in micron syntax they have separate origins:
|
||||
|
||||
- **Form fields** (`field_<name>`) come from `<flags|name`value>` widgets that render as text inputs / checkboxes / radios. The Browser collects current widget state into a dict at submit time.
|
||||
- **URL parameters** (`var_<name>`) come from `key=value` entries in the third backtick component of a link: `` `[label`/page/foo.mu`username=alice|active=true|message] `` produces `{"var_username": "alice", "var_active": "true", ...}` PLUS `field_message` from a widget named `message` (`Browser.py:198-205`). Entries with `=` are var-params; entries without are field-widget names whose current values get included.
|
||||
|
||||
##### Checkbox semantics (Browser.py:226-241)
|
||||
|
||||
For checkboxes specifically:
|
||||
|
||||
- **Unchecked**: the field key is **omitted from the dict entirely** (NOT sent as empty string).
|
||||
- **Multi-select** (multiple checkboxes sharing the same field name): values are comma-joined (`{"field_topics": "weather,radio"}`).
|
||||
|
||||
Implementations that always send `{"field_<name>": ""}` for unchecked boxes will break server-side handlers that test `if "field_subscribe" in env: ...`.
|
||||
|
||||
#### 11.6.3 Link target syntax (parsed by `Browser.py` `expand_shorthands` + `link_request`)
|
||||
|
||||
A micron link's `target` string (the second component of `[label`target]` or third of `[label`target`fields]`) is one of:
|
||||
|
||||
| Form | Meaning | Browser.py ref |
|
||||
|---|---|---|
|
||||
| `/path/to/page.mu` | Same-node nav: load `path` on the currently-selected destination. | implicit |
|
||||
| `<32hex>` (bare 16-byte truncated identity hash, hex-encoded) | Cross-node nav to `nomadnetwork.node` at that hash, default path `/page/index.mu`. | 255-259 |
|
||||
| `<32hex>:/page/x.mu` | Cross-node nav with explicit path. | 255-259 |
|
||||
| `nnn@<32hex>[:/path]` | Same as bare-hash form; `nnn` is a shorthand for `nomadnetwork.node`. | 184-189 |
|
||||
| `lxmf@<32hex>` / `lxmf.delivery@<32hex>` | Open a conversation in the LXMF (messaging) layer, NOT a page fetch. | 184-189, 266-322 |
|
||||
|
||||
`expand_shorthands` (lines 184-189):
|
||||
|
||||
```python
|
||||
def expand_shorthands(self, destination_type):
|
||||
if destination_type == "nnn": return "nomadnetwork.node"
|
||||
elif destination_type == "lxmf": return "lxmf.delivery"
|
||||
else: return destination_type
|
||||
```
|
||||
|
||||
Implementations should normalize hash hex to lower case before keying any cache / repo lookup, and reject inputs with embedded separators (`dead:beef:…`) — the wire form is plain bytes, accepting forgiving variants creates aliases for the same destination and risks cache-poisoning.
|
||||
|
||||
#### 11.6.4 Page-level header conventions
|
||||
|
||||
A `.mu` page MAY begin with one or more single-line headers prefixed `#!`. These are stripped by `Browser.py` before micron rendering and are NOT part of the page body:
|
||||
|
||||
| Header | Effect | Ref |
|
||||
|---|---|---|
|
||||
| `#!c=<seconds>` | Cache-TTL hint. `0` = "do not cache." Default cache is 12 h. | Browser.py:1315-1335 |
|
||||
| `#!bg=<3hex or 6hex>` | Page-wide background color. | Browser.py:1282-1302 |
|
||||
| `#!fg=<3hex or 6hex>` | Page-wide foreground color (overrides theme default). | Browser.py:1282-1302 |
|
||||
|
||||
The `#!c=N` header is widely used; the color headers are rare. A client that doesn't honor any of them still renders pages correctly.
|
||||
|
||||
#### 11.6.5 File downloads (`/file/...`)
|
||||
|
||||
Pages whose path starts with `/file/` are static downloads, not micron content. The server's response generator returns:
|
||||
|
||||
```python
|
||||
return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}]
|
||||
```
|
||||
|
||||
— a `(file_handle, metadata_dict)` pair. The transport-layer file response shape per §11.2 §"File responses": the file bytes go through the §10 Resource pipeline, AND the metadata is also embedded as a length-prefixed msgpack blob in the Resource advertisement's metadata-prefix slot (§10.2 step 1). Clients receive `[filename_bytes, file_data_bytes]` after Resource assembly (Browser.py:1437-1441).
|
||||
|
||||
A client that hasn't implemented file downloads can detect `/file/` paths and either show a "downloads not supported" message or just discard the response.
|
||||
|
||||
#### 11.6.6 Authorization: `ALLOW_ALL` vs `ALLOW_LIST`
|
||||
|
||||
Pages are registered with one of three allow modes (`Destination.py:35-40`):
|
||||
|
||||
- `ALLOW_ALL` — anyone with a Link can fetch. Used for public NomadNet pages, the propagation node's `/get`, etc.
|
||||
- `ALLOW_LIST` — caller's identity hash must appear in the page's `.allowed` file. Server checks `remote_identity.hash` against the list at request time (`Node.py:152-154`).
|
||||
- `ALLOW_NONE` — registered handlers that exist but reject all requests (rare; debug only).
|
||||
|
||||
For `ALLOW_LIST` the client MUST call `link.identify(identity)` immediately after the link transitions to ACTIVE and BEFORE issuing the REQUEST. This sends a `LINKIDENTIFY (context = 0xFB)` packet whose payload carries a signature over `link_id` proving the long-term identity hash. Without it, `remote_identity` is `None` server-side and every `ALLOW_LIST` page returns `DEFAULT_NOTALLOWED`. See `Browser.py:1245-1250` for the upstream call site:
|
||||
|
||||
```python
|
||||
def link_established(self, link):
|
||||
if self.app.directory.should_identify_on_connect(self.destination_hash):
|
||||
self.link.identify(self.app.identity)
|
||||
```
|
||||
|
||||
> **Privacy note for client implementers.** Calling `link.identify` on
|
||||
> *every* link reveals the user's long-term identity hash to any node
|
||||
> they browse — including pages on hostile public hubs. Implementations
|
||||
> SHOULD make `identify` opt-in per destination (or per session), only
|
||||
> firing it when the user has affirmatively decided to authenticate.
|
||||
> Anonymous browsing of `ALLOW_ALL` pages should not pin identity.
|
||||
|
||||
#### 11.6.7 Partial pages (server-side includes)
|
||||
|
||||
A micron page may embed `` `{<path>[`<refresh_seconds>[`<fields>]]} `` placeholders. The Browser tracks each placeholder, opens / reuses a Link to the partial's destination, fetches `<path>` as a sub-REQUEST, and substitutes the response bytes into the rendered output. If a `<refresh>` is set, the partial is re-fetched periodically.
|
||||
|
||||
Implementation reference: `Browser.py:493-606` (`__load_partial`, `start_partial_updater`). Partials are how live "chat tail" / "status" panels work on real NomadNet community pages. A client without partial support sees the literal placeholder text and the page renders as a static snapshot.
|
||||
|
||||
#### 11.6.8 Source map (NomadNet ↔ wire)
|
||||
|
||||
| Concept | Upstream Python file:line |
|
||||
|---|---|
|
||||
| Default path | `nomadnet/ui/textui/Browser.py:67` |
|
||||
| Form-field collection | `Browser.py:198-241` |
|
||||
| `field_` / `var_` env-var mapping | `nomadnet/Node.py:109-111` |
|
||||
| Shorthand expansion (`nnn`/`lxmf`) | `Browser.py:184-189` |
|
||||
| Cross-node link routing | `Browser.py:248-322` |
|
||||
| Identify-on-connect | `Browser.py:1245-1250` |
|
||||
| Cache-TTL header `#!c=N` | `Browser.py:1315-1335` |
|
||||
| Color headers `#!bg=` / `#!fg=` | `Browser.py:1282-1302` |
|
||||
| `/file/...` download dispatch | `Browser.py:781-785, 1420-1462` + `Node.py:128-141` |
|
||||
| Partial placeholders | `Browser.py:493-606` |
|
||||
| Allow modes / `ALLOW_LIST` enforcement | `Node.py:152-154` |
|
||||
|
||||
None of these are wire-spec — they're caller conventions layered on top of §11. A Reticulum client that can't render micron markup or doesn't implement the form/cache/partial conventions can still fetch pages and display the raw bytes; the protocol layer doesn't care about content.
|
||||
|
||||
### 11.7 Source map
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue