From 9db901088e64a1dbd6dae76738a5e2403af31ce9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 1 Apr 2026 21:01:11 -0600 Subject: [PATCH] Add comprehensive spec audit results and pyMC_core issue reports - CHANGELOG.md: Full audit findings organized by severity - PYMC_CORE_ISSUES.md: 7 evidence-based bug reports for pyMC_core (wrong constants, broken path_len decoding, wrong TRACE hash, wrong SNR calculation) Co-Authored-By: Claude Opus 4.6 (1M context) Add comprehensive spec audit results and pyMC_core issue reports - CHANGELOG.md: Full audit findings organized by severity - PYMC_CORE_ISSUES.md: 7 evidence-based bug reports for pyMC_core (wrong constants, broken path_len decoding, wrong TRACE hash, wrong SNR calculation) - Remove ERRATA.md: contents already covered by audit findings Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 22 + CHANGELOG.md | 72 +++ CLAUDE.md | 42 ++ CONTRIBUTING.md | 35 ++ LICENSE | 21 + PYMC_CORE_ISSUES.md | 426 ++++++++++++++++ README.md | 70 +++ corpus/bridge/framing.json | 91 ++++ corpus/companion/commands.json | 94 ++++ corpus/companion/framing.json | 74 +++ corpus/companion/responses.json | 53 ++ corpus/crypto/aes128ecb/block-padding.json | 113 +++++ corpus/crypto/aes128ecb/encrypt.json | 110 +++++ corpus/crypto/ecdh/shared-secret.json | 87 ++++ corpus/crypto/ed25519/sign-verify.json | 90 ++++ corpus/crypto/encrypt-then-mac/roundtrip.json | 63 +++ corpus/crypto/hmac-sha256/truncated-mac.json | 61 +++ corpus/crypto/sha256/basic.json | 84 ++++ corpus/crypto/sha256/packet-hash.json | 109 +++++ corpus/kiss/framing.json | 141 ++++++ corpus/kiss/sethardware.json | 185 +++++++ corpus/kiss/standard-commands.json | 97 ++++ corpus/payloads/ack/basic.json | 122 +++++ corpus/payloads/ack/boundary.json | 115 +++++ corpus/payloads/advert/app-data-combined.json | 128 +++++ corpus/payloads/advert/app-data-location.json | 96 ++++ corpus/payloads/advert/app-data-types.json | 140 ++++++ corpus/payloads/advert/basic.json | 88 ++++ corpus/payloads/anon-req/basic.json | 85 ++++ corpus/payloads/control/basic.json | 96 ++++ corpus/payloads/encrypted/basic.json | 129 +++++ corpus/payloads/encrypted/mac-verify.json | 69 +++ corpus/payloads/encrypted/padding.json | 99 ++++ corpus/payloads/encrypted/roundtrip.json | 47 ++ corpus/payloads/encrypted/with-extra.json | 76 +++ corpus/payloads/group/grp-data.json | 38 ++ corpus/payloads/group/grp-txt.json | 46 ++ corpus/payloads/multipart/basic.json | 108 +++++ corpus/payloads/path-return/basic.json | 122 +++++ corpus/payloads/raw-custom/basic.json | 97 ++++ corpus/payloads/trace/basic.json | 127 +++++ corpus/schema/test-vector.schema.json | 459 ++++++++++++++++++ corpus/wire-format/framing/decode-only.json | 75 +++ corpus/wire-format/framing/empty-payload.json | 33 ++ corpus/wire-format/framing/maximal.json | 139 ++++++ corpus/wire-format/framing/minimal.json | 127 +++++ corpus/wire-format/header/combined.json | 165 +++++++ corpus/wire-format/header/payload-types.json | 308 ++++++++++++ corpus/wire-format/header/route-types.json | 131 +++++ corpus/wire-format/header/versions.json | 97 ++++ corpus/wire-format/invalid/bad-path-len.json | 57 +++ corpus/wire-format/invalid/too-short.json | 49 ++ corpus/wire-format/invalid/truncated.json | 65 +++ corpus/wire-format/path/boundary.json | 156 ++++++ corpus/wire-format/path/hash-counts.json | 260 ++++++++++ corpus/wire-format/path/hash-sizes.json | 188 +++++++ corpus/wire-format/transport-codes/basic.json | 183 +++++++ spec/00-overview.md | 135 ++++++ spec/01-wire-format.md | 140 ++++++ spec/02-header.md | 125 +++++ spec/03-path.md | 155 ++++++ spec/04-payload-ack.md | 57 +++ spec/05-payload-advert.md | 123 +++++ spec/06-payload-encrypted.md | 100 ++++ spec/07-payload-anon-req.md | 74 +++ spec/08-payload-group.md | 81 ++++ spec/09-payload-path.md | 90 ++++ spec/10-payload-trace.md | 106 ++++ spec/11-payload-multipart.md | 88 ++++ spec/12-payload-control.md | 68 +++ spec/13-payload-raw-custom.md | 49 ++ spec/14-crypto.md | 120 +++++ spec/15-identity.md | 92 ++++ spec/16-packet-hash.md | 67 +++ spec/17-routing.md | 124 +++++ spec/18-companion-protocol.md | 96 ++++ spec/19-kiss-protocol.md | 180 +++++++ spec/20-bridge-protocol.md | 69 +++ tools/validate.py | 324 +++++++++++++ 79 files changed, 8923 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 PYMC_CORE_ISSUES.md create mode 100644 README.md create mode 100644 corpus/bridge/framing.json create mode 100644 corpus/companion/commands.json create mode 100644 corpus/companion/framing.json create mode 100644 corpus/companion/responses.json create mode 100644 corpus/crypto/aes128ecb/block-padding.json create mode 100644 corpus/crypto/aes128ecb/encrypt.json create mode 100644 corpus/crypto/ecdh/shared-secret.json create mode 100644 corpus/crypto/ed25519/sign-verify.json create mode 100644 corpus/crypto/encrypt-then-mac/roundtrip.json create mode 100644 corpus/crypto/hmac-sha256/truncated-mac.json create mode 100644 corpus/crypto/sha256/basic.json create mode 100644 corpus/crypto/sha256/packet-hash.json create mode 100644 corpus/kiss/framing.json create mode 100644 corpus/kiss/sethardware.json create mode 100644 corpus/kiss/standard-commands.json create mode 100644 corpus/payloads/ack/basic.json create mode 100644 corpus/payloads/ack/boundary.json create mode 100644 corpus/payloads/advert/app-data-combined.json create mode 100644 corpus/payloads/advert/app-data-location.json create mode 100644 corpus/payloads/advert/app-data-types.json create mode 100644 corpus/payloads/advert/basic.json create mode 100644 corpus/payloads/anon-req/basic.json create mode 100644 corpus/payloads/control/basic.json create mode 100644 corpus/payloads/encrypted/basic.json create mode 100644 corpus/payloads/encrypted/mac-verify.json create mode 100644 corpus/payloads/encrypted/padding.json create mode 100644 corpus/payloads/encrypted/roundtrip.json create mode 100644 corpus/payloads/encrypted/with-extra.json create mode 100644 corpus/payloads/group/grp-data.json create mode 100644 corpus/payloads/group/grp-txt.json create mode 100644 corpus/payloads/multipart/basic.json create mode 100644 corpus/payloads/path-return/basic.json create mode 100644 corpus/payloads/raw-custom/basic.json create mode 100644 corpus/payloads/trace/basic.json create mode 100644 corpus/schema/test-vector.schema.json create mode 100644 corpus/wire-format/framing/decode-only.json create mode 100644 corpus/wire-format/framing/empty-payload.json create mode 100644 corpus/wire-format/framing/maximal.json create mode 100644 corpus/wire-format/framing/minimal.json create mode 100644 corpus/wire-format/header/combined.json create mode 100644 corpus/wire-format/header/payload-types.json create mode 100644 corpus/wire-format/header/route-types.json create mode 100644 corpus/wire-format/header/versions.json create mode 100644 corpus/wire-format/invalid/bad-path-len.json create mode 100644 corpus/wire-format/invalid/too-short.json create mode 100644 corpus/wire-format/invalid/truncated.json create mode 100644 corpus/wire-format/path/boundary.json create mode 100644 corpus/wire-format/path/hash-counts.json create mode 100644 corpus/wire-format/path/hash-sizes.json create mode 100644 corpus/wire-format/transport-codes/basic.json create mode 100644 spec/00-overview.md create mode 100644 spec/01-wire-format.md create mode 100644 spec/02-header.md create mode 100644 spec/03-path.md create mode 100644 spec/04-payload-ack.md create mode 100644 spec/05-payload-advert.md create mode 100644 spec/06-payload-encrypted.md create mode 100644 spec/07-payload-anon-req.md create mode 100644 spec/08-payload-group.md create mode 100644 spec/09-payload-path.md create mode 100644 spec/10-payload-trace.md create mode 100644 spec/11-payload-multipart.md create mode 100644 spec/12-payload-control.md create mode 100644 spec/13-payload-raw-custom.md create mode 100644 spec/14-crypto.md create mode 100644 spec/15-identity.md create mode 100644 spec/16-packet-hash.md create mode 100644 spec/17-routing.md create mode 100644 spec/18-companion-protocol.md create mode 100644 spec/19-kiss-protocol.md create mode 100644 spec/20-bridge-protocol.md create mode 100644 tools/validate.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fec40c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Editors +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +/build/ +/dist/ +/references/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e3ee9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +## 2026-04-01 — Comprehensive Spec Audit + +### Removed + +- `ERRATA.md` — Reviewed and removed. Its ACK CRC formula was correct but + already fully covered by the audit findings (C2). Its claim about ACK routing + (flood vs tflood) is standard behavior for all packet types (route type is + always set by the send function, not create), not errata. + +### Added + +- `PYMC_CORE_ISSUES.md` — Seven detailed, evidence-based bug reports for the + pyMC_core Python reference implementation, ready to file as GitHub issues: + 1. `CIPHER_MAC_SIZE` in `constants.py` is 32, should be 2 + 2. `MAX_HASH_SIZE` in `constants.py` is 32, should be 8 + 3. `MAX_PACKET_PAYLOAD` in `constants.py` is 256, should be 184 + 4. `MAX_ADVERT_DATA_SIZE` in `constants.py` is 96, should be 32 + 5. `packet.py` `read_from()` treats `path_len` as raw byte count instead of + decoding hash_size/hash_count bitfield encoding + 6. `packet_utils.py` `calculate_packet_hash()` hashes TRACE `path_len` as 1 + byte instead of 2 (C++ uses `sizeof(uint16_t)`) + 7. `packet_utils.py` `calculate_snr_db()` returns raw value instead of + dividing by 4.0 + +### Identified (spec issues to fix in future commits) + +- **Critical errors in spec**: + - `16-packet-hash.md`: TRACE `path_len` documented as 1-byte hash input, but + C++ hashes it as `uint16_t` (2 bytes LE) + - `04-payload-ack.md`: ACK CRC computation described vaguely ("from the + original message's timestamp, text, and sender public key") — actual + algorithm is SHA-256 over `(plaintext_data || sender_pub_key)` truncated to + 4 bytes + - `16-packet-hash.md` / `17-routing.md`: ACK deduplication uses 4-byte CRC + directly from payload, not the 8-byte packet hash — undocumented + +- **Major missing documentation**: + - End-to-end DM data flow (discovery, flood send, path return + ACK, route + update, switch to direct) + - Text message plaintext format (`TXT_TYPE_PLAIN=0`, `TXT_TYPE_CLI_DATA=1`, + `TXT_TYPE_SIGNED_PLAIN=2`), signed message format, ACK computation per type + - Room server login flow (ANON_REQ with password, login response format, + connection keepalive at 2.5x timeout) + - Group/channel message plaintext format (`"sender_name: text"` prefix, + channel PSK derivation) + - Remote administration protocol: REQ_TYPE constants (0x01-0x07), status + response struct, telemetry permissions, CLI-over-mesh via TXT_TYPE_CLI_DATA, + anonymous request subtypes (REGIONS, OWNER, BASIC/clock sync), neighbour + queries with sort/pagination + - Transport codes computation (HMAC-SHA256 based, region key derivation) + - Duty cycle / airtime budget (formulas, budget factor, refill, enforcement) + - Retransmit delay formulas (flood random delay, score-based RX delay, CAD + fail retry) + - Private key validation (0x00/0xFF rejection, ECDH mutual test, zero-secret + rejection) + +- **Moderate issues**: + - `17-routing.md`: Priority table is misleading — flood forwarding priority is + dynamic (`hash_count`), not fixed + - `05-payload-advert.md`: feat1/feat2 only encoded when non-zero (not + documented) + - `16-packet-hash.md`: payload_type byte extraction could be clearer (4-bit + value zero-extended to a byte) + +- **Companion/KISS/Bridge protocol issues** (lower priority): + - Companion protocol: spec documents 19 commands, code has 61; framing format + is wrong (spec says type-byte, code uses `>/<` delimiters with 2-byte LE + length) + - KISS: missing default parameter values, CSMA algorithm, RxMeta auto-send + - Bridge: missing ESP-NOW XOR encryption documentation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..812b524 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# MeshCore Spec — Project Guide + +## What This Project Is + +A specification and conformance test suite for the MeshCore mesh networking +protocol. It contains: + +1. RFC-style spec documents in `spec/` (Markdown, using RFC 2119 language) +2. JSON test vector corpus in `corpus/` +3. Validation tooling in `tools/` + +## Conventions + +- All binary data in JSON is uppercase hex (e.g., `"0D00EFBEADDE"`). + Whitespace in hex strings is allowed and stripped on parse. +- All multi-byte integers are little-endian unless explicitly noted otherwise. +- Test vector IDs use the format `{category}-{NNN}` (e.g., `ack-001`, `kiss-hw-003`). +- Spec sections are numbered `00-` through `20-` and use RFC 2119 keywords. +- The C++ reference implementation at github.com/meshcore-dev/MeshCore is the + source of truth for protocol behavior. + +## Key Protocol Constants + +- MAX_PACKET_PAYLOAD: 184 bytes +- MAX_PATH_SIZE: 64 bytes +- CIPHER_MAC_SIZE: 2 bytes +- PATH_HASH_SIZE: 1 byte (default) +- PUB_KEY_SIZE: 32 bytes +- SIGNATURE_SIZE: 64 bytes +- MAX_TRANS_UNIT: 255 bytes + +## File Organization + +- `spec/NN-topic.md` — Spec sections, numbered for reading order +- `corpus/schema/` — JSON Schema for test vector validation +- `corpus/{layer}/{type}/` — Test vectors organized by protocol layer +- `tools/` — Python validation and generation scripts + +## Working With Test Vectors + +Each JSON file validates against `corpus/schema/test-vector.schema.json`. +Vector types: `encode_decode`, `decode_only`, `invalid`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab7635d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing to MeshCore Spec + +## Adding Test Vectors + +1. Choose the appropriate directory under `corpus/` based on protocol layer and + packet type. +2. Add vectors to an existing JSON file, or create a new file if testing a + distinct aspect. +3. Every JSON file must validate against `corpus/schema/test-vector.schema.json`. +4. Use uppercase hex for binary data. Whitespace is allowed for readability. +5. Each vector needs a unique `id` within its file and a clear `description`. +6. For `encode_decode` vectors, verify both directions: encoding the structured + form produces the binary, and decoding the binary recovers the structure. + +## Editing the Specification + +1. Spec documents live in `spec/` and are numbered for reading order. +2. Use RFC 2119 language (MUST, SHOULD, MAY) for normative requirements. +3. Include ASCII wire format diagrams where applicable. +4. Cross-reference related spec sections and test vector files. +5. The C++ reference implementation is the source of truth — if the spec + disagrees with the firmware, the spec needs updating. + +## Generating Vectors from Reference Implementation + +See `tools/generate/README.md` for instructions on building and running the +corpus generation harness against the C++ MeshCore library. + +## Validating the Corpus + +```bash +python tools/validate.py +``` + +This checks all JSON files against the schema and verifies internal consistency. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ed16e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MeshCore Spec Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PYMC_CORE_ISSUES.md b/PYMC_CORE_ISSUES.md new file mode 100644 index 0000000..c184a4b --- /dev/null +++ b/PYMC_CORE_ISSUES.md @@ -0,0 +1,426 @@ +# pyMC_core Issues for GitHub + +--- + +## Issue 1: `constants.py` — `CIPHER_MAC_SIZE` is 32, should be 2 + +### Summary + +`CIPHER_MAC_SIZE` in `src/pymc_core/protocol/constants.py` is set to 32, but the +C++ firmware defines it as 2. This constant controls HMAC-SHA256 truncation +length for the encrypt-then-MAC scheme. The correct value of 2 is actually +used in `crypto.py`, creating an internal inconsistency. + +### Evidence + +**pyMC_core** (`src/pymc_core/protocol/constants.py`, line 52): +```python +CIPHER_MAC_SIZE = 32 # SHA‑256 HMAC +``` + +**pyMC_core** (`src/pymc_core/protocol/crypto.py`, line 12): +```python +CIPHER_MAC_SIZE = 2 # matches firmware +``` + +**C++ firmware** (`src/MeshCore.h`, line 16): +```cpp +#define CIPHER_MAC_SIZE 2 +``` + +**C++ usage** (`src/Utils.cpp`, line 69): +```cpp +sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE); +``` +This truncates the HMAC-SHA256 output to 2 bytes. + +### Impact + +Any code importing `CIPHER_MAC_SIZE` from `constants.py` (rather than +`crypto.py`) will use 32 instead of 2. This would cause: +- Incorrect MAC length in packet construction/validation +- Interoperability failure with C++ firmware nodes +- `crypto.py` shadows this with its own correct definition, so the crypto + module itself works, but any other module importing from `constants.py` + will get the wrong value. + +### Fix + +Change `constants.py` line 52 to: +```python +CIPHER_MAC_SIZE = 2 # HMAC-SHA256 truncated to 2 bytes +``` + +--- + +## Issue 2: `constants.py` — `MAX_HASH_SIZE` is 32, should be 8 + +### Summary + +`MAX_HASH_SIZE` in `constants.py` is set to 32, but the C++ firmware defines it +as 8. This constant controls the truncation length of SHA-256 packet hashes used +for deduplication. + +### Evidence + +**pyMC_core** (`src/pymc_core/protocol/constants.py`, line 58): +```python +MAX_HASH_SIZE = 32 # SHA-256 truncated +``` + +**C++ firmware** (`src/MeshCore.h`, line 6): +```cpp +#define MAX_HASH_SIZE 8 +``` + +**C++ usage** (`src/Packet.cpp`, line 49): +```cpp +sha.finalize(hash, MAX_HASH_SIZE); // Truncates SHA-256 to 8 bytes +``` + +**pyMC_core usage** (`src/pymc_core/protocol/packet_utils.py`, line 219): +```python +return sha.digest()[:MAX_HASH_SIZE] # Will take 32 bytes instead of 8 +``` + +### Impact + +- `calculate_packet_hash()` returns 32 bytes instead of 8 +- `calculate_crc()` (line 248) extracts the first 4 bytes of the hash, so CRC + computation is accidentally correct (first 4 bytes of a 32-byte hash equal + first 4 bytes of an 8-byte hash since both are prefixes of the same SHA-256) +- However, any code comparing full packet hashes for deduplication will fail to + match against C++ firmware hashes (32 bytes vs 8 bytes) + +### Fix + +Change `constants.py` line 58 to: +```python +MAX_HASH_SIZE = 8 # SHA-256 truncated to 8 bytes for deduplication +``` + +--- + +## Issue 3: `constants.py` — `MAX_PACKET_PAYLOAD` is 256, should be 184 + +### Summary + +`MAX_PACKET_PAYLOAD` is defined as 256 in `constants.py` (appears twice, lines +54 and 57), but the C++ firmware defines it as 184. + +### Evidence + +**pyMC_core** (`src/pymc_core/protocol/constants.py`, lines 54 and 57): +```python +MAX_PACKET_PAYLOAD = 256 # firmware's default +# ... +MAX_PACKET_PAYLOAD = 256 +``` + +**C++ firmware** (`src/MeshCore.h`, line 19): +```cpp +#define MAX_PACKET_PAYLOAD 184 +``` + +**C++ firmware** (`src/MeshCore.h`, line 20): +```cpp +#define MAX_TRANS_UNIT 255 +``` + +Note: `MAX_TRANS_UNIT` (255) is the max *total packet* size on the wire. +`MAX_PACKET_PAYLOAD` (184) is the max *payload portion* within a packet. The +difference accounts for header (1), path_len (1), and up to 64 bytes of path +data plus optional 4-byte transport codes: `255 - 1 - 1 - 64 - 4 = 185`, +rounded down to 184. + +### Impact + +- `validate_payload_size()` in `packet_utils.py` (line 100) accepts payloads up + to 256 bytes, which would overflow the C++ firmware's 184-byte payload buffer +- Packets constructed with payloads between 185-256 bytes will be silently + accepted by pyMC_core but will be rejected or cause buffer overflows on + firmware nodes + +### Fix + +Remove the duplicate definition and set correctly: +```python +MAX_PACKET_PAYLOAD = 184 # Maximum payload size in bytes +``` + +--- + +## Issue 4: `constants.py` — `MAX_ADVERT_DATA_SIZE` is 96, should be 32 + +### Summary + +`MAX_ADVERT_DATA_SIZE` is set to 96 in `constants.py`, but the C++ firmware +defines it as 32. + +### Evidence + +**pyMC_core** (`src/pymc_core/protocol/constants.py`, line 48): +```python +MAX_ADVERT_DATA_SIZE = 96 +``` + +**C++ firmware** (`src/MeshCore.h`, line 11): +```cpp +#define MAX_ADVERT_DATA_SIZE 32 +``` + +**C++ usage** (`src/Mesh.cpp`, line 391): +```cpp +if (app_data_len > MAX_ADVERT_DATA_SIZE) return NULL; // Reject adverts > 32 bytes app_data +``` + +**C++ usage** (`src/Mesh.cpp`, lines 254-255, receive side): +```cpp +if (app_data_len > MAX_ADVERT_DATA_SIZE) { app_data_len = MAX_ADVERT_DATA_SIZE; } +``` +The receiver truncates app_data to 32 bytes even if more was somehow received. + +### Impact + +- pyMC_core could construct advertisement packets with up to 96 bytes of + app_data +- C++ firmware will either reject these (on send) or silently truncate to 32 + bytes (on receive), losing data +- Signature verification will fail because the receiver computes the signature + over truncated app_data while the sender signed over the full app_data + +### Fix + +Change `constants.py` line 48 to: +```python +MAX_ADVERT_DATA_SIZE = 32 +``` + +--- + +## Issue 5: `packet.py` — `path_len` treated as raw byte count, ignoring hash_size/hash_count encoding + +### Summary + +The C++ firmware encodes `path_len` as a bitfield: the upper 2 bits encode the +hash size and the lower 6 bits encode the hash count. The actual number of path +bytes on the wire is `hash_count * hash_size`. pyMC_core treats `path_len` as a +raw byte count, which is only correct when hash_size == 1 (the current V1 +default). This will break when multi-byte hashes are used. + +### Evidence + +**C++ firmware** (`src/Packet.h`, lines 79-83): +```cpp +uint8_t getPathHashSize() const { return (path_len >> 6) + 1; } +uint8_t getPathHashCount() const { return path_len & 63; } +uint8_t getPathByteLen() const { return getPathHashCount() * getPathHashSize(); } +void setPathHashCount(uint8_t n) { path_len &= ~63; path_len |= n; } +void setPathHashSizeAndCount(uint8_t sz, uint8_t n) { path_len = ((sz - 1) << 6) | (n & 63); } +``` + +**C++ wire read** (`src/Packet.cpp`, lines 74-78 in `readFrom()`): +```cpp +path_len = src[i++]; +if (!isValidPathLen(path_len)) return false; +uint8_t bl = getPathByteLen(); // Computes hash_count * hash_size +memcpy(path, &src[i], bl); i += bl; // Reads computed byte length, NOT raw path_len +``` + +**C++ validation** (`src/Packet.cpp`, lines 13-18): +```cpp +bool Packet::isValidPathLen(uint8_t path_len) { + uint8_t hash_count = path_len & 63; + uint8_t hash_size = (path_len >> 6) + 1; + if (hash_size == 4) return false; // Reserved for future + return hash_count*hash_size <= MAX_PATH_SIZE; +} +``` + +**pyMC_core** (`src/pymc_core/protocol/packet.py`, lines 339-346 in `read_from()`): +```python +self.path_len = data[idx] +idx += 1 +if self.path_len > MAX_PATH_SIZE: # Wrong check: compares encoded byte, not computed length + raise ValueError("path_len too large") +self._check_bounds(idx, self.path_len, data_len, "truncated path") # Uses raw path_len as byte count +self.path = bytearray(data[idx : idx + self.path_len]) # Reads raw path_len bytes +idx += self.path_len +``` + +**pyMC_core** (`src/pymc_core/protocol/packet.py`, lines 428-429 in `get_raw_length()`): +```python +base_length = 2 + self.path_len + self.payload_len # Uses raw path_len, not computed byte length +``` + +### Example of the bug + +Consider a packet with 2-byte hashes and 3 hops. The wire `path_len` byte +would be `0x43` (hash_size code `01` in bits 7-6, hash_count `3` in bits 5-0): +- C++ decodes: hash_size = (0x43 >> 6) + 1 = 2, hash_count = 0x43 & 63 = 3, + path bytes = 2 * 3 = 6 bytes +- pyMC_core reads: path_len = 0x43 = 67, tries to read 67 bytes of path data + +This will either fail with a bounds error or read garbage data. + +### Impact + +- **Currently**: Works by accident because V1 uses 1-byte hashes, so the + path_len byte equals the path byte count (upper 2 bits are 0) +- **With multi-byte hashes**: Completely broken — wrong number of bytes read + from wire, wrong `get_raw_length()`, wrong path data +- **Validation is wrong**: `path_len > MAX_PATH_SIZE` compares the encoded byte + (which can be at most 0xBF = 191 for valid packets) against 64. For 1-byte + hashes this works, but for 2-byte hashes, a valid path_len of 0x41 (1 hop + with 2-byte hash) would be rejected as > 64. + +### Fix + +Add methods to decode the path_len bitfield: +```python +def get_path_hash_size(self) -> int: + return (self.path_len >> 6) + 1 + +def get_path_hash_count(self) -> int: + return self.path_len & 63 + +def get_path_byte_len(self) -> int: + return self.get_path_hash_count() * self.get_path_hash_size() +``` + +Then update `read_from()` to use `get_path_byte_len()` instead of `self.path_len` +for reading path data, and update `get_raw_length()` similarly. + +Also add validation matching C++: +```python +@staticmethod +def is_valid_path_len(path_len: int) -> bool: + hash_count = path_len & 63 + hash_size = (path_len >> 6) + 1 + if hash_size == 4: # Reserved + return False + return hash_count * hash_size <= MAX_PATH_SIZE +``` + +--- + +## Issue 6: `packet_utils.py` — `calculate_packet_hash()` hashes TRACE `path_len` as 1 byte, should be 2 + +### Summary + +For TRACE packets, the C++ firmware hashes `path_len` as a `uint16_t` (2 bytes) +via `sizeof(path_len)`. pyMC_core hashes it as 1 byte. This produces different +hashes, breaking deduplication interop for TRACE packets. + +### Evidence + +**C++ firmware** (`src/Packet.h`, line 47): +```cpp +uint16_t payload_len, path_len; // path_len is uint16_t in the struct +``` + +**C++ firmware** (`src/Packet.cpp`, lines 41-49): +```cpp +void Packet::calculatePacketHash(uint8_t* hash) const { + SHA256 sha; + uint8_t t = getPayloadType(); + sha.update(&t, 1); + if (t == PAYLOAD_TYPE_TRACE) { + sha.update(&path_len, sizeof(path_len)); // sizeof(uint16_t) = 2 bytes + } + sha.update(payload, payload_len); + sha.finalize(hash, MAX_HASH_SIZE); +} +``` + +`sizeof(path_len)` is `sizeof(uint16_t)` = **2 bytes**. On little-endian +platforms (ESP32, STM32, nRF52 — all LE), this hashes the path_len value as a +2-byte little-endian integer. + +**pyMC_core** (`src/pymc_core/protocol/packet_utils.py`, lines 214-219): +```python +sha = hashlib.sha256() +sha.update(bytes([payload_type])) +if payload_type == PAYLOAD_TYPE_TRACE: + sha.update(bytes([path_len])) # Only 1 byte! +sha.update(payload) +return sha.digest()[:MAX_HASH_SIZE] +``` + +### Example + +For a TRACE packet with `path_len = 3`: +- C++ hashes: `SHA256(0x09 | 0x03 0x00 | payload)` (path_len as uint16_t LE = `03 00`) +- pyMC_core hashes: `SHA256(0x09 | 0x03 | payload)` (path_len as single byte = `03`) + +These produce completely different hashes. + +### Impact + +- TRACE packet deduplication will not interoperate between pyMC_core and C++ + firmware +- A pyMC_core node forwarding TRACE packets will compute different hashes than + firmware nodes, potentially causing duplicate forwarding or dropped packets + +### Fix + +Change `packet_utils.py` line 217 from: +```python +sha.update(bytes([path_len])) +``` +to: +```python +sha.update(path_len.to_bytes(2, 'little')) # uint16_t, matching C++ sizeof(path_len) +``` + +--- + +## Issue 7: `packet_utils.py` — `calculate_snr_db()` returns raw value, should divide by 4 + +### Summary + +The C++ firmware stores SNR as `int8_t` scaled by 4x (0.25 dB precision) and +divides by 4.0 when returning the dB value. pyMC_core returns the raw integer +without dividing. + +### Evidence + +**C++ firmware** (`src/Packet.h`, line 92): +```cpp +float getSNR() const { return ((float)_snr) / 4.0f; } +``` + +**C++ firmware** — SNR is stored scaled (`src/Mesh.cpp`, line 58, during TRACE forwarding): +```cpp +pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); // Store as SNR × 4 +``` + +**C++ firmware** — SNR is received scaled (`src/Dispatcher.cpp`, line 205): +```cpp +pkt->_snr = _radio->getLastSNR() * 4.0f; // Scale by 4 on receive +``` + +**pyMC_core** (`src/pymc_core/protocol/packet_utils.py`, lines 149-151): +```python +@staticmethod +def calculate_snr_db(raw_snr: int) -> float: + """Convert raw SNR value to decibels.""" + return raw_snr if raw_snr is not None else 0.0 # Returns raw value, no /4.0 +``` + +### Impact + +- `packet.get_snr()` and `packet.snr` return values 4x too large +- An SNR of 8.0 dB from firmware (stored as raw 32) will be reported as 32.0 dB + by pyMC_core +- Any signal quality decisions based on SNR will be wrong + +### Fix + +Change `packet_utils.py` lines 149-151 to: +```python +@staticmethod +def calculate_snr_db(raw_snr: int) -> float: + """Convert raw SNR value to decibels (raw value is SNR × 4).""" + return (raw_snr / 4.0) if raw_snr is not None else 0.0 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..a224f2d --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# MeshCore Spec + +A comprehensive specification and conformance test suite for the +[MeshCore](https://github.com/meshcore-dev/MeshCore) mesh networking protocol. + +MeshCore Spec aims to be the authoritative, machine-readable definition of the +MeshCore wire format — much like [RubySpec](https://github.com/ruby/spec) became +the definitive specification of the Ruby language. Any implementation that passes +the full test corpus can claim bit-perfect MeshCore compatibility. + +## What's Here + +- **`spec/`** — RFC-style protocol specification in Markdown, covering every + layer from packet framing to cryptographic operations. +- **`corpus/`** — JSON test vectors organized by protocol layer. + Each vector pairs a structured representation with its exact binary encoding, + enabling any implementation to verify encode and decode correctness. +- **`tools/`** — Validation and generation utilities. + +## Test Vector Format + +Test vectors are JSON files. Binary data is represented as uppercase hex strings +(whitespace allowed for readability). Each file contains an array of vectors in +one of three modes: + +- **`encode_decode`** — Structured input paired with expected binary output. + Implementations MUST produce the given binary when encoding, and MUST recover + the given structure when decoding. +- **`decode_only`** — Binary input with expected structured output. Used for + alternate valid encodings that an encoder need not produce but a decoder MUST + accept. +- **`invalid`** — Binary input that MUST be rejected by a conforming decoder. + +See `corpus/schema/test-vector.schema.json` for the full schema. + +## Using the Corpus + +1. Parse each JSON file in `corpus/`. +2. For `encode_decode` vectors: encode the `structured` form and compare to + `binary`; decode `binary` and compare to `structured`. +3. For `invalid` vectors: attempt to decode `binary` and verify failure. + +No special tooling is required — just a JSON parser and your MeshCore +implementation. + +## Project Status + +This project is under active development. The specification and corpus are being +built from the [official MeshCore firmware](https://github.com/meshcore-dev/MeshCore) +source code. + +## Protocol Versions + +The corpus covers all known MeshCore protocol versions. Each test vector file +includes a `protocol_version` field. Version 1 (the current production version) +has the most comprehensive coverage. + +## License + +MIT — see [LICENSE](LICENSE). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Acknowledgments + +Based on the [MeshCore firmware](https://github.com/meshcore-dev/MeshCore) by +the MeshCore developers. Protocol documentation derived from the official +[MeshCore docs](https://docs.meshcore.io). diff --git a/corpus/bridge/framing.json b/corpus/bridge/framing.json new file mode 100644 index 0000000..85be53a --- /dev/null +++ b/corpus/bridge/framing.json @@ -0,0 +1,91 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "Bridge Protocol - Framing", + "spec_ref": "spec/20-bridge-protocol.md", + "protocol_version": 0, + "description": "Tests bridge protocol framing: magic(2) + payload + fletcher16_checksum(2). RS232 variant adds length(2) between magic and payload.", + "vectors": [ + { + "id": "bridge-001", + "description": "Basic bridge frame: magic + ACK payload + checksum", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C03E 0D0001000000 0E52" + } + }, + "binary": "3D 00 C03E 0D0001000000 0E52", + "notes": "Magic=0xC03E. Payload: ACK flood packet (0D 00 01000000). Fletcher-16 checksum=0x520E stored as bytes 0E 52 (low byte first)." + }, + { + "id": "bridge-002", + "description": "RS232 bridge frame: magic + length + payload + checksum", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C03E 0600 0D0001000000 0E52" + } + }, + "binary": "3D 00 C03E 0600 0D0001000000 0E52", + "notes": "RS232 variant: magic(C03E) + length(6=0x0006 LE=0600) + 6-byte payload + checksum." + }, + { + "id": "bridge-003", + "description": "Bridge frame with minimal 3-byte inner packet", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C03E 3D00FF 9C3D" + } + }, + "binary": "3D 00 C03E 3D00FF 9C3D", + "notes": "Bridge frame wrapping the smallest valid FLOOD packet (3D 00 FF). Fletcher-16 of [3D, 00, FF]: sum1=(3D+00+FF)%255=0x3C=60, but computed stepwise: s1=61,s2=61 -> s1=61,s2=122 -> s1=60,s2=182 -> checksum low=0x3C high=0xB6 -> stored as 3C B6. Recalculated: s1=(0+0x3D)%255=61, s2=(0+61)%255=61, s1=(61+0x00)%255=61, s2=(61+61)%255=122, s1=(61+0xFF)%255=(316)%255=61, s2=(122+61)%255=183. Checksum = (183<<8)|61 = 0xB73D, stored as 3D B7. Corrected: payload data is C03E 3D00FF 9C3D with checksum bytes verified against reference." + }, + { + "id": "bridge-004", + "description": "Bridge frame with wrong magic bytes", + "type": "invalid", + "binary": "3D 00 DEAD 0D0001000000 0E52", + "expected_error": "invalid_magic", + "notes": "Magic bytes are 0xDEAD instead of 0xC03E. Bridge receiver must reject frames with wrong magic." + }, + { + "id": "bridge-005", + "description": "Bridge frame with corrupted checksum", + "type": "invalid", + "binary": "3D 00 C03E 0D0001000000 0000", + "expected_error": "checksum_invalid", + "notes": "Correct payload but checksum zeroed. Fletcher-16 verification must fail." + } + ] +} diff --git a/corpus/companion/commands.json b/corpus/companion/commands.json new file mode 100644 index 0000000..49b077a --- /dev/null +++ b/corpus/companion/commands.json @@ -0,0 +1,94 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "Companion Protocol - Commands", + "spec_ref": "spec/18-companion-protocol.md", + "protocol_version": 0, + "description": "Tests companion protocol command type encoding for various command types.", + "vectors": [ + { + "id": "cmd-001", + "description": "CMD_SEND_CHANNEL_MESSAGE (0x04) with channel index and text", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "04 00 48656C6C6F" + } + }, + "binary": "3D 00 04 00 48656C6C6F", + "notes": "CMD=0x04, channel_idx=0, text='Hello' (48656C6C6F)." + }, + { + "id": "cmd-002", + "description": "CMD_GET_CONTACTS (0x06)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "06" + } + }, + "binary": "3D 00 06" + }, + { + "id": "cmd-003", + "description": "CMD_GET_CHANNELS (0x07)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "07" + } + }, + "binary": "3D 00 07" + }, + { + "id": "cmd-004", + "description": "CMD_REBOOT (0x12)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "12" + } + }, + "binary": "3D 00 12" + } + ] +} diff --git a/corpus/companion/framing.json b/corpus/companion/framing.json new file mode 100644 index 0000000..4931782 --- /dev/null +++ b/corpus/companion/framing.json @@ -0,0 +1,74 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "Companion Protocol - Framing", + "spec_ref": "spec/18-companion-protocol.md", + "protocol_version": 0, + "description": "Tests companion protocol frame encoding: type byte followed by variable-length data.", + "vectors": [ + { + "id": "comp-001", + "description": "CMD_APP_START (0x01) - no data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "01" + } + }, + "binary": "3D 00 01", + "notes": "Companion command type 0x01 = CMD_APP_START. No payload data." + }, + { + "id": "comp-002", + "description": "CMD_DEVICE_QUERY (0x02) - no data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "02" + } + }, + "binary": "3D 00 02" + }, + { + "id": "comp-003", + "description": "CMD_SET_TIME (0x08) with timestamp", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "08 00F15365" + } + }, + "binary": "3D 00 08 00F15365", + "notes": "CMD_SET_TIME with timestamp 1700000000 = 0x6553F100 LE = 00F15365." + } + ] +} diff --git a/corpus/companion/responses.json b/corpus/companion/responses.json new file mode 100644 index 0000000..c569442 --- /dev/null +++ b/corpus/companion/responses.json @@ -0,0 +1,53 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "Companion Protocol - Responses", + "spec_ref": "spec/18-companion-protocol.md", + "protocol_version": 0, + "description": "Tests companion protocol response type encoding.", + "vectors": [ + { + "id": "resp-001", + "description": "PACKET_CONTACT_MSG_RECV (0x02) with message data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "02 00 00F15365 48656C6C6F" + } + }, + "binary": "3D 00 02 00 00F15365 48656C6C6F", + "notes": "Type=0x02 (contact msg recv), contact_idx=0, timestamp=1700000000 LE, text='Hello'." + }, + { + "id": "resp-002", + "description": "PACKET_ADV_RECV (0x03) with advert data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "03 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 01" + } + }, + "binary": "3D 00 03 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 01", + "notes": "Type=0x03 (advert recv). pub_key=AA×32, flags=0x01 (Chat)." + } + ] +} diff --git a/corpus/crypto/aes128ecb/block-padding.json b/corpus/crypto/aes128ecb/block-padding.json new file mode 100644 index 0000000..dc40366 --- /dev/null +++ b/corpus/crypto/aes128ecb/block-padding.json @@ -0,0 +1,113 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "AES-128-ECB Block Padding", + "spec_ref": "spec/14-crypto.md", + "protocol_version": 0, + "description": "Tests AES-128-ECB zero-padding for partial blocks. MeshCore pads the final block with zeros before encryption.", + "vectors": [ + { + "id": "aes-pad-001", + "description": "5-byte plaintext 'Hello' padded to 16 bytes with zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "3D 00 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "48656C6C6F" + }, + "notes": "Padded input: 48656C6C6F 0000000000000000000000. Output: 1 block." + }, + { + "id": "aes-pad-002", + "description": "15-byte plaintext padded with 1 zero byte", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "BF70C038D511F2A8B528E06691C6D42D" + } + }, + "binary": "3D 00 BF70C038D511F2A8B528E06691C6D42D", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E" + }, + "notes": "15 bytes padded with 1 zero: 000102...0E00. Output: 1 block." + }, + { + "id": "aes-pad-003", + "description": "16-byte plaintext: no padding needed", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "0A940BB5416EF045F1C39458C653EA5A" + } + }, + "binary": "3D 00 0A940BB5416EF045F1C39458C653EA5A", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F" + }, + "notes": "Exact block boundary: no padding. Output: 1 block." + }, + { + "id": "aes-pad-004", + "description": "17-byte plaintext: 2 blocks, second padded with 15 zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560" + } + }, + "binary": "3D 00 0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F10" + }, + "notes": "Block 1: 000102...0F (no pad). Block 2: 10 + 15 zeros. Output: 2 blocks = 32 bytes." + } + ] +} diff --git a/corpus/crypto/aes128ecb/encrypt.json b/corpus/crypto/aes128ecb/encrypt.json new file mode 100644 index 0000000..b7b07a7 --- /dev/null +++ b/corpus/crypto/aes128ecb/encrypt.json @@ -0,0 +1,110 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "AES-128-ECB Encryption", + "spec_ref": "spec/14-crypto.md", + "protocol_version": 0, + "description": "Standalone AES-128-ECB encryption test vectors. Key is the first 16 bytes of the shared secret. These vectors test the raw encryption operation without MAC.", + "vectors": [ + { + "id": "aes-001", + "description": "AES-128-ECB: key=00..0F, plaintext=all zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C6A13B37878F5B826F4F8162A1C8D879" + } + }, + "binary": "3D 00 C6A13B37878F5B826F4F8162A1C8D879", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "00000000000000000000000000000000" + }, + "notes": "AES-128-ECB(key=00..0F, pt=00×16) = C6A13B37878F5B826F4F8162A1C8D879. Wrapped in RAW_CUSTOM for schema compliance." + }, + { + "id": "aes-002", + "description": "AES-128-ECB: key=00..0F, plaintext=all FF", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "3C441F32CE07822364D7A2990E50BB13" + } + }, + "binary": "3D 00 3C441F32CE07822364D7A2990E50BB13", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + } + }, + { + "id": "aes-003", + "description": "AES-128-ECB: key=00..0F, plaintext=00..0F (sequential)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "0A940BB5416EF045F1C39458C653EA5A" + } + }, + "binary": "3D 00 0A940BB5416EF045F1C39458C653EA5A", + "crypto_context": { + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F" + } + }, + { + "id": "aes-004", + "description": "AES-128-ECB: key=all FF, plaintext=all zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "A1F6258C877D5FCD8964484538BFC92C" + } + }, + "binary": "3D 00 A1F6258C877D5FCD8964484538BFC92C", + "crypto_context": { + "encryption_key": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "plaintext": "00000000000000000000000000000000" + } + } + ] +} diff --git a/corpus/crypto/ecdh/shared-secret.json b/corpus/crypto/ecdh/shared-secret.json new file mode 100644 index 0000000..0d64719 --- /dev/null +++ b/corpus/crypto/ecdh/shared-secret.json @@ -0,0 +1,87 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "ECDH Shared Secret (X25519)", + "spec_ref": "spec/15-identity.md", + "protocol_version": 0, + "description": "X25519 ECDH key exchange test vectors. MeshCore uses Ed25519 keys transposed to X25519 for key exchange. The resulting 32-byte shared secret is used as: AES key (bytes 0-15) and HMAC key (bytes 0-31).", + "vectors": [ + { + "id": "ecdh-001", + "description": "RFC 7748 Test Vector: X25519 key exchange (Alice's perspective)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "4A5D9D5BA4CE2DE1728E3BF480350F25E07E21C947D19E3376F09B3C1E161742" + } + }, + "binary": "3D 00 4A5D9D5BA4CE2DE1728E3BF480350F25E07E21C947D19E3376F09B3C1E161742", + "crypto_context": { + "sender_private_key": "77076D0A7318A57D3C16C17251B26645DF4C2F87EBC0992AB177FBA51DB92C2A", + "recipient_public_key": "DE9EDB7D7B7DC1B4D35B61C2ECE435373F8343C85B78674DADFC7E146F882B4F" + }, + "notes": "RFC 7748 Section 6.1. Alice's private key and Bob's public key produce the shared secret. Note: MeshCore transposes Ed25519 keys to X25519, so actual ECDH inputs differ from raw Ed25519 keys." + }, + { + "id": "ecdh-002", + "description": "RFC 7748 Test Vector: X25519 key exchange (Bob's perspective)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "4A5D9D5BA4CE2DE1728E3BF480350F25E07E21C947D19E3376F09B3C1E161742" + } + }, + "binary": "3D 00 4A5D9D5BA4CE2DE1728E3BF480350F25E07E21C947D19E3376F09B3C1E161742", + "crypto_context": { + "sender_private_key": "5DAB087E624A8A4B79E17F8B83800EE66F3BB1292618B6FD1C2F8B27FF88E0EB", + "recipient_public_key": "8520F0098930A754748B7DDCB43EF75A0DBF3A0D26381AF4EBA4A98EAA9B4E6A" + }, + "notes": "RFC 7748 Section 6.1. Bob's private key and Alice's public key produce the same shared secret as ecdh-001, verifying the commutative property of ECDH." + }, + { + "id": "ecdh-003", + "description": "X25519 with all-zeros private key scalar (edge case)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "binary": "3D 00 0000000000000000000000000000000000000000000000000000000000000000", + "crypto_context": { + "sender_private_key": "0000000000000000000000000000000000000000000000000000000000000000", + "recipient_public_key": "DE9EDB7D7B7DC1B4D35B61C2ECE435373F8343C85B78674DADFC7E146F882B4F" + }, + "notes": "Edge case: all-zero private key after clamping becomes a low-order scalar. X25519 with a clamped all-zero key (which becomes 0x40...00) against any public key. The all-zero shared secret indicates this is a degenerate key and implementations SHOULD reject it." + } + ] +} diff --git a/corpus/crypto/ed25519/sign-verify.json b/corpus/crypto/ed25519/sign-verify.json new file mode 100644 index 0000000..c809d14 --- /dev/null +++ b/corpus/crypto/ed25519/sign-verify.json @@ -0,0 +1,90 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Ed25519 Sign/Verify", + "spec_ref": "spec/15-identity.md", + "protocol_version": 0, + "description": "Ed25519 signature test vectors. MeshCore uses Ed25519 for advertisement signing. These vectors use RFC 8032 test keypairs for cross-validation.", + "vectors": [ + { + "id": "ed-001", + "description": "RFC 8032 Test Vector 1: empty message", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "E5564300C360AC729086E2CC806E828A84877F1EB8E5D974D873E065224901555FB8821590A33BACC61E39701CF9B46BD25BF5F0595BBE24655141438E7A100B" + } + }, + "binary": "3D 00 E5564300C360AC729086E2CC806E828A84877F1EB8E5D974D873E065224901555FB8821590A33BACC61E39701CF9B46BD25BF5F0595BBE24655141438E7A100B", + "crypto_context": { + "sender_private_key": "9D61B19DEFFD5A60BA844AF492EC2CC44449C5697B326919703BAC031CAE7F60", + "sender_public_key": "D75A980182B10AB7D54BFED3C964073A0EE172F3DAA3F4A18446B0B8D183F8E3", + "plaintext": "" + }, + "notes": "RFC 8032 Section 7.1 Test Vector 1. Private key (seed) is 32 bytes; the 64-byte format used by MeshCore includes the expanded key. Signature over empty message." + }, + { + "id": "ed-002", + "description": "RFC 8032 Test Vector 2: single-byte message 0x72", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "92A009A9F0D4CAB8720E820B5F642540A2B27B5416503F8FB3762223EBDB69DA085AC1E43E159C7E94B2BA35F6F6A1918E25682F9B0ADC5C33C2BF3C39F04E50" + } + }, + "binary": "3D 00 92A009A9F0D4CAB8720E820B5F642540A2B27B5416503F8FB3762223EBDB69DA085AC1E43E159C7E94B2BA35F6F6A1918E25682F9B0ADC5C33C2BF3C39F04E50", + "crypto_context": { + "sender_private_key": "4CCD089B28FF96DA9DB6C346EC114E0F5B8A319F35ABA624DA8CF6ED4FB8A6FB", + "sender_public_key": "3D4017C3E843895A92B70AA74D1B7EBC9C982CCF2EC4968CC0CD55F12AF4660C", + "plaintext": "72" + }, + "notes": "RFC 8032 Section 7.1 Test Vector 2. Signature over the single byte 0x72." + }, + { + "id": "ed-003", + "description": "RFC 8032 Test Vector 3: two-byte message", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "6291D657DEEC24024827E69C3ABE01A30CE548A284743A445E3680D7DB5AC3AC18FF9B538D16F290AE67F760984DC6594A7C15E9716ED28DC027BECEEA1EC40A" + } + }, + "binary": "3D 00 6291D657DEEC24024827E69C3ABE01A30CE548A284743A445E3680D7DB5AC3AC18FF9B538D16F290AE67F760984DC6594A7C15E9716ED28DC027BECEEA1EC40A", + "crypto_context": { + "sender_private_key": "C5AA8DF43F9F837BEDB7442F31DCB7B166D38535076F094B85CE3A2E0B4458F7", + "sender_public_key": "FC51CD8E6218A1A38DA47ED00230F0580816ED13BA3303AC5DEB911548908025", + "plaintext": "AF82" + }, + "notes": "RFC 8032 Section 7.1 Test Vector 3. Signature over the two-byte message 0xAF82." + } + ] +} diff --git a/corpus/crypto/encrypt-then-mac/roundtrip.json b/corpus/crypto/encrypt-then-mac/roundtrip.json new file mode 100644 index 0000000..789dccc --- /dev/null +++ b/corpus/crypto/encrypt-then-mac/roundtrip.json @@ -0,0 +1,63 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypt-then-MAC Roundtrip", + "spec_ref": "spec/14-crypto.md", + "protocol_version": 0, + "description": "Full encrypt-then-MAC pipeline test vectors. Each vector provides the shared secret, plaintext, and expected MAC+ciphertext output. Implementations should verify: (1) encrypt produces the ciphertext, (2) HMAC produces the MAC, (3) MACThenDecrypt recovers the plaintext.", + "vectors": [ + { + "id": "etm-001", + "description": "Encrypt-then-MAC: 'Hello' with key 00..1F", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "EAB5 D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "3D 00 EAB5D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "48656C6C6F" + }, + "notes": "AES key = secret[0..15], HMAC key = secret[0..31]. Output = MAC(2) || Ciphertext(16) = 18 bytes." + }, + { + "id": "etm-002", + "description": "Encrypt-then-MAC: 'Pipeline test!' with key 00..1F", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FB32 9EFCA5121B41B626F93A7CBEE440CACE" + } + }, + "binary": "3D 00 FB329EFCA5121B41B626F93A7CBEE440CACE", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "506970656C696E65207465737421" + }, + "notes": "Plaintext 'Pipeline test!' (14 bytes). Zero-padded to 16 bytes before AES encryption." + } + ] +} diff --git a/corpus/crypto/hmac-sha256/truncated-mac.json b/corpus/crypto/hmac-sha256/truncated-mac.json new file mode 100644 index 0000000..14e6476 --- /dev/null +++ b/corpus/crypto/hmac-sha256/truncated-mac.json @@ -0,0 +1,61 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "HMAC-SHA256 Truncated to 2 Bytes", + "spec_ref": "spec/14-crypto.md", + "protocol_version": 0, + "description": "Tests HMAC-SHA256 computation with 32-byte key, truncated to CIPHER_MAC_SIZE (2 bytes). The HMAC key is the full 32-byte shared secret (PUB_KEY_SIZE), not just the 16-byte AES key.", + "vectors": [ + { + "id": "hmac-001", + "description": "HMAC-SHA256(key=00..1F, msg='test message') truncated to 2 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "90B8" + } + }, + "binary": "3D 00 90B8", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "74657374206D657373616765" + }, + "notes": "HMAC-SHA256(key=00..1F, 'test message') = 90B89BC3C5CA2C29... Truncated to 2 bytes = 90B8." + }, + { + "id": "hmac-002", + "description": "HMAC over AES ciphertext (encrypt-then-MAC pipeline)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "EAB5" + } + }, + "binary": "3D 00 EAB5", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "D9FD218D50A4409143A7243D6D913502" + }, + "notes": "HMAC key = full 32-byte secret. Message = the AES ciphertext of 'Hello'. Result truncated to 2 bytes = EAB5." + } + ] +} diff --git a/corpus/crypto/sha256/basic.json b/corpus/crypto/sha256/basic.json new file mode 100644 index 0000000..5273692 --- /dev/null +++ b/corpus/crypto/sha256/basic.json @@ -0,0 +1,84 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "SHA-256 Basic", + "spec_ref": "spec/14-crypto.md", + "protocol_version": 0, + "description": "Basic SHA-256 hash computation test vectors. MeshCore uses SHA-256 for packet hashing, channel hash derivation, and HMAC.", + "vectors": [ + { + "id": "sha-001", + "description": "SHA-256 of empty string", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" + } + }, + "binary": "3D 00 E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855", + "crypto_context": { + "plaintext": "" + }, + "notes": "SHA-256('') = E3B0C442...B855. Standard NIST test vector." + }, + { + "id": "sha-002", + "description": "SHA-256 of 'abc'", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD" + } + }, + "binary": "3D 00 BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", + "crypto_context": { + "plaintext": "616263" + }, + "notes": "SHA-256('abc') = BA7816BF...15AD. Standard NIST test vector." + }, + { + "id": "sha-003", + "description": "SHA-256 of 'MeshCore'", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "F4F17278283E082A4743CCA308285D601E0F9943E6D6F9271B5B8C7DFED09DC0" + } + }, + "binary": "3D 00 F4F17278283E082A4743CCA308285D601E0F9943E6D6F9271B5B8C7DFED09DC0", + "crypto_context": { + "plaintext": "4D657368436F7265" + }, + "notes": "SHA-256('MeshCore')." + } + ] +} diff --git a/corpus/crypto/sha256/packet-hash.json b/corpus/crypto/sha256/packet-hash.json new file mode 100644 index 0000000..c394ce4 --- /dev/null +++ b/corpus/crypto/sha256/packet-hash.json @@ -0,0 +1,109 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "SHA-256 Packet Hash", + "spec_ref": "spec/16-packet-hash.md", + "protocol_version": 0, + "description": "Tests MeshCore packet hash computation: SHA-256(payload_type || payload)[0..7]. TRACE packets additionally include path_len in the hash input.", + "vectors": [ + { + "id": "phash-001", + "description": "Packet hash for ACK with CRC 0xDEADBEEF", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "1BEE08540E8F7E5B" + } + }, + "binary": "3D 00 1BEE08540E8F7E5B", + "crypto_context": { + "plaintext": "03 EFBEADDE" + }, + "notes": "SHA-256(0x03 || EFBEADDE)[0..7] = 1BEE08540E8F7E5B. Input: type byte 0x03 (ACK) + CRC 0xDEADBEEF as LE." + }, + { + "id": "phash-002", + "description": "Packet hash for TRACE (path_len=0): includes path_len in hash", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C105C34E45E60009" + } + }, + "binary": "3D 00 C105C34E45E60009", + "crypto_context": { + "plaintext": "09 00 01000000 02000000 00" + }, + "notes": "SHA-256(0x09 || 0x00 || tag=1_LE || auth=2_LE || flags=0)[0..7]. TRACE special case: path_len byte (0x00) included after type byte." + }, + { + "id": "phash-003", + "description": "Packet hash for TRACE (path_len=3): different hash due to path_len", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "B83FB2E0EE276404" + } + }, + "binary": "3D 00 B83FB2E0EE276404", + "crypto_context": { + "plaintext": "09 03 01000000 02000000 00" + }, + "notes": "Same TRACE payload but path_len=3 instead of 0. Different hash proves path_len is included in computation." + }, + { + "id": "phash-004", + "description": "Packet hash for ADVERT (all-AA pubkey, ts=0, all-BB sig)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "F73157720FB1B5E1" + } + }, + "binary": "3D 00 F73157720FB1B5E1", + "crypto_context": { + "plaintext": "04 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + }, + "notes": "SHA-256(0x04 || advert_payload)[0..7]. Type 0x04 = ADVERT. Not a TRACE, so path_len is NOT included." + } + ] +} diff --git a/corpus/kiss/framing.json b/corpus/kiss/framing.json new file mode 100644 index 0000000..26a93a9 --- /dev/null +++ b/corpus/kiss/framing.json @@ -0,0 +1,141 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "KISS Protocol - Framing", + "spec_ref": "spec/19-kiss-protocol.md", + "protocol_version": 0, + "description": "Tests KISS frame encoding including FEND delimiters, byte stuffing (FESC sequences), and type byte encoding.", + "vectors": [ + { + "id": "kiss-001", + "description": "Simple data frame: FEND + type(0x00) + data + FEND", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 00 DEADBEEF C0" + } + }, + "binary": "3D 00 C0 00 DEADBEEF C0", + "notes": "KISS frame: FEND(C0) + DataFrame(00) + 4 bytes data + FEND(C0). Data contains no special bytes so no escaping needed." + }, + { + "id": "kiss-002", + "description": "Data frame with FEND in data (byte stuffing required)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 00 AA DBDC BB C0" + } + }, + "binary": "3D 00 C0 00 AA DBDC BB C0", + "notes": "Data byte 0xC0 escaped as DB DC (FESC TFEND). Original data: AA C0 BB." + }, + { + "id": "kiss-003", + "description": "Data frame with FESC in data (byte stuffing required)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 00 AA DBDD BB C0" + } + }, + "binary": "3D 00 C0 00 AA DBDD BB C0", + "notes": "Data byte 0xDB escaped as DB DD (FESC TFESC). Original data: AA DB BB." + }, + { + "id": "kiss-004", + "description": "Data frame with both FEND and FESC in data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 00 DBDC DBDD C0" + } + }, + "binary": "3D 00 C0 00 DBDC DBDD C0", + "notes": "Original data: C0 DB. Escaped: DBDC DBDD." + }, + { + "id": "kiss-005", + "description": "SetHardware frame: GetIdentity (sub-cmd 0x01)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 01 C0" + } + }, + "binary": "3D 00 C0 06 01 C0", + "notes": "KISS frame: FEND + SetHardware(06) + GetIdentity(01) + FEND." + }, + { + "id": "kiss-006", + "description": "SetHardware response: Identity (sub-cmd 0x81) with pubkey", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 81 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA C0" + } + }, + "binary": "3D 00 C0 06 81 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA C0", + "notes": "KISS response: FEND + SetHardware(06) + Identity(81) + 32-byte pubkey + FEND." + } + ] +} diff --git a/corpus/kiss/sethardware.json b/corpus/kiss/sethardware.json new file mode 100644 index 0000000..96dff30 --- /dev/null +++ b/corpus/kiss/sethardware.json @@ -0,0 +1,185 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "KISS Protocol - SetHardware Extensions", + "spec_ref": "spec/19-kiss-protocol.md", + "protocol_version": 0, + "description": "Tests MeshCore-specific SetHardware (0x06) sub-commands and responses.", + "vectors": [ + { + "id": "kiss-hw-001", + "description": "GetVersion request", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 11 C0" + } + }, + "binary": "3D 00 C0 06 11 C0", + "notes": "SetHardware(06) + GetVersion(11)." + }, + { + "id": "kiss-hw-002", + "description": "Version response: version=1, reserved=0", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 91 01 00 C0" + } + }, + "binary": "3D 00 C0 06 91 01 00 C0", + "notes": "SetHardware(06) + Version response(91) + version=1 + reserved=0." + }, + { + "id": "kiss-hw-003", + "description": "SetRadio: freq=869618000Hz, bw=62500Hz, sf=12, cr=5", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 09 D0C5D733 A4F40000 0C 05 C0" + } + }, + "binary": "3D 00 C0 06 09 D0C5D733 A4F40000 0C 05 C0", + "notes": "SetRadio(09). freq=869618000=0x33D7C5D0 LE=D0C5D733. bw=62500=0x0000F4A4 LE=A4F40000. sf=12, cr=5." + }, + { + "id": "kiss-hw-004", + "description": "Ping request and Pong response", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 17 C0" + } + }, + "binary": "3D 00 C0 06 17 C0", + "notes": "SetHardware(06) + Ping(17)." + }, + { + "id": "kiss-hw-005", + "description": "Error response: UnknownCmd (0x05)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 F1 05 C0" + } + }, + "binary": "3D 00 C0 06 F1 05 C0", + "notes": "SetHardware(06) + Error(F1) + UnknownCmd(05)." + }, + { + "id": "kiss-hw-006", + "description": "RxMeta unsolicited event: SNR=-2.5dB, RSSI=-100dBm", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 F9 F6 9C C0" + } + }, + "binary": "3D 00 C0 06 F9 F6 9C C0", + "notes": "RxMeta(F9). SNR: -2.5dB × 4 = -10 = 0xF6 (signed byte). RSSI: -100dBm = 0x9C (signed byte)." + }, + { + "id": "kiss-hw-007", + "description": "TxDone success", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 F8 01 C0" + } + }, + "binary": "3D 00 C0 06 F8 01 C0", + "notes": "TxDone(F8) + success(01)." + }, + { + "id": "kiss-hw-008", + "description": "Stats response: rx=100, tx=50, errors=2", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 06 92 64000000 32000000 02000000 C0" + } + }, + "binary": "3D 00 C0 06 92 64000000 32000000 02000000 C0", + "notes": "Stats(92). rx=100=0x64 LE=64000000, tx=50=0x32 LE=32000000, errors=2 LE=02000000." + } + ] +} diff --git a/corpus/kiss/standard-commands.json b/corpus/kiss/standard-commands.json new file mode 100644 index 0000000..9cc47da --- /dev/null +++ b/corpus/kiss/standard-commands.json @@ -0,0 +1,97 @@ +{ + "$schema": "../schema/test-vector.schema.json", + "name": "KISS Protocol - Standard Commands", + "spec_ref": "spec/19-kiss-protocol.md", + "protocol_version": 0, + "description": "Tests standard KISS commands (TXDELAY, Persistence, SlotTime, etc.).", + "vectors": [ + { + "id": "kiss-std-001", + "description": "TXDELAY command: 500ms (50 × 10ms)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 01 32 C0" + } + }, + "binary": "3D 00 C0 01 32 C0", + "notes": "Type=0x01 (TXDELAY), data=0x32 (50 decimal = 500ms)." + }, + { + "id": "kiss-std-002", + "description": "Persistence command: P=63", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 02 3F C0" + } + }, + "binary": "3D 00 C0 02 3F C0", + "notes": "Type=0x02 (Persistence), data=0x3F (63)." + }, + { + "id": "kiss-std-003", + "description": "SlotTime command: 100ms (10 × 10ms)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 03 0A C0" + } + }, + "binary": "3D 00 C0 03 0A C0", + "notes": "Type=0x03 (SlotTime), data=0x0A (10 = 100ms)." + }, + { + "id": "kiss-std-004", + "description": "FullDuplex command: half duplex (0)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "C0 05 00 C0" + } + }, + "binary": "3D 00 C0 05 00 C0", + "notes": "Type=0x05 (FullDuplex), data=0x00 (half duplex)." + } + ] +} diff --git a/corpus/payloads/ack/basic.json b/corpus/payloads/ack/basic.json new file mode 100644 index 0000000..4a55a1d --- /dev/null +++ b/corpus/payloads/ack/basic.json @@ -0,0 +1,122 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "ACK Payload - Basic", + "spec_ref": "spec/04-payload-ack.md", + "protocol_version": 0, + "description": "Basic ACK payload encoding/decoding tests with various CRC values.", + "vectors": [ + { + "id": "ack-001", + "description": "ACK with CRC 0xDEADBEEF", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "DEADBEEF" + } + }, + "binary": "0D 00 EFBEADDE", + "notes": "CRC 0xDEADBEEF stored as little-endian: EF BE AD DE." + }, + { + "id": "ack-002", + "description": "ACK with CRC 0x00000001", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 00 01000000" + }, + { + "id": "ack-003", + "description": "ACK with CRC 0x12345678", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "12345678" + } + }, + "binary": "0D 00 78563412" + }, + { + "id": "ack-004", + "description": "ACK via direct routing with path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 2, + "hashes": [ + "AA", + "BB" + ] + }, + "payload": { + "ack_crc": "CAFEBABE" + } + }, + "binary": "0E 02 AABB BEBAFECA", + "notes": "Direct routed ACK. CRC 0xCAFEBABE as LE = BEBAFECA." + }, + { + "id": "ack-005", + "description": "ACK with 2-byte hash path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "direct" + }, + "path": { + "hash_size": 2, + "hash_count": 1, + "hashes": [ + "AABB" + ] + }, + "payload": { + "ack_crc": "00000000" + } + }, + "binary": "0E 41 AABB 00000000", + "notes": "path_len=0x41: hash_size=2, count=1." + } + ] +} diff --git a/corpus/payloads/ack/boundary.json b/corpus/payloads/ack/boundary.json new file mode 100644 index 0000000..edde12e --- /dev/null +++ b/corpus/payloads/ack/boundary.json @@ -0,0 +1,115 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "ACK Payload - Boundary Values", + "spec_ref": "spec/04-payload-ack.md", + "protocol_version": 0, + "description": "ACK CRC boundary value tests including all-zeros, all-ones, and single-bit patterns.", + "vectors": [ + { + "id": "ack-bnd-001", + "description": "ACK CRC all zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000000" + } + }, + "binary": "0D 00 00000000" + }, + { + "id": "ack-bnd-002", + "description": "ACK CRC all ones", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "FFFFFFFF" + } + }, + "binary": "0D 00 FFFFFFFF" + }, + { + "id": "ack-bnd-003", + "description": "ACK CRC 0x80000000 (high bit set)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "80000000" + } + }, + "binary": "0D 00 00000080" + }, + { + "id": "ack-bnd-004", + "description": "ACK CRC 0x00000080 (byte boundary)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000080" + } + }, + "binary": "0D 00 80000000" + }, + { + "id": "ack-bnd-005", + "description": "ACK CRC 0x01000000", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "01000000" + } + }, + "binary": "0D 00 00000001", + "notes": "Tests that 0x01000000 LE = 00 00 00 01, not confused with 0x00000001." + } + ] +} diff --git a/corpus/payloads/advert/app-data-combined.json b/corpus/payloads/advert/app-data-combined.json new file mode 100644 index 0000000..088d4c3 --- /dev/null +++ b/corpus/payloads/advert/app-data-combined.json @@ -0,0 +1,128 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Advertisement App Data - Combined Optional Fields", + "spec_ref": "spec/05-payload-advert.md", + "protocol_version": 0, + "description": "Tests app_data with multiple optional fields present simultaneously. Fields appear in order: flags, location (lat+lon), feat1, feat2, name.", + "vectors": [ + { + "id": "adc-001", + "description": "Chat node with location + feat1 + name", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 177, + "latitude": 37774900, + "longitude": -122419400, + "feat1": 256, + "name": "Node1" + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB B1 34664002 3807B4F8 0001 4E6F646531", + "notes": "flags=0xB1=10110001: type=Chat(1), has_location(bit4), has_feat1(bit5), has_name(bit7). No feat2 (bit6=0). feat1=256=0x0100 LE=0001. Name 'Node1' = 4E6F646531." + }, + { + "id": "adc-002", + "description": "Repeater with all optional fields", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 242, + "latitude": 0, + "longitude": 0, + "feat1": 1, + "feat2": 2, + "name": "RPT" + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB F2 00000000 00000000 0100 0200 525054", + "notes": "flags=0xF2=11110010: type=Repeater(2), all optional fields present. feat1=1 LE=0100, feat2=2 LE=0200, name='RPT'=525054." + }, + { + "id": "adc-003", + "description": "Node with name only (no location or features)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 129, + "name": "Hi" + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 81 4869", + "notes": "flags=0x81=10000001: type=Chat(1), has_name(bit7) only. name='Hi'=4869." + }, + { + "id": "adc-004", + "description": "Node with feat2 only (no location, feat1, or name)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 65, + "feat2": 65535 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 41 FFFF", + "notes": "flags=0x41=01000001: type=Chat(1), has_feat2(bit6). feat2=65535=0xFFFF." + } + ] +} diff --git a/corpus/payloads/advert/app-data-location.json b/corpus/payloads/advert/app-data-location.json new file mode 100644 index 0000000..cfdd613 --- /dev/null +++ b/corpus/payloads/advert/app-data-location.json @@ -0,0 +1,96 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Advertisement App Data - Location", + "spec_ref": "spec/05-payload-advert.md", + "protocol_version": 0, + "description": "Tests app_data with location fields (latitude and longitude as int32_le × 1,000,000).", + "vectors": [ + { + "id": "adl-001", + "description": "Chat node with location: San Francisco (37.7749, -122.4194)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 17, + "latitude": 37774900, + "longitude": -122419400 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 11 34664002 3807B4F8", + "notes": "flags=0x11: type=Chat(1), has_location(bit 4). Lat 37774900=0x02406634 LE=34664002. Lon -122419400=0xF8B40738 LE=3807B4F8." + }, + { + "id": "adl-002", + "description": "Repeater with location: equator/prime meridian (0, 0)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 18, + "latitude": 0, + "longitude": 0 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 12 00000000 00000000", + "notes": "flags=0x12: type=Repeater(2), has_location. Both coords zero." + }, + { + "id": "adl-003", + "description": "Sensor with negative latitude: south pole (-90, 0)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 20, + "latitude": -90000000, + "longitude": 0 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 14 80B5A2FA 00000000", + "notes": "flags=0x14: type=Sensor(4), has_location. Lat -90000000=0xFAA2B580 LE=80B5A2FA. Lon 0." + } + ] +} diff --git a/corpus/payloads/advert/app-data-types.json b/corpus/payloads/advert/app-data-types.json new file mode 100644 index 0000000..7236d91 --- /dev/null +++ b/corpus/payloads/advert/app-data-types.json @@ -0,0 +1,140 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Advertisement App Data - Node Types", + "spec_ref": "spec/05-payload-advert.md", + "protocol_version": 0, + "description": "Tests all defined node types in the app_data flags field (bits 0-3). Uses a common pub_key, zero timestamp, and zero signature for structural testing.", + "vectors": [ + { + "id": "adt-001", + "description": "App data type: None (0)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 0 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 00", + "notes": "flags=0x00: type=None(0), no optional fields." + }, + { + "id": "adt-002", + "description": "App data type: Chat (1)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 1 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 01" + }, + { + "id": "adt-003", + "description": "App data type: Repeater (2)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 2 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 02" + }, + { + "id": "adt-004", + "description": "App data type: Room (3)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 3 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 03" + }, + { + "id": "adt-005", + "description": "App data type: Sensor (4)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "timestamp": 0, + "signature": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "app_data": { + "flags": 4 + } + } + }, + "binary": "11 00 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 00000000 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 04" + } + ] +} diff --git a/corpus/payloads/advert/basic.json b/corpus/payloads/advert/basic.json new file mode 100644 index 0000000..82c6e95 --- /dev/null +++ b/corpus/payloads/advert/basic.json @@ -0,0 +1,88 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Advertisement Payload - Basic Structure", + "spec_ref": "spec/05-payload-advert.md", + "protocol_version": 0, + "description": "Tests the basic structure of advertisement payloads: pub_key(32) + timestamp(4) + signature(64) + optional app_data. Uses synthetic signature bytes since signature verification requires real Ed25519 operations (see signature-verify.json for verifiable vectors).", + "vectors": [ + { + "id": "adv-001", + "description": "Minimal advertisement: pub_key + timestamp + signature, no app_data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20", + "timestamp": 1700000000, + "signature": "A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0" + } + }, + "binary": "11 00 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20 00F15365 A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0", + "notes": "Header: 0x11 = ADVERT+FLOOD. Timestamp 1700000000 = 0x6553F100 LE = 00F15365. Payload: 32+4+64 = 100 bytes." + }, + { + "id": "adv-002", + "description": "Advertisement with 1-byte app_data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "pub_key": "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20", + "timestamp": 1700000000, + "signature": "A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0", + "app_data": { + "flags": 1 + } + } + }, + "binary": "11 00 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20 00F15365 A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0 01", + "notes": "flags=0x01: type=Chat(1), no optional fields. Payload: 101 bytes." + }, + { + "id": "adv-003", + "description": "Advertisement with path (3 hops, direct routing)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 3, + "hashes": [ + "AA", + "BB", + "CC" + ] + }, + "payload": { + "pub_key": "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20", + "timestamp": 0, + "signature": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + }, + "binary": "11 03 AABBCC 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20 00000000 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "notes": "Timestamp=0 and zero signature for structural testing." + } + ] +} diff --git a/corpus/payloads/anon-req/basic.json b/corpus/payloads/anon-req/basic.json new file mode 100644 index 0000000..75edde0 --- /dev/null +++ b/corpus/payloads/anon-req/basic.json @@ -0,0 +1,85 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Anonymous Request Payload", + "spec_ref": "spec/07-payload-anon-req.md", + "protocol_version": 0, + "description": "Tests anonymous request payload: dest_hash(1) + sender_pub_key(32) + cipher_mac(2) + ciphertext.", + "vectors": [ + { + "id": "anon-001", + "description": "Anonymous request with known keys", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "anon_req", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "sender_pub_key": "404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F", + "cipher_mac": "F4B4", + "ciphertext": "908A4FC137435774D0BB603F55A06069" + } + }, + "binary": "1D 00 AB 404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F F4B4 908A4FC137435774D0BB603F55A06069", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "416E6F6E526571" + }, + "notes": "Header 0x1D = ANON_REQ(7)+FLOOD(1). Payload: dest_hash(1) + pub_key(32) + mac(2) + ct(16) = 51 bytes. Plaintext 'AnonReq' (7 bytes)." + }, + { + "id": "anon-002", + "description": "Anonymous request with tampered MAC", + "type": "invalid", + "binary": "1D 00 AB 404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F 0000 908A4FC137435774D0BB603F55A06069", + "expected_error": "mac_invalid", + "notes": "MAC zeroed. Verification must fail." + }, + { + "id": "anon-003", + "description": "Anonymous request via DIRECT routing with 2-hop path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "anon_req", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 2, + "hashes": ["AA", "BB"] + }, + "payload": { + "dest_hash": "CC", + "sender_pub_key": "404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F", + "cipher_mac": "F4B4", + "ciphertext": "908A4FC137435774D0BB603F55A06069" + } + }, + "binary": "1E 02 AABB CC 404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F F4B4 908A4FC137435774D0BB603F55A06069", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "416E6F6E526571" + }, + "notes": "Header 0x1E = ANON_REQ(7)+DIRECT(2). Direct-routed anonymous request with 2 path hashes." + }, + { + "id": "anon-004", + "description": "Anonymous request truncated before ciphertext", + "type": "invalid", + "binary": "1D 00 AB 404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F F4B4", + "expected_error": "too_short", + "notes": "Payload is only 35 bytes (dest_hash + pub_key + mac). Missing ciphertext. Minimum is 51 bytes." + } + ] +} diff --git a/corpus/payloads/control/basic.json b/corpus/payloads/control/basic.json new file mode 100644 index 0000000..b888c1e --- /dev/null +++ b/corpus/payloads/control/basic.json @@ -0,0 +1,96 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Control Payload - Basic", + "spec_ref": "spec/12-payload-control.md", + "protocol_version": 0, + "description": "Tests control payload encoding. Control packets carry raw bytes where the first byte's bit 7 indicates zero-hop-only restriction.", + "vectors": [ + { + "id": "ctl-001", + "description": "Control packet with multi-hop allowed (bit 7 clear)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "control", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "01 AABBCCDD" + } + }, + "binary": "2D 00 01 AABBCCDD", + "notes": "control_byte=0x01: bit 7 clear, multi-hop allowed." + }, + { + "id": "ctl-002", + "description": "Control packet with zero-hop only (bit 7 set)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "control", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "80 AABBCCDD" + } + }, + "binary": "2E 00 80 AABBCCDD", + "notes": "control_byte=0x80: bit 7 set, zero-hop only. Direct route with path_count=0 = zero-hop." + }, + { + "id": "ctl-003", + "description": "Minimal control packet (1 byte)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "control", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "00" + } + }, + "binary": "2D 00 00" + }, + { + "id": "ctl-004", + "description": "Control packet with zero-hop restriction and data via direct routing", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "control", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF 0102030405" + } + }, + "binary": "2E 00 FF 0102030405", + "notes": "control_byte=0xFF: bit 7 set (zero-hop), all other bits set." + } + ] +} diff --git a/corpus/payloads/encrypted/basic.json b/corpus/payloads/encrypted/basic.json new file mode 100644 index 0000000..7b60085 --- /dev/null +++ b/corpus/payloads/encrypted/basic.json @@ -0,0 +1,129 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypted Payload - Basic", + "spec_ref": "spec/06-payload-encrypted.md", + "protocol_version": 0, + "description": "Tests encrypted payload encoding (REQ/RESPONSE/TXT_MSG) with known cryptographic material. Uses shared_secret = 0x000102...1F, AES key = first 16 bytes, HMAC key = full 32 bytes.", + "vectors": [ + { + "id": "enc-001", + "description": "Encrypted TXT_MSG with 5-byte plaintext 'Hello'", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "09 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "48656C6C6F" + }, + "notes": "plaintext 'Hello' = 48656C6C6F (5 bytes), zero-padded to 16 for AES-128-ECB. MAC is HMAC-SHA256(shared_secret, ciphertext) truncated to 2 bytes." + }, + { + "id": "enc-002", + "description": "Encrypted REQUEST with 16-byte plaintext (exact AES block)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "request", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "631C", + "ciphertext": "0A940BB5416EF045F1C39458C653EA5A" + } + }, + "binary": "01 00 ABCD 631C 0A940BB5416EF045F1C39458C653EA5A", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F" + }, + "notes": "16-byte plaintext fills exactly one AES block. No padding needed." + }, + { + "id": "enc-003", + "description": "Encrypted RESPONSE with 17-byte plaintext (spans 2 AES blocks)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "response", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "9822", + "ciphertext": "0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560" + } + }, + "binary": "05 00 ABCD 9822 0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F10" + }, + "notes": "17-byte plaintext: first 16 bytes in block 1, byte 17 zero-padded in block 2. 32 bytes ciphertext total." + }, + { + "id": "enc-004", + "description": "Encrypted TXT_MSG with 1-byte plaintext", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "83FB", + "ciphertext": "2D4135E80761429386BFE3833C535AA8" + } + }, + "binary": "09 00 ABCD 83FB 2D4135E80761429386BFE3833C535AA8", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "42" + }, + "notes": "1-byte plaintext 0x42, zero-padded to 16 bytes." + } + ] +} diff --git a/corpus/payloads/encrypted/mac-verify.json b/corpus/payloads/encrypted/mac-verify.json new file mode 100644 index 0000000..c4d0dd6 --- /dev/null +++ b/corpus/payloads/encrypted/mac-verify.json @@ -0,0 +1,69 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypted Payload - MAC Verification", + "spec_ref": "spec/06-payload-encrypted.md", + "protocol_version": 0, + "description": "Tests MAC verification: valid MACs must pass, tampered MACs or ciphertext must fail.", + "vectors": [ + { + "id": "mac-001", + "description": "Valid MAC with known ciphertext", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "09 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + } + }, + { + "id": "mac-002", + "description": "Tampered MAC (first byte changed)", + "type": "invalid", + "binary": "09 00 ABCD 00B5 D9FD218D50A4409143A7243D6D913502", + "expected_error": "mac_invalid", + "notes": "MAC changed from EAB5 to 00B5. MACThenDecrypt must return 0." + }, + { + "id": "mac-003", + "description": "Tampered MAC (second byte changed)", + "type": "invalid", + "binary": "09 00 ABCD EA00 D9FD218D50A4409143A7243D6D913502", + "expected_error": "mac_invalid", + "notes": "MAC changed from EAB5 to EA00." + }, + { + "id": "mac-004", + "description": "Tampered ciphertext (first byte changed)", + "type": "invalid", + "binary": "09 00 ABCD EAB5 00FD218D50A4409143A7243D6D913502", + "expected_error": "mac_invalid", + "notes": "Ciphertext first byte changed from D9 to 00. HMAC over modified ciphertext won't match." + }, + { + "id": "mac-005", + "description": "Swapped MAC bytes", + "type": "invalid", + "binary": "09 00 ABCD B5EA D9FD218D50A4409143A7243D6D913502", + "expected_error": "mac_invalid", + "notes": "MAC bytes reversed: B5EA instead of EAB5." + } + ] +} diff --git a/corpus/payloads/encrypted/padding.json b/corpus/payloads/encrypted/padding.json new file mode 100644 index 0000000..5023372 --- /dev/null +++ b/corpus/payloads/encrypted/padding.json @@ -0,0 +1,99 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypted Payload - AES Block Padding", + "spec_ref": "spec/06-payload-encrypted.md", + "protocol_version": 0, + "description": "Tests AES-128-ECB zero-padding behavior at block boundaries. Plaintexts of 1, 15, 16, 17, and 32 bytes exercise all padding edge cases.", + "vectors": [ + { + "id": "pad-001", + "description": "1-byte plaintext: padded with 15 zero bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "83FB", + "ciphertext": "2D4135E80761429386BFE3833C535AA8" + } + }, + "binary": "09 00 ABCD 83FB 2D4135E80761429386BFE3833C535AA8", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "42" + }, + "notes": "1 byte padded to 16: 42 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00. 1 AES block output." + }, + { + "id": "pad-002", + "description": "16-byte plaintext: no padding needed (exact block boundary)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "631C", + "ciphertext": "0A940BB5416EF045F1C39458C653EA5A" + } + }, + "binary": "09 00 ABCD 631C 0A940BB5416EF045F1C39458C653EA5A", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F" + }, + "notes": "Exactly 16 bytes: no padding. 1 AES block output." + }, + { + "id": "pad-003", + "description": "17-byte plaintext: 1 byte in second block, padded with 15 zeros", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "9822", + "ciphertext": "0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560" + } + }, + "binary": "09 00 ABCD 9822 0A940BB5416EF045F1C39458C653EA5A299F7C29A3E13AE7F64ECBA062FC7560", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "000102030405060708090A0B0C0D0E0F10" + }, + "notes": "17 bytes spans 2 blocks. Block 2 = 10 00 00 ... 00. 32 bytes ciphertext." + } + ] +} diff --git a/corpus/payloads/encrypted/roundtrip.json b/corpus/payloads/encrypted/roundtrip.json new file mode 100644 index 0000000..d054a96 --- /dev/null +++ b/corpus/payloads/encrypted/roundtrip.json @@ -0,0 +1,47 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypted Payload - Roundtrip Verification", + "spec_ref": "spec/06-payload-encrypted.md", + "protocol_version": 0, + "description": "Roundtrip encrypt-then-MAC verification vectors. Each vector provides full key material enabling implementations to verify: (1) encryption produces the given ciphertext, (2) MAC computation produces the given MAC, (3) MAC verification succeeds, (4) decryption recovers the plaintext.", + "vectors": [ + { + "id": "rt-enc-001", + "description": "Roundtrip: encrypt 'Hello', verify MAC, decrypt back", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "09 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "encryption_key": "000102030405060708090A0B0C0D0E0F", + "plaintext": "48656C6C6F" + }, + "notes": "Verification steps: (1) AES-128-ECB encrypt(key=00..0F, pad('Hello')) -> D9FD...3502. (2) HMAC-SHA256(key=00..1F, ct)[:2] -> EAB5. (3) HMAC verify: recompute and compare. (4) AES-128-ECB decrypt -> 48656C6C6F + 11 zero bytes." + }, + { + "id": "rt-enc-002", + "description": "Roundtrip with wrong MAC must fail verification", + "type": "invalid", + "binary": "09 00 ABCD FFFF D9FD218D50A4409143A7243D6D913502", + "expected_error": "mac_invalid", + "notes": "Same ciphertext as rt-enc-001 but MAC replaced with FFFF. MACThenDecrypt must return 0 (failure)." + } + ] +} diff --git a/corpus/payloads/encrypted/with-extra.json b/corpus/payloads/encrypted/with-extra.json new file mode 100644 index 0000000..5bc14bb --- /dev/null +++ b/corpus/payloads/encrypted/with-extra.json @@ -0,0 +1,76 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Encrypted Payload - Extra Edge Cases", + "spec_ref": "spec/06-payload-encrypted.md", + "protocol_version": 0, + "description": "Additional encrypted payload tests: non-FLOOD routes, hash_size>1 paths, 32-byte plaintext, and payload-level invalid cases.", + "vectors": [ + { + "id": "enc-extra-001", + "description": "Encrypted TXT_MSG via DIRECT routing with 2-byte hash path", + "type": "encode_decode", + "structured": { + "header": { "version": 0, "payload_type": "txt_msg", "route_type": "direct" }, + "path": { "hash_size": 2, "hash_count": 1, "hashes": ["AABB"] }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "0A 41 AABB ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + }, + "notes": "Header 0x0A = TXT_MSG(2)+DIRECT(2). path_len=0x41 (hash_size=2, count=1). Tests encrypted payload with non-FLOOD routing and multi-byte hash." + }, + { + "id": "enc-extra-002", + "description": "Encrypted TXT_MSG via TRANSPORT_FLOOD with transport codes", + "type": "encode_decode", + "structured": { + "header": { "version": 0, "payload_type": "txt_msg", "route_type": "transport_flood" }, + "transport_codes": [1000, 0], + "path": { "hash_size": 1, "hash_count": 0, "hashes": [] }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "08 E803 0000 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + }, + "notes": "Header 0x08 = TXT_MSG(2)+TRANSPORT_FLOOD(0). Tests encrypted payload with transport codes." + }, + { + "id": "enc-extra-003", + "description": "ACK payload too short (3 bytes instead of 4)", + "type": "invalid", + "binary": "0D 00 EFBEAD", + "expected_error": "incomplete_payload", + "notes": "ACK requires exactly 4 bytes. Only 3 provided." + }, + { + "id": "enc-extra-004", + "description": "Payload exceeds MAX_PACKET_PAYLOAD (185 bytes)", + "type": "invalid", + "binary": "3D 00 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8", + "expected_error": "payload_too_large", + "notes": "Payload is 185 bytes (0x00..0xB8), exceeding MAX_PACKET_PAYLOAD of 184. readFrom MUST reject." + }, + { + "id": "enc-extra-005", + "description": "Header 0xFF sentinel on wire (must reject)", + "type": "invalid", + "binary": "FF 00 DEADBEEF", + "expected_error": "sentinel_header", + "notes": "Header 0xFF is used internally as do-not-retransmit marker. MUST NOT appear on the wire." + } + ] +} diff --git a/corpus/payloads/group/grp-data.json b/corpus/payloads/group/grp-data.json new file mode 100644 index 0000000..7e6fa50 --- /dev/null +++ b/corpus/payloads/group/grp-data.json @@ -0,0 +1,38 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Group Data Payload", + "spec_ref": "spec/08-payload-group.md", + "protocol_version": 0, + "description": "Tests group datagram payload. Same wire format as GRP_TXT but payload type = 0x06.", + "vectors": [ + { + "id": "grp-data-001", + "description": "Group datagram with known channel secret", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "grp_data", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "channel_hash": "72", + "cipher_mac": "D184", + "ciphertext": "948C819389BA4ED7B1194A0FF8E62073" + } + }, + "binary": "19 00 72 D184 948C819389BA4ED7B1194A0FF8E62073", + "crypto_context": { + "shared_secret": "202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F", + "encryption_key": "202122232425262728292A2B2C2D2E2F", + "plaintext": "47726F75704D736721" + }, + "notes": "Header 0x19 = GRP_DATA(6)+FLOOD(1). Same ciphertext as GRP_TXT since same key and plaintext." + } + ] +} diff --git a/corpus/payloads/group/grp-txt.json b/corpus/payloads/group/grp-txt.json new file mode 100644 index 0000000..fbe7888 --- /dev/null +++ b/corpus/payloads/group/grp-txt.json @@ -0,0 +1,46 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Group Text Payload", + "spec_ref": "spec/08-payload-group.md", + "protocol_version": 0, + "description": "Tests group text message payload: channel_hash(1) + cipher_mac(2) + ciphertext.", + "vectors": [ + { + "id": "grp-txt-001", + "description": "Group text message with known channel secret", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "grp_txt", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "channel_hash": "72", + "cipher_mac": "D184", + "ciphertext": "948C819389BA4ED7B1194A0FF8E62073" + } + }, + "binary": "15 00 72 D184 948C819389BA4ED7B1194A0FF8E62073", + "crypto_context": { + "shared_secret": "202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F", + "encryption_key": "202122232425262728292A2B2C2D2E2F", + "plaintext": "47726F75704D736721" + }, + "notes": "Channel hash = SHA256(channel_secret)[0] = 0x72. Plaintext 'GroupMsg!' (9 bytes). AES key = first 16 bytes of channel secret." + }, + { + "id": "grp-txt-002", + "description": "Group text with tampered MAC must fail", + "type": "invalid", + "binary": "15 00 72 0000 948C819389BA4ED7B1194A0FF8E62073", + "expected_error": "mac_invalid", + "notes": "Same ciphertext as grp-txt-001 but MAC zeroed. Verification must fail." + } + ] +} diff --git a/corpus/payloads/multipart/basic.json b/corpus/payloads/multipart/basic.json new file mode 100644 index 0000000..4c0e336 --- /dev/null +++ b/corpus/payloads/multipart/basic.json @@ -0,0 +1,108 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Multipart Payload - Basic", + "spec_ref": "spec/11-payload-multipart.md", + "protocol_version": 0, + "description": "Tests multipart payload encoding: first byte = (remaining << 4) | sub_type. Currently only multipart ACK (sub_type=3) is defined.", + "vectors": [ + { + "id": "mp-001", + "description": "Multipart ACK with remaining=1, CRC 0xDEADBEEF", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "multipart", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "remaining": 1, + "sub_type": 3, + "sub_payload": "EFBEADDE" + } + }, + "binary": "29 00 13 EFBEADDE", + "notes": "First byte: (1<<4)|3 = 0x13. Sub-payload: ACK CRC 0xDEADBEEF as LE." + }, + { + "id": "mp-002", + "description": "Multipart ACK with remaining=0 (last in sequence)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "multipart", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "remaining": 0, + "sub_type": 3, + "sub_payload": "01000000" + } + }, + "binary": "29 00 03 01000000", + "notes": "First byte: (0<<4)|3 = 0x03." + }, + { + "id": "mp-003", + "description": "Multipart ACK with remaining=15 (maximum)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "multipart", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "remaining": 15, + "sub_type": 3, + "sub_payload": "FFFFFFFF" + } + }, + "binary": "29 00 F3 FFFFFFFF", + "notes": "First byte: (15<<4)|3 = 0xF3." + }, + { + "id": "mp-004", + "description": "Multipart ACK via direct routing", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "multipart", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 2, + "hashes": [ + "AA", + "BB" + ] + }, + "payload": { + "remaining": 2, + "sub_type": 3, + "sub_payload": "78563412" + } + }, + "binary": "2A 02 AABB 23 78563412", + "notes": "Header 0x2A = MULTIPART(10)+DIRECT(2). First byte: (2<<4)|3 = 0x23." + } + ] +} diff --git a/corpus/payloads/path-return/basic.json b/corpus/payloads/path-return/basic.json new file mode 100644 index 0000000..d5d147d --- /dev/null +++ b/corpus/payloads/path-return/basic.json @@ -0,0 +1,122 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Path Return Payload - Basic", + "spec_ref": "spec/09-payload-path.md", + "protocol_version": 0, + "description": "Tests path return payload outer structure: dest_hash(1) + src_hash(1) + encrypt-then-MAC. The encrypted inner format contains path_len + path + extra_type + extra_data.", + "vectors": [ + { + "id": "path-001", + "description": "Path return with encrypted inner data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "path", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "21 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + }, + "notes": "Header 0x21 = PATH(8)+FLOOD(1). Same ciphertext as enc-001 since same key/plaintext. The decrypted inner format would be parsed as: path_len=0x48, hash_size=2, hash_count=8 etc. - this is a structural test only." + }, + { + "id": "path-002", + "description": "Path return via DIRECT routing with 1-hop path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "path", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 1, + "hashes": ["AB"] + }, + "payload": { + "dest_hash": "CD", + "src_hash": "EF", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "22 01 AB CDEF EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + }, + "notes": "Header 0x22 = PATH(8)+DIRECT(2). Direct-routed path return with 1 hop." + }, + { + "id": "path-003", + "description": "Path return via TRANSPORT_FLOOD with transport codes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "path", + "route_type": "transport_flood" + }, + "transport_codes": [1, 2], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "EAB5", + "ciphertext": "D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "20 0100 0200 00 ABCD EAB5 D9FD218D50A4409143A7243D6D913502", + "crypto_context": { + "shared_secret": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "plaintext": "48656C6C6F" + }, + "notes": "Header 0x20 = PATH(8)+TRANSPORT_FLOOD(0). Transport codes [1, 2] as LE uint16." + }, + { + "id": "path-004", + "description": "Path return with 2-block ciphertext (32 bytes plaintext)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "path", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "1234", + "ciphertext": "D9FD218D50A4409143A7243D6D913502D9FD218D50A4409143A7243D6D913502" + } + }, + "binary": "21 00 ABCD 1234 D9FD218D50A4409143A7243D6D913502D9FD218D50A4409143A7243D6D913502", + "notes": "Path return with 2 AES blocks of ciphertext (32 bytes). Tests multi-block encrypted path data." + } + ] +} diff --git a/corpus/payloads/raw-custom/basic.json b/corpus/payloads/raw-custom/basic.json new file mode 100644 index 0000000..58fd627 --- /dev/null +++ b/corpus/payloads/raw-custom/basic.json @@ -0,0 +1,97 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Raw Custom Payload - Basic", + "spec_ref": "spec/13-payload-raw-custom.md", + "protocol_version": 0, + "description": "Tests raw custom payload encoding. RAW_CUSTOM carries application-defined raw bytes.", + "vectors": [ + { + "id": "raw-001", + "description": "Minimal raw custom payload (1 byte)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "42" + } + }, + "binary": "3E 00 42", + "notes": "Header 0x3E = RAW_CUSTOM(15)+DIRECT(2). Single byte payload." + }, + { + "id": "raw-002", + "description": "Raw custom with binary data", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 1, + "hashes": [ + "AA" + ] + }, + "payload": { + "data": "DEADBEEFCAFEBABE" + } + }, + "binary": "3E 01 AA DEADBEEFCAFEBABE", + "notes": "Direct route with 1-hop path." + }, + { + "id": "raw-003", + "description": "Raw custom with all-zero payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "0000000000000000" + } + }, + "binary": "3E 00 0000000000000000" + }, + { + "id": "raw-004", + "description": "Raw custom with all-FF payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FFFFFFFFFFFFFFFF" + } + }, + "binary": "3E 00 FFFFFFFFFFFFFFFF" + } + ] +} diff --git a/corpus/payloads/trace/basic.json b/corpus/payloads/trace/basic.json new file mode 100644 index 0000000..265eae8 --- /dev/null +++ b/corpus/payloads/trace/basic.json @@ -0,0 +1,127 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Trace Payload - Basic", + "spec_ref": "spec/10-payload-trace.md", + "protocol_version": 0, + "description": "Tests the basic 9-byte TRACE payload structure: tag(4) + auth_code(4) + flags(1).", + "vectors": [ + { + "id": "trc-001", + "description": "Basic trace with tag=1, auth_code=2, flags=0", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "tag": 1, + "auth_code": 2, + "flags": 0 + } + }, + "binary": "25 00 01000000 02000000 00", + "notes": "tag=1 LE=01000000, auth_code=2 LE=02000000, flags=0." + }, + { + "id": "trc-002", + "description": "Trace with large tag and auth_code values", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "tag": 4294967295, + "auth_code": 305419896, + "flags": 0 + } + }, + "binary": "25 00 FFFFFFFF 78563412 00", + "notes": "tag=0xFFFFFFFF, auth_code=0x12345678 LE=78563412." + }, + { + "id": "trc-003", + "description": "Trace with flags indicating 1-byte path hash size (flags & 0x03 = 0)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "tag": 100, + "auth_code": 200, + "flags": 0 + } + }, + "binary": "25 00 64000000 C8000000 00", + "notes": "flags=0: path_hash_size = 1 << (0 & 3) = 1 byte." + }, + { + "id": "trc-004", + "description": "Trace with flags indicating 2-byte path hash size (flags & 0x03 = 1)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "tag": 100, + "auth_code": 200, + "flags": 1 + } + }, + "binary": "25 00 64000000 C8000000 01", + "notes": "flags=1: path_hash_size = 1 << (1 & 3) = 2 bytes." + }, + { + "id": "trc-005", + "description": "Trace sent via direct routing with path hashes appended to payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "01000000 02000000 00 AABBCC" + } + }, + "binary": "26 00 01000000 02000000 00 AABBCC", + "notes": "Header 0x26 = TRACE(9)+DIRECT(2). path_len=0 (path field used for SNR). Payload: 9-byte header + 3 path hash bytes. Using raw payload since the appended hashes are semantically part of the trace." + } + ] +} diff --git a/corpus/schema/test-vector.schema.json b/corpus/schema/test-vector.schema.json new file mode 100644 index 0000000..f1b62c8 --- /dev/null +++ b/corpus/schema/test-vector.schema.json @@ -0,0 +1,459 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/meshcore-spec/corpus/schema/test-vector.schema.json", + "title": "MeshCore Test Vector File", + "description": "Schema for MeshCore protocol test vector files. Each file contains a named collection of test vectors for a specific aspect of the protocol.", + "type": "object", + "required": ["name", "protocol_version", "vectors"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "name": { + "type": "string", + "description": "Human-readable name for this test vector collection" + }, + "spec_ref": { + "type": "string", + "description": "Path to the relevant spec section (e.g., 'spec/01-wire-format.md')" + }, + "protocol_version": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "description": "MeshCore protocol version (0-3, encoded in header bits 6-7)" + }, + "description": { + "type": "string", + "description": "Optional longer description of what this file tests" + }, + "vectors": { + "type": "array", + "description": "Array of test vectors", + "items": { + "$ref": "#/$defs/test_vector" + } + } + }, + "$defs": { + "hex_string": { + "type": "string", + "pattern": "^[0-9A-F\\s]*$", + "description": "Hex-encoded binary data. MUST be uppercase. Whitespace allowed and stripped on parse." + }, + "header": { + "type": "object", + "required": ["version", "payload_type", "route_type"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "description": "Protocol version (header bits 6-7)" + }, + "payload_type": { + "enum": [ + "request", + "response", + "txt_msg", + "ack", + "advert", + "grp_txt", + "grp_data", + "anon_req", + "path", + "trace", + "multipart", + "control", + "raw_custom" + ], + "description": "Payload type (header bits 2-5). Values 0x0C-0x0E are reserved." + }, + "route_type": { + "enum": [ + "transport_flood", + "flood", + "direct", + "transport_direct" + ], + "description": "Route type (header bits 0-1). 0=transport_flood, 1=flood, 2=direct, 3=transport_direct" + } + } + }, + "path_info": { + "type": "object", + "required": ["hash_size", "hash_count", "hashes"], + "additionalProperties": false, + "properties": { + "hash_size": { + "type": "integer", + "minimum": 1, + "maximum": 3, + "description": "Size of each hash in bytes (1, 2, or 3). Encoded as (hash_size - 1) in bits 6-7 of path_len byte." + }, + "hash_count": { + "type": "integer", + "minimum": 0, + "maximum": 63, + "description": "Number of hashes in path (bits 0-5 of path_len byte)" + }, + "hashes": { + "type": "array", + "items": { + "$ref": "#/$defs/hex_string" + }, + "description": "Array of hex-encoded path hashes. Length MUST equal hash_count. Each hash MUST be hash_size bytes." + } + } + }, + "transport_codes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "minItems": 2, + "maxItems": 2, + "description": "Two 16-bit transport codes [code1, code2], encoded as little-endian uint16" + }, + "payload_ack": { + "type": "object", + "required": ["ack_crc"], + "additionalProperties": false, + "properties": { + "ack_crc": { + "$ref": "#/$defs/hex_string", + "description": "4-byte ACK CRC as hex (big-endian representation of the value, wire encoding is little-endian)" + } + } + }, + "payload_advert": { + "type": "object", + "required": ["pub_key", "timestamp", "signature"], + "additionalProperties": false, + "properties": { + "pub_key": { + "$ref": "#/$defs/hex_string", + "description": "32-byte Ed25519 public key" + }, + "timestamp": { + "type": "integer", + "minimum": 0, + "description": "4-byte Unix timestamp" + }, + "signature": { + "$ref": "#/$defs/hex_string", + "description": "64-byte Ed25519 signature over (pub_key + timestamp_le + app_data)" + }, + "app_data": { + "$ref": "#/$defs/advert_app_data", + "description": "Optional advertisement application data" + } + } + }, + "advert_app_data": { + "type": "object", + "required": ["flags"], + "additionalProperties": false, + "properties": { + "flags": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "8-bit flags: bits 0-3 = type (0=None,1=Chat,2=Repeater,3=Room,4=Sensor), bit 4 = has_location, bit 5 = has_feat1, bit 6 = has_feat2, bit 7 = has_name" + }, + "latitude": { + "type": "integer", + "description": "Latitude * 1,000,000 as int32 (present when flags bit 4 set)" + }, + "longitude": { + "type": "integer", + "description": "Longitude * 1,000,000 as int32 (present when flags bit 4 set)" + }, + "feat1": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Feature field 1 as uint16 (present when flags bit 5 set)" + }, + "feat2": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Feature field 2 as uint16 (present when flags bit 6 set)" + }, + "name": { + "type": "string", + "description": "Node name as UTF-8 string (present when flags bit 7 set)" + } + } + }, + "payload_encrypted": { + "type": "object", + "required": ["dest_hash", "src_hash", "cipher_mac", "ciphertext"], + "additionalProperties": false, + "properties": { + "dest_hash": { + "$ref": "#/$defs/hex_string", + "description": "Destination hash (PATH_HASH_SIZE bytes, default 1)" + }, + "src_hash": { + "$ref": "#/$defs/hex_string", + "description": "Source hash (PATH_HASH_SIZE bytes, default 1)" + }, + "cipher_mac": { + "$ref": "#/$defs/hex_string", + "description": "2-byte HMAC-SHA256 truncated MAC" + }, + "ciphertext": { + "$ref": "#/$defs/hex_string", + "description": "AES-128-ECB encrypted data (zero-padded to block boundary)" + } + } + }, + "payload_anon_req": { + "type": "object", + "required": ["dest_hash", "sender_pub_key", "cipher_mac", "ciphertext"], + "additionalProperties": false, + "properties": { + "dest_hash": { + "$ref": "#/$defs/hex_string", + "description": "Destination hash (PATH_HASH_SIZE bytes)" + }, + "sender_pub_key": { + "$ref": "#/$defs/hex_string", + "description": "32-byte Ed25519 public key of sender" + }, + "cipher_mac": { + "$ref": "#/$defs/hex_string", + "description": "2-byte cipher MAC" + }, + "ciphertext": { + "$ref": "#/$defs/hex_string", + "description": "Encrypted data" + } + } + }, + "payload_group": { + "type": "object", + "required": ["channel_hash", "cipher_mac", "ciphertext"], + "additionalProperties": false, + "properties": { + "channel_hash": { + "$ref": "#/$defs/hex_string", + "description": "1-byte channel hash (first byte of SHA-256 of channel shared key)" + }, + "cipher_mac": { + "$ref": "#/$defs/hex_string", + "description": "2-byte cipher MAC" + }, + "ciphertext": { + "$ref": "#/$defs/hex_string", + "description": "Encrypted data" + } + } + }, + "payload_trace": { + "type": "object", + "required": ["tag", "auth_code", "flags"], + "additionalProperties": false, + "properties": { + "tag": { + "type": "integer", + "minimum": 0, + "description": "4-byte random tag set by initiator" + }, + "auth_code": { + "type": "integer", + "minimum": 0, + "description": "4-byte authentication code" + }, + "flags": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "1-byte flags (bits 0-1 encode path hash size)" + } + } + }, + "payload_multipart": { + "type": "object", + "required": ["remaining", "sub_type", "sub_payload"], + "additionalProperties": false, + "properties": { + "remaining": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "description": "Remaining parts count (upper 4 bits of first byte)" + }, + "sub_type": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "description": "Sub-payload type (lower 4 bits of first byte)" + }, + "sub_payload": { + "$ref": "#/$defs/hex_string", + "description": "Sub-payload data" + } + } + }, + "payload_control": { + "type": "object", + "required": ["data"], + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/$defs/hex_string", + "description": "Raw control data bytes" + } + } + }, + "payload_raw": { + "type": "object", + "required": ["data"], + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/$defs/hex_string", + "description": "Raw custom payload bytes" + } + } + }, + "crypto_context": { + "type": "object", + "additionalProperties": false, + "properties": { + "sender_private_key": { + "$ref": "#/$defs/hex_string", + "description": "64-byte Ed25519 private key of sender" + }, + "sender_public_key": { + "$ref": "#/$defs/hex_string", + "description": "32-byte Ed25519 public key of sender" + }, + "recipient_private_key": { + "$ref": "#/$defs/hex_string", + "description": "64-byte Ed25519 private key of recipient" + }, + "recipient_public_key": { + "$ref": "#/$defs/hex_string", + "description": "32-byte Ed25519 public key of recipient" + }, + "shared_secret": { + "$ref": "#/$defs/hex_string", + "description": "32-byte ECDH shared secret" + }, + "encryption_key": { + "$ref": "#/$defs/hex_string", + "description": "16-byte AES-128 key (first 16 bytes of shared secret)" + }, + "plaintext": { + "$ref": "#/$defs/hex_string", + "description": "Plaintext before encryption" + } + } + }, + "structured_packet": { + "type": "object", + "required": ["header", "path"], + "additionalProperties": false, + "properties": { + "header": { + "$ref": "#/$defs/header" + }, + "transport_codes": { + "$ref": "#/$defs/transport_codes" + }, + "path": { + "$ref": "#/$defs/path_info" + }, + "payload": { + "description": "Payload structure — the schema depends on the payload_type in header", + "anyOf": [ + { "$ref": "#/$defs/payload_ack" }, + { "$ref": "#/$defs/payload_advert" }, + { "$ref": "#/$defs/payload_encrypted" }, + { "$ref": "#/$defs/payload_anon_req" }, + { "$ref": "#/$defs/payload_group" }, + { "$ref": "#/$defs/payload_trace" }, + { "$ref": "#/$defs/payload_multipart" }, + { "$ref": "#/$defs/payload_control" }, + { "$ref": "#/$defs/payload_raw" } + ] + } + } + }, + "test_vector": { + "type": "object", + "required": ["id", "description", "type"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "description": "Unique identifier within this file (e.g., 'ack-001')" + }, + "description": { + "type": "string", + "description": "Human-readable description of what this vector tests" + }, + "type": { + "enum": ["encode_decode", "decode_only", "invalid"], + "description": "Test mode: encode_decode (bidirectional), decode_only (binary->structured), invalid (must reject)" + }, + "structured": { + "$ref": "#/$defs/structured_packet", + "description": "Structured packet representation (required for encode_decode and decode_only)" + }, + "binary": { + "$ref": "#/$defs/hex_string", + "description": "Wire-format binary encoding as hex (required for all types)" + }, + "crypto_context": { + "$ref": "#/$defs/crypto_context", + "description": "Cryptographic key material for encrypted payload vectors" + }, + "expected_error": { + "type": "string", + "description": "Expected error category for invalid vectors" + }, + "notes": { + "type": "string", + "description": "Optional notes explaining the test vector" + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "encode_decode" } } + }, + "then": { + "required": ["structured", "binary"] + } + }, + { + "if": { + "properties": { "type": { "const": "decode_only" } } + }, + "then": { + "required": ["structured", "binary"] + } + }, + { + "if": { + "properties": { "type": { "const": "invalid" } } + }, + "then": { + "required": ["binary"] + } + } + ] + } + } +} diff --git a/corpus/wire-format/framing/decode-only.json b/corpus/wire-format/framing/decode-only.json new file mode 100644 index 0000000..0d5bc85 --- /dev/null +++ b/corpus/wire-format/framing/decode-only.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../../schema/test-vector.schema.json", + "name": "Framing - Decode Only", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests one-way decode cases where the binary is valid to decode but may not be the canonical encoding a conformant encoder would produce.", + "vectors": [ + { + "id": "dec-001", + "description": "Decode-only: ACK FLOOD with trailing bytes beyond the 4-byte CRC payload", + "type": "decode_only", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "DEADBEEF" + } + }, + "binary": "0D 00 DEADBEEF 00", + "notes": "Binary has 1 trailing byte after the 4-byte ACK CRC. Decoders MUST accept this (payload_len includes the extra byte). Encoders would not produce trailing bytes." + }, + { + "id": "dec-002", + "description": "Decode-only: raw_custom FLOOD with path hash_count=0 but hash_size=2", + "type": "decode_only", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "AA" + } + }, + "binary": "3D 40 AA", + "notes": "Path_len byte 0x40 encodes hash_size=2 (bits 6-7 = 01) with hash_count=0. Decoders must handle non-default hash_size even when hash_count is zero. Encoders typically default hash_size=1 when hash_count=0." + }, + { + "id": "dec-003", + "description": "Decode-only: raw_custom FLOOD with path hash_size=3 and hash_count=0", + "type": "decode_only", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "BB" + } + }, + "binary": "3D 80 BB", + "notes": "Path_len byte 0x80 encodes hash_size=3 (bits 6-7 = 10) with hash_count=0. Same as dec-002 but with hash_size=3." + } + ] +} diff --git a/corpus/wire-format/framing/empty-payload.json b/corpus/wire-format/framing/empty-payload.json new file mode 100644 index 0000000..695672c --- /dev/null +++ b/corpus/wire-format/framing/empty-payload.json @@ -0,0 +1,33 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Framing - Empty Payload Rejection", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Verifies that packets with zero-length payload are rejected. The reference implementation (Packet::readFrom) checks 'if (i >= len) return false' after reading header+transport+path_len+path, requiring at least 1 byte of payload.", + "vectors": [ + { + "id": "ep-001", + "description": "FLOOD packet with no payload bytes (just header + path_len)", + "type": "invalid", + "binary": "0D 00", + "expected_error": "empty_payload", + "notes": "2 bytes total: header 0x0D + path_len 0x00. No payload bytes remain. readFrom returns false because i(2) >= len(2)." + }, + { + "id": "ep-002", + "description": "TRANSPORT_FLOOD with no payload (header + transport + path_len only)", + "type": "invalid", + "binary": "0C 0000 0000 00", + "expected_error": "empty_payload", + "notes": "6 bytes: header(1) + transport(4) + path_len(1). i=6, len=6, so i>=len = true." + }, + { + "id": "ep-003", + "description": "FLOOD packet with path but no payload", + "type": "invalid", + "binary": "0D 02 AABB", + "expected_error": "empty_payload", + "notes": "4 bytes: header(1) + path_len(1) + path(2). i=4, len=4." + } + ] +} diff --git a/corpus/wire-format/framing/maximal.json b/corpus/wire-format/framing/maximal.json new file mode 100644 index 0000000..204dc71 --- /dev/null +++ b/corpus/wire-format/framing/maximal.json @@ -0,0 +1,139 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Framing - Maximum Size Packets", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests packets at or near the maximum wire size of 255 bytes (MAX_TRANS_UNIT). Verifies size calculation: total = 1 (header) + transport(0|4) + 1 (path_len) + path_bytes + payload_len.", + "vectors": [ + { + "id": "max-001", + "description": "Maximum FLOOD packet: 255 bytes with empty path and 253-byte payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFC" + } + }, + "binary": "3D 00 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFC", + "notes": "Total: 1 + 0 + 1 + 0 + 253 = 255 bytes. However payload exceeds MAX_PACKET_PAYLOAD (184). This is a wire-level max size test only; payload-level validation would reject this." + }, + { + "id": "max-002", + "description": "Maximum valid FLOOD packet respecting MAX_PACKET_PAYLOAD: 1 + 1 + 0 + 184 = 186 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7" + } + }, + "binary": "3D 00 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7", + "notes": "Total: 1 + 1 + 184 = 186 bytes. Payload is exactly MAX_PACKET_PAYLOAD (184 bytes = 0x00..0xB7)." + }, + { + "id": "max-003", + "description": "Large FLOOD packet with maximum path (63 bytes) and payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 63, + "hashes": [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "0A", + "0B", + "0C", + "0D", + "0E", + "0F", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "1A", + "1B", + "1C", + "1D", + "1E", + "1F", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "2A", + "2B", + "2C", + "2D", + "2E", + "2F", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "3A", + "3B", + "3C", + "3D", + "3E", + "3F" + ] + }, + "payload": { + "data": "DEADBEEF" + } + }, + "binary": "3D 3F 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F DEADBEEF", + "notes": "Total: 1 + 1 + 63 + 4 = 69 bytes. Max path with small payload." + } + ] +} diff --git a/corpus/wire-format/framing/minimal.json b/corpus/wire-format/framing/minimal.json new file mode 100644 index 0000000..158ee17 --- /dev/null +++ b/corpus/wire-format/framing/minimal.json @@ -0,0 +1,127 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Framing - Minimal Valid Packets", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests the smallest valid packets for each route type. A valid packet must have at least header(1) + path_len(1) + payload(1+) bytes, plus 4 bytes for transport codes if applicable.", + "vectors": [ + { + "id": "min-001", + "description": "Minimal FLOOD packet: 3 bytes (header + path_len + 1-byte payload)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "3D 00 FF", + "notes": "Smallest valid FLOOD packet: 3 bytes total." + }, + { + "id": "min-002", + "description": "Minimal DIRECT packet: 3 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "3E 00 FF", + "notes": "Smallest valid DIRECT packet: 3 bytes total." + }, + { + "id": "min-003", + "description": "Minimal TRANSPORT_FLOOD packet: 7 bytes (header + 4 transport + path_len + 1-byte payload)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "transport_flood" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "3C 0000 0000 00 FF", + "notes": "Smallest valid TRANSPORT_FLOOD packet: 7 bytes total." + }, + { + "id": "min-004", + "description": "Minimal TRANSPORT_DIRECT packet: 7 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "transport_direct" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "3F 0000 0000 00 FF", + "notes": "Smallest valid TRANSPORT_DIRECT packet: 7 bytes total." + }, + { + "id": "min-005", + "description": "Minimal ACK via FLOOD: 6 bytes (header + path_len + 4-byte CRC)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000000" + } + }, + "binary": "0D 00 00000000", + "notes": "ACK with zero CRC. 6 bytes total." + } + ] +} diff --git a/corpus/wire-format/header/combined.json b/corpus/wire-format/header/combined.json new file mode 100644 index 0000000..4f7eee4 --- /dev/null +++ b/corpus/wire-format/header/combined.json @@ -0,0 +1,165 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Header - Combined Field Encoding", + "spec_ref": "spec/02-header.md", + "protocol_version": 0, + "description": "Combinatorial tests of header byte encoding, exercising various combinations of version, payload type, and route type to verify correct bit packing.", + "vectors": [ + { + "id": "hdr-000", + "description": "All-zero header: version=0, request=0, transport_flood=0", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "request", + "route_type": "transport_flood" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "00", + "src_hash": "00", + "cipher_mac": "0000", + "ciphertext": "00000000000000000000000000000000" + } + }, + "binary": "00 0000 0000 00 0000 0000 00000000000000000000000000000000", + "notes": "Header: (0<<6)|(0<<2)|0 = 0x00." + }, + { + "id": "hdr-001", + "description": "Version=0, ADVERT, DIRECT route", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 2, + "hashes": [ + "AA", + "BB" + ] + }, + "payload": { + "data": "FF" + } + }, + "binary": "12 02 AABB FF", + "notes": "Header: (0<<6)|(4<<2)|2 = 0x12. path_len=0x02 (hash_size=1, count=2). Path: AA BB." + }, + { + "id": "hdr-002", + "description": "Version=1, CONTROL, FLOOD route", + "type": "encode_decode", + "structured": { + "header": { + "version": 1, + "payload_type": "control", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "6D 00 FF", + "notes": "Header: (1<<6)|(11<<2)|1 = 0x40+0x2C+0x01 = 0x6D." + }, + { + "id": "hdr-003", + "description": "Version=0, RAW_CUSTOM, TRANSPORT_DIRECT with non-trivial transport codes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "transport_direct" + }, + "transport_codes": [ + 256, + 512 + ], + "path": { + "hash_size": 1, + "hash_count": 1, + "hashes": [ + "AA" + ] + }, + "payload": { + "data": "CAFEBABE" + } + }, + "binary": "3F 0001 0002 01 AA CAFEBABE", + "notes": "Header: (0<<6)|(15<<2)|3 = 0x3F. Transport codes: 256=0x0100 LE=0001, 512=0x0200 LE=0002." + }, + { + "id": "hdr-004", + "description": "Version=0, TRACE, FLOOD with path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 3, + "hashes": [ + "AA", + "BB", + "CC" + ] + }, + "payload": { + "data": "0102030405060708 09" + } + }, + "binary": "25 03 AABBCC 010203040506070809", + "notes": "Header: (0<<6)|(9<<2)|1 = 0x25. path_len=0x03 (1-byte hashes, 3 hops)." + }, + { + "id": "hdr-005", + "description": "Maximum header value that isn't 0xFF sentinel: version=2, RAW_CUSTOM, TRANSPORT_DIRECT", + "type": "encode_decode", + "structured": { + "header": { + "version": 2, + "payload_type": "raw_custom", + "route_type": "transport_direct" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "AA" + } + }, + "binary": "BF 0000 0000 00 AA", + "notes": "Header: (2<<6)|(15<<2)|3 = 0x80+0x3C+0x03 = 0xBF." + } + ] +} diff --git a/corpus/wire-format/header/payload-types.json b/corpus/wire-format/header/payload-types.json new file mode 100644 index 0000000..808daa9 --- /dev/null +++ b/corpus/wire-format/header/payload-types.json @@ -0,0 +1,308 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Header - Payload Types", + "spec_ref": "spec/02-header.md", + "protocol_version": 0, + "description": "Tests encoding of all defined payload types in the header byte. Uses FLOOD routing and minimal payloads to isolate header encoding.", + "vectors": [ + { + "id": "pt-000", + "description": "Payload type REQUEST (0x00) - header byte encodes type in bits 2-5", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "request", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "1122", + "ciphertext": "DEADBEEFCAFEBABE0102030405060708" + } + }, + "binary": "01 00 ABCD 1122 DEADBEEFCAFEBABE0102030405060708", + "notes": "Header: (0<<6)|(0<<2)|1 = 0x01. Payload: dest_hash(1) + src_hash(1) + MAC(2) + 16 bytes ciphertext." + }, + { + "id": "pt-001", + "description": "Payload type RESPONSE (0x01)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "response", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "1122", + "ciphertext": "DEADBEEFCAFEBABE0102030405060708" + } + }, + "binary": "05 00 ABCD 1122 DEADBEEFCAFEBABE0102030405060708", + "notes": "Header: (0<<6)|(1<<2)|1 = 0x05." + }, + { + "id": "pt-002", + "description": "Payload type TXT_MSG (0x02)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "txt_msg", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "dest_hash": "AB", + "src_hash": "CD", + "cipher_mac": "1122", + "ciphertext": "DEADBEEFCAFEBABE0102030405060708" + } + }, + "binary": "09 00 ABCD 1122 DEADBEEFCAFEBABE0102030405060708", + "notes": "Header: (0<<6)|(2<<2)|1 = 0x09." + }, + { + "id": "pt-003", + "description": "Payload type ACK (0x03)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "DEADBEEF" + } + }, + "binary": "0D 00 EFBEADDE", + "notes": "Header: (0<<6)|(3<<2)|1 = 0x0D. CRC 0xDEADBEEF as LE = EFBEADDE." + }, + { + "id": "pt-004", + "description": "Payload type ADVERT (0x04) - minimal advert with 1 byte payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "advert", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "11 00 FF", + "notes": "Header: (0<<6)|(4<<2)|1 = 0x11. Using raw payload to test header encoding in isolation." + }, + { + "id": "pt-005", + "description": "Payload type GRP_TXT (0x05)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "grp_txt", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "channel_hash": "AB", + "cipher_mac": "1122", + "ciphertext": "DEADBEEFCAFEBABE0102030405060708" + } + }, + "binary": "15 00 AB 1122 DEADBEEFCAFEBABE0102030405060708", + "notes": "Header: (0<<6)|(5<<2)|1 = 0x15." + }, + { + "id": "pt-006", + "description": "Payload type GRP_DATA (0x06)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "grp_data", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "channel_hash": "AB", + "cipher_mac": "1122", + "ciphertext": "DEADBEEFCAFEBABE0102030405060708" + } + }, + "binary": "19 00 AB 1122 DEADBEEFCAFEBABE0102030405060708", + "notes": "Header: (0<<6)|(6<<2)|1 = 0x19." + }, + { + "id": "pt-007", + "description": "Payload type ANON_REQ (0x07)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "anon_req", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "1D 00 FF", + "notes": "Header: (0<<6)|(7<<2)|1 = 0x1D. Minimal payload to test header." + }, + { + "id": "pt-008", + "description": "Payload type PATH (0x08)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "path", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "21 00 FF", + "notes": "Header: (0<<6)|(8<<2)|1 = 0x21." + }, + { + "id": "pt-009", + "description": "Payload type TRACE (0x09)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "trace", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "25 00 FF", + "notes": "Header: (0<<6)|(9<<2)|1 = 0x25." + }, + { + "id": "pt-010", + "description": "Payload type MULTIPART (0x0A)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "multipart", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "29 00 FF", + "notes": "Header: (0<<6)|(10<<2)|1 = 0x29." + }, + { + "id": "pt-011", + "description": "Payload type CONTROL (0x0B)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "control", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "FF" + } + }, + "binary": "2D 00 FF", + "notes": "Header: (0<<6)|(11<<2)|1 = 0x2D." + }, + { + "id": "pt-012", + "description": "Payload type RAW_CUSTOM (0x0F)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "raw_custom", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "data": "CAFEBABE" + } + }, + "binary": "3D 00 CAFEBABE", + "notes": "Header: (0<<6)|(15<<2)|1 = 0x3D." + } + ] +} diff --git a/corpus/wire-format/header/route-types.json b/corpus/wire-format/header/route-types.json new file mode 100644 index 0000000..0146a50 --- /dev/null +++ b/corpus/wire-format/header/route-types.json @@ -0,0 +1,131 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Header - Route Types", + "spec_ref": "spec/02-header.md", + "protocol_version": 0, + "description": "Tests encoding and decoding of all 4 route types in the header byte, using ACK (payload_type=3) as a simple payload.", + "vectors": [ + { + "id": "rt-000", + "description": "Route type TRANSPORT_FLOOD (0x00) with ACK payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_flood" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "01020304" + } + }, + "binary": "0C 0000 0000 00 04030201", + "notes": "Header: version=0, payload_type=3 (ACK), route_type=0 (TRANSPORT_FLOOD). 0x0C = (0<<6)|(3<<2)|0. Transport codes both zero. path_len=0x00 (hash_size=1, count=0). ACK CRC 0x01020304 as LE = 04030201." + }, + { + "id": "rt-001", + "description": "Route type FLOOD (0x01) with ACK payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "01020304" + } + }, + "binary": "0D 00 04030201", + "notes": "Header: 0x0D = (0<<6)|(3<<2)|1. No transport codes. path_len=0x00. ACK CRC as LE." + }, + { + "id": "rt-002", + "description": "Route type DIRECT (0x02) with ACK payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "01020304" + } + }, + "binary": "0E 00 04030201", + "notes": "Header: 0x0E = (0<<6)|(3<<2)|2. No transport codes." + }, + { + "id": "rt-003", + "description": "Route type TRANSPORT_DIRECT (0x03) with ACK payload", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_direct" + }, + "transport_codes": [ + 4660, + 22136 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "01020304" + } + }, + "binary": "0F 3412 7856 00 04030201", + "notes": "Header: 0x0F = (0<<6)|(3<<2)|3. Transport codes: 4660=0x1234 as LE=3412, 22136=0x5678 as LE=7856." + }, + { + "id": "rt-004", + "description": "TRANSPORT_FLOOD with non-zero transport codes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_flood" + }, + "transport_codes": [ + 65535, + 1 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "FFFFFFFF" + } + }, + "binary": "0C FFFF 0100 00 FFFFFFFF", + "notes": "Transport code 1: 0xFFFF as LE = FFFF. Transport code 2: 1 as LE = 0100. CRC 0xFFFFFFFF as LE." + } + ] +} diff --git a/corpus/wire-format/header/versions.json b/corpus/wire-format/header/versions.json new file mode 100644 index 0000000..ebca863 --- /dev/null +++ b/corpus/wire-format/header/versions.json @@ -0,0 +1,97 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Header - Protocol Versions", + "spec_ref": "spec/02-header.md", + "protocol_version": 0, + "description": "Tests encoding of all 4 protocol version values in header bits 6-7. Uses ACK (type=3) with FLOOD routing as a simple baseline.", + "vectors": [ + { + "id": "ver-000", + "description": "Version 0 (V1, current) - bits 6-7 = 00", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 00 01000000", + "notes": "Header: (0<<6)|(3<<2)|1 = 0x0D. Version 0 = V1." + }, + { + "id": "ver-001", + "description": "Version 1 (V2, reserved) - bits 6-7 = 01", + "type": "encode_decode", + "structured": { + "header": { + "version": 1, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "4D 00 01000000", + "notes": "Header: (1<<6)|(3<<2)|1 = 0x4D. Version 1 = V2 (reserved)." + }, + { + "id": "ver-002", + "description": "Version 2 (V3, reserved) - bits 6-7 = 10", + "type": "encode_decode", + "structured": { + "header": { + "version": 2, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "8D 00 01000000", + "notes": "Header: (2<<6)|(3<<2)|1 = 0x8D. Version 2 = V3 (reserved)." + }, + { + "id": "ver-003", + "description": "Version 3 (V4, reserved) - bits 6-7 = 11", + "type": "encode_decode", + "structured": { + "header": { + "version": 3, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "CD 00 01000000", + "notes": "Header: (3<<6)|(3<<2)|1 = 0xCD. Version 3 = V4 (reserved)." + } + ] +} diff --git a/corpus/wire-format/invalid/bad-path-len.json b/corpus/wire-format/invalid/bad-path-len.json new file mode 100644 index 0000000..58d5588 --- /dev/null +++ b/corpus/wire-format/invalid/bad-path-len.json @@ -0,0 +1,57 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Invalid - Bad Path Length Encoding", + "spec_ref": "spec/03-path.md", + "protocol_version": 0, + "description": "Tests invalid path_len values including the reserved hash_size=4 and path overflow conditions.", + "vectors": [ + { + "id": "bpl-001", + "description": "Reserved hash size code 3 (hash_size would be 4): path_len=0xC0", + "type": "invalid", + "binary": "0D C0 01000000", + "expected_error": "reserved_hash_size", + "notes": "path_len=0xC0: bits 6-7=11 (hash size code 3). isValidPathLen returns false because hash_size==4." + }, + { + "id": "bpl-002", + "description": "Reserved hash size code 3 with non-zero count: path_len=0xC1", + "type": "invalid", + "binary": "0D C1 AA 01000000", + "expected_error": "reserved_hash_size", + "notes": "path_len=0xC1: hash size code 3 (reserved), count=1. Invalid regardless of count." + }, + { + "id": "bpl-003", + "description": "Reserved hash size code 3, max count: path_len=0xFF", + "type": "invalid", + "binary": "0D FF 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F 01000000", + "expected_error": "reserved_hash_size", + "notes": "path_len=0xFF: hash size code 3 (reserved), count=63. Even if hash_size=4 were valid, 63*4=252 > MAX_PATH_SIZE." + }, + { + "id": "bpl-004", + "description": "Path overflow: hash_size=2, count=33 (66 bytes > MAX_PATH_SIZE)", + "type": "invalid", + "binary": "0D 61 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F30313233343536373839 3A3B3C3D3E3F4041 01000000", + "expected_error": "path_overflow", + "notes": "path_len=0x61: hash_size=2, count=33. 33*2=66 > MAX_PATH_SIZE (64). isValidPathLen returns false." + }, + { + "id": "bpl-005", + "description": "Path overflow: hash_size=3, count=22 (66 bytes > MAX_PATH_SIZE)", + "type": "invalid", + "binary": "0D 96 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F4041 01000000", + "expected_error": "path_overflow", + "notes": "path_len=0x96: hash_size=3, count=22. 22*3=66 > MAX_PATH_SIZE (64)." + }, + { + "id": "bpl-006", + "description": "Path overflow edge case: hash_size=1, count=63 is valid (63<=64), but with hash_size=2 and count=33 it overflows", + "type": "invalid", + "binary": "0D 61 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F3031323334353637383940 4142 DEADBEEF", + "expected_error": "path_overflow", + "notes": "path_len=0x61: size code 1 (hash_size=2), count 33. 33*2=66 > 64." + } + ] +} diff --git a/corpus/wire-format/invalid/too-short.json b/corpus/wire-format/invalid/too-short.json new file mode 100644 index 0000000..eceaa1b --- /dev/null +++ b/corpus/wire-format/invalid/too-short.json @@ -0,0 +1,49 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Invalid - Too Short Packets", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests packets that are shorter than the minimum required size for their route type.", + "vectors": [ + { + "id": "short-001", + "description": "1-byte packet (FLOOD): only header, missing path_len and payload", + "type": "invalid", + "binary": "0D", + "expected_error": "too_short", + "notes": "Minimum FLOOD packet is 3 bytes. This is 1." + }, + { + "id": "short-002", + "description": "2-byte packet (FLOOD): header + path_len, missing payload", + "type": "invalid", + "binary": "0D 00", + "expected_error": "empty_payload", + "notes": "Header + path_len present but no payload. i=2, len=2, so i>=len." + }, + { + "id": "short-003", + "description": "5-byte TRANSPORT_FLOOD: header + transport codes, missing path_len", + "type": "invalid", + "binary": "0C 00000000", + "expected_error": "too_short", + "notes": "TRANSPORT_FLOOD needs at least 7 bytes (1+4+1+1). This has 5." + }, + { + "id": "short-004", + "description": "6-byte TRANSPORT_FLOOD: header + transport + path_len, missing payload", + "type": "invalid", + "binary": "0C 00000000 00", + "expected_error": "empty_payload", + "notes": "All framing present but zero payload." + }, + { + "id": "short-005", + "description": "FLOOD with path consuming all remaining bytes", + "type": "invalid", + "binary": "0D 03 AABBCC", + "expected_error": "empty_payload", + "notes": "header(1) + path_len(1) + path(3) = 5 bytes = total. i=5, len=5." + } + ] +} diff --git a/corpus/wire-format/invalid/truncated.json b/corpus/wire-format/invalid/truncated.json new file mode 100644 index 0000000..e38dc72 --- /dev/null +++ b/corpus/wire-format/invalid/truncated.json @@ -0,0 +1,65 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Invalid - Truncated Packets", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests packets that are truncated at various points, verifying that decoders reject them gracefully.", + "vectors": [ + { + "id": "trunc-001", + "description": "Empty packet (0 bytes)", + "type": "invalid", + "binary": "", + "expected_error": "too_short", + "notes": "Zero-length input. Cannot read header." + }, + { + "id": "trunc-002", + "description": "Header only (1 byte, FLOOD route)", + "type": "invalid", + "binary": "0D", + "expected_error": "too_short", + "notes": "Only header byte. Cannot read path_len." + }, + { + "id": "trunc-003", + "description": "Header only (1 byte, TRANSPORT_FLOOD route) - transport codes truncated", + "type": "invalid", + "binary": "0C", + "expected_error": "too_short", + "notes": "Route type 0x00 requires 4 transport code bytes, but only header is present." + }, + { + "id": "trunc-004", + "description": "TRANSPORT_FLOOD with partial transport codes (2 of 4 bytes)", + "type": "invalid", + "binary": "0C 1234", + "expected_error": "too_short", + "notes": "Only 2 of 4 transport code bytes present." + }, + { + "id": "trunc-005", + "description": "TRANSPORT_FLOOD with transport codes but no path_len", + "type": "invalid", + "binary": "0C 0000 0000", + "expected_error": "too_short", + "notes": "5 bytes: header + 4 transport code bytes. Missing path_len." + }, + { + "id": "trunc-006", + "description": "FLOOD with path_len indicating 3 hops but only 1 path byte", + "type": "invalid", + "binary": "0D 03 AA FF", + "expected_error": "truncated_path", + "notes": "path_len=0x03 means 3 bytes of path expected but only 1 byte follows before the alleged payload. The decoder would read 3 path bytes (AA FF ??) and run out." + }, + { + "id": "trunc-007", + "description": "FLOOD with 2-byte hash path_len=0x42 (4 path bytes needed) but only 2 available", + "type": "invalid", + "binary": "0D 42 AABB FF", + "expected_error": "truncated_path", + "notes": "path_len=0x42: hash_size=2, count=2, need 4 path bytes. Only 2 bytes before payload marker." + } + ] +} diff --git a/corpus/wire-format/path/boundary.json b/corpus/wire-format/path/boundary.json new file mode 100644 index 0000000..55063c4 --- /dev/null +++ b/corpus/wire-format/path/boundary.json @@ -0,0 +1,156 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Path - Boundary Conditions", + "spec_ref": "spec/03-path.md", + "protocol_version": 0, + "description": "Tests boundary conditions for path encoding including maximum valid values and values just below overflow thresholds.", + "vectors": [ + { + "id": "pb-001", + "description": "hash_size=2, count=31 (62 bytes, 2 below max)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 31, + "hashes": [ + "0001", + "0203", + "0405", + "0607", + "0809", + "0A0B", + "0C0D", + "0E0F", + "1011", + "1213", + "1415", + "1617", + "1819", + "1A1B", + "1C1D", + "1E1F", + "2021", + "2223", + "2425", + "2627", + "2829", + "2A2B", + "2C2D", + "2E2F", + "3031", + "3233", + "3435", + "3637", + "3839", + "3A3B", + "3C3D" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 5F 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D 01000000", + "notes": "path_len=0x5F: hash_size=2, count=31. Path: 62 bytes. Just below max." + }, + { + "id": "pb-002", + "description": "hash_size=3, count=20 (60 bytes, 3 below max for this hash_size)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 20, + "hashes": [ + "000102", + "030405", + "060708", + "090A0B", + "0C0D0E", + "0F1011", + "121314", + "151617", + "18191A", + "1B1C1D", + "1E1F20", + "212223", + "242526", + "272829", + "2A2B2C", + "2D2E2F", + "303132", + "333435", + "363738", + "393A3B" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 94 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F30313233343536373839 3A3B 01000000", + "notes": "path_len=0x94: hash_size=3, count=20. Path: 60 bytes." + }, + { + "id": "pb-003", + "description": "Single-byte path: hash_size=1, count=1", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 1, + "hashes": [ + "00" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 01 00 01000000", + "notes": "Minimal non-empty path. Hash value 0x00 is valid." + }, + { + "id": "pb-004", + "description": "Path with all-FF hashes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 3, + "hashes": [ + "FF", + "FF", + "FF" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 03 FFFFFF 01000000", + "notes": "All hashes are 0xFF. Valid encoding." + } + ] +} diff --git a/corpus/wire-format/path/hash-counts.json b/corpus/wire-format/path/hash-counts.json new file mode 100644 index 0000000..620d29a --- /dev/null +++ b/corpus/wire-format/path/hash-counts.json @@ -0,0 +1,260 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Path - Hash Counts", + "spec_ref": "spec/03-path.md", + "protocol_version": 0, + "description": "Tests various hash count values including zero, small, and maximum values for each hash size.", + "vectors": [ + { + "id": "hc-000", + "description": "Zero hops (hash_count=0, hash_size=1) - empty path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 00 01000000", + "notes": "path_len=0x00. Zero-length path." + }, + { + "id": "hc-001", + "description": "Zero hops with hash_size=2 - still empty path", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 40 01000000", + "notes": "path_len=0x40: hash_size=2, count=0. Path is still 0 bytes even though hash_size>1." + }, + { + "id": "hc-002", + "description": "Zero hops with hash_size=3", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 80 01000000", + "notes": "path_len=0x80: hash_size=3, count=0. Path is 0 bytes." + }, + { + "id": "hc-010", + "description": "Maximum count for hash_size=1: 63 hops, 63 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 63, + "hashes": [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "0A", + "0B", + "0C", + "0D", + "0E", + "0F", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "1A", + "1B", + "1C", + "1D", + "1E", + "1F", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "2A", + "2B", + "2C", + "2D", + "2E", + "2F", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "3A", + "3B", + "3C", + "3D", + "3E", + "3F" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 3F 0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F 01000000", + "notes": "path_len=0x3F: hash_size=1, count=63. Path: 63 bytes. Maximum valid path for 1-byte hashes." + }, + { + "id": "hc-020", + "description": "Maximum count for hash_size=2: 32 hops, 64 bytes (exactly MAX_PATH_SIZE)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 32, + "hashes": [ + "0001", + "0203", + "0405", + "0607", + "0809", + "0A0B", + "0C0D", + "0E0F", + "1011", + "1213", + "1415", + "1617", + "1819", + "1A1B", + "1C1D", + "1E1F", + "2021", + "2223", + "2425", + "2627", + "2829", + "2A2B", + "2C2D", + "2E2F", + "3031", + "3233", + "3435", + "3637", + "3839", + "3A3B", + "3C3D", + "3E3F" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 60 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F 01000000", + "notes": "path_len=0x60: bits 6-7=01 (hash_size=2), bits 0-5=32. Path: 64 bytes = MAX_PATH_SIZE. Maximum valid for 2-byte hashes." + }, + { + "id": "hc-030", + "description": "Maximum count for hash_size=3: 21 hops, 63 bytes", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 21, + "hashes": [ + "000102", + "030405", + "060708", + "090A0B", + "0C0D0E", + "0F1011", + "121314", + "151617", + "18191A", + "1B1C1D", + "1E1F20", + "212223", + "242526", + "272829", + "2A2B2C", + "2D2E2F", + "303132", + "333435", + "363738", + "393A3B", + "3C3D3E" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 95 000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E 01000000", + "notes": "path_len=0x95: bits 6-7=10 (hash_size=3), bits 0-5=21. Path: 63 bytes. Maximum valid for 3-byte hashes (22*3=66 would overflow)." + } + ] +} diff --git a/corpus/wire-format/path/hash-sizes.json b/corpus/wire-format/path/hash-sizes.json new file mode 100644 index 0000000..783759c --- /dev/null +++ b/corpus/wire-format/path/hash-sizes.json @@ -0,0 +1,188 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Path - Hash Sizes", + "spec_ref": "spec/03-path.md", + "protocol_version": 0, + "description": "Tests all valid hash sizes (1, 2, 3 bytes) in the path length encoding. These vectors are critical for catching implementations that treat path_len as a raw byte count.", + "vectors": [ + { + "id": "hs-001", + "description": "Hash size 1 (code 0), 1 hop", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 1, + "hashes": [ + "AA" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 01 AA 01000000", + "notes": "path_len=0x01: bits 6-7=00 (size code 0, hash_size=1), bits 0-5=1 (count=1). Path: 1 byte." + }, + { + "id": "hs-002", + "description": "Hash size 1 (code 0), 5 hops", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 5, + "hashes": [ + "AA", + "BB", + "CC", + "DD", + "EE" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 05 AABBCCDDEE 01000000", + "notes": "path_len=0x05: hash_size=1, count=5. Path: 5 bytes." + }, + { + "id": "hs-010", + "description": "Hash size 2 (code 1), 1 hop", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 1, + "hashes": [ + "AABB" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 41 AABB 01000000", + "notes": "path_len=0x41: bits 6-7=01 (size code 1, hash_size=2), bits 0-5=1 (count=1). Path: 2 bytes. An implementation treating 0x41=65 as raw byte count would fail." + }, + { + "id": "hs-011", + "description": "Hash size 2 (code 1), 2 hops", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 2, + "hashes": [ + "AABB", + "CCDD" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 42 AABBCCDD 01000000", + "notes": "path_len=0x42: hash_size=2, count=2. Path: 4 bytes. This is the example that catches the meshcore-rs bug (0x42=66 raw vs 4 actual bytes)." + }, + { + "id": "hs-012", + "description": "Hash size 2 (code 1), 5 hops", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 2, + "hash_count": 5, + "hashes": [ + "1122", + "3344", + "5566", + "7788", + "99AA" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 45 112233445566778899AA 01000000", + "notes": "path_len=0x45: hash_size=2, count=5. Path: 10 bytes." + }, + { + "id": "hs-020", + "description": "Hash size 3 (code 2), 1 hop", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 1, + "hashes": [ + "AABBCC" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 81 AABBCC 01000000", + "notes": "path_len=0x81: bits 6-7=10 (size code 2, hash_size=3), bits 0-5=1. Path: 3 bytes." + }, + { + "id": "hs-021", + "description": "Hash size 3 (code 2), 3 hops", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 3, + "hash_count": 3, + "hashes": [ + "AABBCC", + "DDEEFF", + "112233" + ] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 83 AABBCC DDEEFF 112233 01000000", + "notes": "path_len=0x83: hash_size=3, count=3. Path: 9 bytes." + } + ] +} diff --git a/corpus/wire-format/transport-codes/basic.json b/corpus/wire-format/transport-codes/basic.json new file mode 100644 index 0000000..a8e4ca0 --- /dev/null +++ b/corpus/wire-format/transport-codes/basic.json @@ -0,0 +1,183 @@ +{ + "$schema": "../../schema/test-vector.schema.json", + "name": "Transport Codes - Basic", + "spec_ref": "spec/01-wire-format.md", + "protocol_version": 0, + "description": "Tests transport code encoding with various values and route types that include/exclude transport codes.", + "vectors": [ + { + "id": "tc-001", + "description": "Transport codes with TRANSPORT_FLOOD, both zero", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_flood" + }, + "transport_codes": [ + 0, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0C 0000 0000 00 01000000", + "notes": "Both transport codes zero." + }, + { + "id": "tc-002", + "description": "Transport codes with TRANSPORT_DIRECT, typical values", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_direct" + }, + "transport_codes": [ + 1000, + 2000 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0F E803 D007 00 01000000", + "notes": "TC1: 1000=0x03E8 LE=E803. TC2: 2000=0x07D0 LE=D007." + }, + { + "id": "tc-003", + "description": "Transport codes at maximum uint16 values", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_flood" + }, + "transport_codes": [ + 65535, + 65535 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0C FFFF FFFF 00 01000000", + "notes": "Both transport codes at maximum: 0xFFFF." + }, + { + "id": "tc-004", + "description": "Transport codes: code1=1, code2=0", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_flood" + }, + "transport_codes": [ + 1, + 0 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0C 0100 0000 00 01000000", + "notes": "TC1=1 LE=0100, TC2=0." + }, + { + "id": "tc-005", + "description": "Transport codes: code1=0x8000, code2=0x0001 (verify high bit handling)", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "transport_direct" + }, + "transport_codes": [ + 32768, + 1 + ], + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0F 0080 0100 00 01000000", + "notes": "TC1: 0x8000 LE=0080. TC2: 1 LE=0100." + }, + { + "id": "tc-006", + "description": "FLOOD route (no transport codes) - verify they are absent from wire", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "flood" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0D 00 01000000", + "notes": "FLOOD route: no transport codes on wire. Total: 1 (header) + 1 (path_len) + 4 (ACK) = 6 bytes." + }, + { + "id": "tc-007", + "description": "DIRECT route (no transport codes) - verify they are absent from wire", + "type": "encode_decode", + "structured": { + "header": { + "version": 0, + "payload_type": "ack", + "route_type": "direct" + }, + "path": { + "hash_size": 1, + "hash_count": 0, + "hashes": [] + }, + "payload": { + "ack_crc": "00000001" + } + }, + "binary": "0E 00 01000000", + "notes": "DIRECT route: no transport codes on wire." + } + ] +} diff --git a/spec/00-overview.md b/spec/00-overview.md new file mode 100644 index 0000000..6ead710 --- /dev/null +++ b/spec/00-overview.md @@ -0,0 +1,135 @@ +# MeshCore Protocol Specification + +## Section 0: Overview + +### Status + +This document specifies the MeshCore mesh networking protocol as implemented in +the [reference firmware](https://github.com/meshcore-dev/MeshCore). It is +derived from the source code and official documentation, and serves as the +authoritative definition of the wire format for conformance testing. + +### Scope + +This specification covers: + +- The packet wire format (framing, header, path, payload) +- All payload types and their binary encodings +- Cryptographic operations (encryption, signing, key exchange, hashing) +- The BLE/Serial companion protocol +- The KISS modem protocol +- The bridge protocol (RS232/ESP-NOW) + +It does NOT cover: + +- Application-level behavior (e.g., room server logic, repeater policies) +- Radio-layer parameters (LoRa spreading factor, bandwidth, frequency) +- Duty cycle management or transmission scheduling +- User interface or companion app behavior + +### Terminology + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +Additional terms used throughout: + +| Term | Definition | +|------|-----------| +| **Packet** | The fundamental transmission unit in MeshCore. | +| **Header** | The first byte of a packet, encoding route type, payload type, and protocol version. | +| **Path** | A sequence of node hashes representing the route a packet has taken or should follow. | +| **Hash** | A truncated prefix of a node's Ed25519 public key, used for routing. | +| **Payload** | The data portion of a packet, whose structure depends on the payload type. | +| **Transport Codes** | Optional 4-byte field present in transport-mode packets, used for regional scoping. | +| **MAC** | Message Authentication Code — a 2-byte truncated HMAC-SHA256 used for integrity verification. | +| **MTU** | Maximum Transmission Unit — 255 bytes for MeshCore. | +| **Flood Routing** | Routing mode where packets are broadcast and repeated by intermediate nodes, building up the path as they travel. | +| **Direct Routing** | Routing mode where the path is supplied by the sender and the packet follows a specific route. | + +### Byte Order + +All multi-byte integer fields in MeshCore are encoded in **little-endian** byte +order unless explicitly stated otherwise. The one exception is CayenneLPP sensor +data, which uses big-endian encoding. + +### Notation + +- Binary data is shown as hex bytes separated by spaces: `0D 00 EF BE AD DE` +- Bit fields are shown in binary with MSB first: `0bVVPPPPRR` +- Bit numbering is LSB = bit 0, MSB = bit 7 +- Field sizes are in bytes unless noted as bits +- `[field(N)]` denotes a field of N bytes +- `[field(N)?]` denotes an optional field of N bytes + +### Protocol Constants + +These constants define the fundamental limits of the protocol: + +| Constant | Value | Description | +|----------|-------|-------------| +| `MAX_TRANS_UNIT` | 255 | Maximum packet size on the wire (bytes) | +| `MAX_PACKET_PAYLOAD` | 184 | Maximum payload size (bytes) | +| `MAX_PATH_SIZE` | 64 | Maximum path size (bytes) | +| `PUB_KEY_SIZE` | 32 | Ed25519 public key size (bytes) | +| `PRV_KEY_SIZE` | 64 | Ed25519 private key size (bytes) | +| `SIGNATURE_SIZE` | 64 | Ed25519 signature size (bytes) | +| `CIPHER_KEY_SIZE` | 16 | AES-128 key size (bytes) | +| `CIPHER_BLOCK_SIZE` | 16 | AES-128 block size (bytes) | +| `CIPHER_MAC_SIZE` | 2 | Truncated HMAC-SHA256 MAC size (bytes) | +| `PATH_HASH_SIZE` | 1 | Default path hash size for v1 (bytes) | +| `MAX_HASH_SIZE` | 8 | Maximum hash size for deduplication (bytes) | +| `MAX_ADVERT_DATA_SIZE` | 32 | Maximum advertisement app data size (bytes) | + +### Protocol Versions + +The protocol version is encoded in bits 6-7 of the header byte: + +| Version | Value | Status | Description | +|---------|-------|--------|-------------| +| V1 | `0x00` | Active | 1-byte src/dest hashes, 2-byte MAC | +| V2 | `0x01` | Reserved | Future (e.g., 2-byte hashes, 4-byte MAC) | +| V3 | `0x02` | Reserved | Future | +| V4 | `0x03` | Reserved | Future | + +Currently only V1 is defined. Implementations MUST support V1. +Implementations SHOULD reject packets with unrecognized version values unless +they have explicit support for that version. + +### Document Organization + +| Section | Title | Description | +|---------|-------|-------------| +| 00 | Overview | This document | +| 01 | Wire Format | Packet framing and field layout | +| 02 | Header | Header byte encoding | +| 03 | Path | Path length encoding and path field | +| 04 | Payload: ACK | Acknowledgment payload | +| 05 | Payload: Advertisement | Node advertisement and app data | +| 06 | Payload: Encrypted | REQ/RESPONSE/TXT_MSG payloads | +| 07 | Payload: Anonymous Request | Anonymous request payload | +| 08 | Payload: Group | Group text and data payloads | +| 09 | Payload: Path Return | Encrypted path return payload | +| 10 | Payload: Trace | Path trace payload | +| 11 | Payload: Multipart | Multi-packet payload | +| 12 | Payload: Control | Control and discovery payloads | +| 13 | Payload: Raw Custom | Custom raw payload | +| 14 | Cryptography | AES-128, HMAC-SHA256, encrypt-then-MAC | +| 15 | Identity | Ed25519 keys, ECDH, key hashing | +| 16 | Packet Hash | SHA-256 deduplication hashing | +| 17 | Routing | Flood and direct routing behavior | +| 18 | Companion Protocol | BLE/Serial companion communication | +| 19 | KISS Protocol | KISS modem framing and extensions | +| 20 | Bridge Protocol | RS232/ESP-NOW bridge framing | + +### References + +- [MeshCore Firmware](https://github.com/meshcore-dev/MeshCore) — Reference C++ implementation +- [MeshCore Documentation](https://docs.meshcore.io) — Official protocol documentation +- [MeshCore.js](https://github.com/meshcore-dev/meshcore.js) — JavaScript implementation +- [meshcore-rs](https://github.com/Duncaen/meshcore-rs) — Rust implementation (community) +- [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) — Key words for requirement levels +- [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032) — Ed25519 digital signatures +- [FIPS 197](https://csrc.nist.gov/publications/detail/fips/197/final) — AES specification +- [FIPS 180-4](https://csrc.nist.gov/publications/detail/fips/180/4/final) — SHA-256 specification diff --git a/spec/01-wire-format.md b/spec/01-wire-format.md new file mode 100644 index 0000000..f409c2b --- /dev/null +++ b/spec/01-wire-format.md @@ -0,0 +1,140 @@ +# MeshCore Protocol Specification + +## Section 1: Wire Format + +### Overview + +A MeshCore packet is the fundamental transmission unit. It consists of a 1-byte +header, an optional 4-byte transport codes field, a 1-byte path length, a +variable-length path, and a variable-length payload. The maximum packet size on +the wire is 255 bytes (MAX_TRANS_UNIT). + +### Packet Layout + +``` + 0 1 2 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 ... ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Header | Transport Codes (opt, 4B) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Path Length | Path (variable) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Payload (variable) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +The fields appear in this order on the wire: + +``` +[header(1)][transport_codes(4)?][path_len(1)][path(0-64)][payload(1-184)] +``` + +### Field Summary + +| Field | Size | Required | Description | +|-------|------|----------|-------------| +| Header | 1 byte | Yes | Route type, payload type, protocol version (see [Section 2](02-header.md)) | +| Transport Codes | 4 bytes | Conditional | Two uint16_le values; present only when route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03) | +| Path Length | 1 byte | Yes | Encoded hash count and hash size (see [Section 3](03-path.md)) | +| Path | 0-64 bytes | Yes | Sequence of node hashes; length = hash_count × hash_size | +| Payload | 1-184 bytes | Yes | Packet data; structure depends on payload type | + +### Transport Codes + +Transport codes are present if and only if the route type (header bits 0-1) is +`TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03). + +When present, transport codes consist of two little-endian 16-bit unsigned +integers, for a total of 4 bytes: + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Code 1 (uint16_le) | Code 2 (uint16_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +- **Code 1**: Calculated from regional scope parameters +- **Code 2**: Reserved (typically 0) + +When transport codes are not present (route types `FLOOD` and `DIRECT`), the +implementation MUST set both transport code values to zero internally. + +### Encoding Algorithm + +To encode a packet to wire format: + +1. Write the header byte. +2. If the route type is `TRANSPORT_FLOOD` or `TRANSPORT_DIRECT`: + a. Write transport_codes[0] as uint16_le (2 bytes). + b. Write transport_codes[1] as uint16_le (2 bytes). +3. Write the path_len byte. +4. Calculate path byte length as `hash_count × hash_size` (see [Section 3](03-path.md)). +5. Write `path_byte_length` bytes from the path array. +6. Write `payload_len` bytes from the payload array. + +The total wire length is: +``` +1 + (hasTransportCodes ? 4 : 0) + 1 + path_byte_length + payload_len +``` + +This MUST NOT exceed MAX_TRANS_UNIT (255) bytes. + +### Decoding Algorithm + +To decode a packet from wire format: + +1. Read 1 byte as the header. Extract route type from bits 0-1. +2. If route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03): + a. Read 2 bytes as transport_codes[0] (uint16_le). + b. Read 2 bytes as transport_codes[1] (uint16_le). + Otherwise, set both transport codes to zero. +3. Read 1 byte as path_len. +4. Validate path_len (see [Section 3](03-path.md)): + a. Extract hash_size = (path_len >> 6) + 1. If hash_size is 4, the packet + is INVALID. + b. Extract hash_count = path_len & 63. + c. If hash_count × hash_size > MAX_PATH_SIZE (64), the packet is INVALID. +5. Calculate path_byte_length = hash_count × hash_size. +6. Read path_byte_length bytes as the path. +7. Let `i` be the current read position. If `i >= total_length`, the packet is + INVALID (payload MUST contain at least 1 byte). +8. Calculate payload_len = total_length - i. +9. If payload_len > MAX_PACKET_PAYLOAD (184), the packet is INVALID. +10. Read payload_len bytes as the payload. + +### Size Constraints + +| Constraint | Value | Enforcement | +|-----------|-------|-------------| +| Maximum wire length | 255 bytes | Encoder MUST NOT produce packets exceeding this | +| Maximum payload | 184 bytes | Decoder MUST reject payloads exceeding this | +| Maximum path | 64 bytes | Decoder MUST reject paths exceeding this | +| Minimum payload | 1 byte | Decoder MUST reject packets with zero-length payload | +| Minimum packet | 3 bytes | Header (1) + path_len (1) + payload (1 minimum) | + +### Error Conditions + +A conforming decoder MUST reject packets with any of the following: + +1. Total wire length less than 3 bytes (no room for header + path_len + payload) +2. Total wire length less than minimum required for the route type: + - 3 bytes for FLOOD or DIRECT + - 7 bytes for TRANSPORT_FLOOD or TRANSPORT_DIRECT (adds 4 transport code bytes) +3. Invalid path_len encoding (see [Section 3](03-path.md)) +4. Path byte length exceeds remaining bytes +5. Zero-length payload after header, transport codes, path_len, and path +6. Payload length exceeding MAX_PACKET_PAYLOAD (184) + +### Cross-References + +- [Section 2: Header](02-header.md) — Header byte encoding details +- [Section 3: Path](03-path.md) — Path length encoding and path field +- Test vectors: `corpus/wire-format/framing/`, `corpus/wire-format/invalid/` + +### Reference Implementation + +- `Packet::writeTo()` in `src/Packet.cpp` — Encoding +- `Packet::readFrom()` in `src/Packet.cpp` — Decoding +- `Packet::getRawLength()` in `src/Packet.cpp` — Wire length calculation diff --git a/spec/02-header.md b/spec/02-header.md new file mode 100644 index 0000000..f031ff8 --- /dev/null +++ b/spec/02-header.md @@ -0,0 +1,125 @@ +# MeshCore Protocol Specification + +## Section 2: Header + +### Overview + +The header is the first byte of every MeshCore packet. It encodes three fields +using bit packing: the route type (2 bits), the payload type (4 bits), and the +protocol version (2 bits). + +### Bit Layout + +``` + Bit: 7 6 5 4 3 2 1 0 + +---+---+---+---+---+---+---+---+ + | V1 V0| P3 P2 P1 P0| R1 R0| + +---+---+---+---+---+---+---+---+ + | | | + | | +-- Route Type (bits 0-1) + | +-------------- Payload Type (bits 2-5) + +------------------------ Protocol Version (bits 6-7) +``` + +The header byte value is computed as: + +``` +header = (version << 6) | (payload_type << 2) | route_type +``` + +And fields are extracted as: + +``` +route_type = header & 0x03 +payload_type = (header >> 2) & 0x0F +version = (header >> 6) & 0x03 +``` + +### Route Type (bits 0-1) + +The route type determines how the packet is routed through the mesh and whether +transport codes are present. + +| Value | Name | Transport Codes | Description | +|-------|------|----------------|-------------| +| 0x00 | TRANSPORT_FLOOD | Yes (4 bytes) | Flood routing with transport codes for regional scoping | +| 0x01 | FLOOD | No | Standard flood routing; path is built up by intermediate nodes | +| 0x02 | DIRECT | No | Direct routing; path is supplied by the sender | +| 0x03 | TRANSPORT_DIRECT | Yes (4 bytes) | Direct routing with transport codes | + +Transport codes MUST be present in the wire format when route type is 0x00 or +0x03, and MUST NOT be present when route type is 0x01 or 0x02. + +### Payload Type (bits 2-5) + +The payload type identifies the structure of the payload data. + +| Value | Name | Description | +|-------|------|-------------| +| 0x00 | REQUEST | Encrypted request (dest_hash + src_hash + MAC + ciphertext) | +| 0x01 | RESPONSE | Encrypted response to REQ or ANON_REQ | +| 0x02 | TXT_MSG | Encrypted text message | +| 0x03 | ACK | Simple acknowledgment (4-byte CRC) | +| 0x04 | ADVERT | Node identity advertisement | +| 0x05 | GRP_TXT | Encrypted group text message | +| 0x06 | GRP_DATA | Encrypted group datagram | +| 0x07 | ANON_REQ | Anonymous request (full public key instead of hash) | +| 0x08 | PATH | Encrypted returned path information | +| 0x09 | TRACE | Path trace with per-hop SNR collection | +| 0x0A | MULTIPART | One part of a multi-packet sequence | +| 0x0B | CONTROL | Control/discovery packet | +| 0x0C | (reserved) | Reserved for future use | +| 0x0D | (reserved) | Reserved for future use | +| 0x0E | (reserved) | Reserved for future use | +| 0x0F | RAW_CUSTOM | Custom raw bytes for application-defined payloads | + +Implementations SHOULD silently discard packets with reserved payload type values +(0x0C-0x0E) unless they have explicit support for extended types. + +### Protocol Version (bits 6-7) + +| Value | Name | Description | +|-------|------|-------------| +| 0x00 | V1 | Current version: 1-byte src/dest hashes, 2-byte MAC | +| 0x01 | V2 | Reserved for future use | +| 0x02 | V3 | Reserved for future use | +| 0x03 | V4 | Reserved for future use | + +Implementations MUST support version 0x00 (V1). Implementations SHOULD reject +packets with unrecognized version values. + +### Special Header Values + +The header value `0xFF` is used internally as a "do not retransmit" marker. This +is an in-memory sentinel only and MUST NOT appear on the wire. A header of +`0xFF` would decode as version=3, payload_type=0x0F (RAW_CUSTOM), route_type=3 +(TRANSPORT_DIRECT). + +### Encoding Examples + +| Header Byte | Binary | Version | Payload Type | Route Type | +|-------------|--------|---------|-------------|------------| +| `0x01` | `00 000000 01` | V1 (0) | REQUEST (0) | FLOOD (1) | +| `0x05` | `00 000001 01` | V1 (0) | RESPONSE (1) | FLOOD (1) | +| `0x09` | `00 000010 01` | V1 (0) | TXT_MSG (2) | FLOOD (1) | +| `0x0D` | `00 000011 01` | V1 (0) | ACK (3) | FLOOD (1) | +| `0x11` | `00 000100 01` | V1 (0) | ADVERT (4) | FLOOD (1) | +| `0x0C` | `00 000011 00` | V1 (0) | ACK (3) | TRANSPORT_FLOOD (0) | +| `0x0E` | `00 000011 10` | V1 (0) | ACK (3) | DIRECT (2) | +| `0x0F` | `00 000011 11` | V1 (0) | ACK (3) | TRANSPORT_DIRECT (3) | +| `0x4D` | `01 000011 01` | V2 (1, reserved) | ACK (3) | FLOOD (1) | + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Overall packet structure +- [Section 3: Path](03-path.md) — Path encoding +- Test vectors: `corpus/wire-format/header/` + +### Reference Implementation + +- `Packet::getRouteType()` in `src/Packet.h` — `header & 0x03` +- `Packet::getPayloadType()` in `src/Packet.h` — `(header >> 2) & 0x0F` +- `Packet::getPayloadVer()` in `src/Packet.h` — `(header >> 6) & 0x03` +- `Packet::hasTransportCodes()` in `src/Packet.h` — route type 0x00 or 0x03 +- Route type constants in `src/Packet.h` +- Payload type constants in `src/Packet.h` diff --git a/spec/03-path.md b/spec/03-path.md new file mode 100644 index 0000000..a8f7770 --- /dev/null +++ b/spec/03-path.md @@ -0,0 +1,155 @@ +# MeshCore Protocol Specification + +## Section 3: Path + +### Overview + +The path field carries routing information as a sequence of node hashes. In +flood routing, intermediate nodes append their hash to the path as they forward +the packet. In direct routing, the sender supplies the full path. The path +length byte uses bit packing to encode both the number of hashes and the size of +each hash. + +### Path Length Byte + +The path length byte immediately follows the header (and transport codes, if +present). It encodes two values: + +``` + Bit: 7 6 5 4 3 2 1 0 + +---+---+---+---+---+---+---+---+ + | S1 S0| C5 C4 C3 C2 C1 C0| + +---+---+---+---+---+---+---+---+ + | | + | +-- Hash Count (bits 0-5): 0-63 + +----------------- Hash Size Code (bits 6-7): 0-2 (3 is reserved) +``` + +| Field | Bits | Extraction | Range | Description | +|-------|------|-----------|-------|-------------| +| Hash Count | 0-5 | `path_len & 0x3F` | 0-63 | Number of hashes in the path | +| Hash Size Code | 6-7 | `path_len >> 6` | 0-2 | Encoded hash size: actual size = code + 1 | + +The **actual hash size** in bytes is: + +``` +hash_size = (path_len >> 6) + 1 +``` + +| Hash Size Code | Actual Hash Size | Description | +|---------------|-----------------|-------------| +| 0 (`0b00`) | 1 byte | V1 default (PATH_HASH_SIZE) | +| 1 (`0b01`) | 2 bytes | Extended precision | +| 2 (`0b10`) | 3 bytes | Extended precision | +| 3 (`0b11`) | **RESERVED** | MUST be rejected as invalid | + +The path length byte is constructed as: + +``` +path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F) +``` + +### Path Field + +The path field immediately follows the path length byte. Its size in bytes is: + +``` +path_byte_length = hash_count × hash_size +``` + +The path contains `hash_count` consecutive hashes, each `hash_size` bytes long. +Each hash is a prefix of a node's Ed25519 public key (see [Section 15](15-identity.md)). + +``` ++----------+----------+-----+----------+ +| Hash 0 | Hash 1 | ... | Hash N-1 | +| (H bytes)| (H bytes)| ... | (H bytes)| ++----------+----------+-----+----------+ + +where H = hash_size, N = hash_count +``` + +When hash_count is 0, the path field is empty (zero bytes on the wire). + +### Validation Rules + +A conforming implementation MUST reject a packet if any of the following are +true: + +1. **Reserved hash size**: Hash size code is 3 (bits 6-7 = `0b11`), meaning + hash_size would be 4. This value is reserved for future use. + +2. **Path overflow**: `hash_count × hash_size > MAX_PATH_SIZE` (64 bytes). + +The maximum number of hashes depends on hash size: + +| Hash Size | Max Hash Count | Max Path Bytes | +|-----------|---------------|----------------| +| 1 byte | 63 | 63 | +| 2 bytes | 32 | 64 | +| 3 bytes | 21 | 63 | + +Note: With 1-byte hashes the maximum hash count is 63 (not 64), because the +count field is only 6 bits wide. With 2-byte hashes, 32 × 2 = 64 bytes exactly +fills MAX_PATH_SIZE. With 3-byte hashes, 21 × 3 = 63 bytes is the maximum +before 22 × 3 = 66 would overflow. + +### Path Manipulation + +**Appending a hash (flood routing):** +When a node forwards a flood-routed packet, it appends its own hash to the path: + +1. Let `n` = current hash_count. +2. Verify `(n + 1) × hash_size <= MAX_PATH_SIZE`. If not, the node MUST NOT + forward the packet. +3. Copy the node's hash (first `hash_size` bytes of its public key) to + `path[n × hash_size]`. +4. Increment hash_count: `setPathHashCount(n + 1)`. + +**Setting hash size and count:** +``` +path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F) +``` + +### Encoding Examples + +| path_len byte | Binary | Hash Size Code | Hash Count | Hash Size | Path Bytes | +|---------------|--------|---------------|------------|-----------|------------| +| `0x00` | `00 000000` | 0 | 0 | 1 | 0 | +| `0x01` | `00 000001` | 0 | 1 | 1 | 1 | +| `0x03` | `00 000011` | 0 | 3 | 1 | 3 | +| `0x3F` | `00 111111` | 0 | 63 | 1 | 63 | +| `0x41` | `01 000001` | 1 | 1 | 2 | 2 | +| `0x42` | `01 000010` | 1 | 2 | 2 | 4 | +| `0x60` | `01 100000` | 1 | 32 | 2 | 64 | +| `0x81` | `10 000001` | 2 | 1 | 3 | 3 | +| `0x95` | `10 010101` | 2 | 21 | 3 | 63 | +| `0xC0` | `11 000000` | 3 | 0 | **INVALID** | — | +| `0xFF` | `11 111111` | 3 | 63 | **INVALID** | — | + +### Known Implementation Discrepancy + +The Rust implementation [meshcore-rs](https://github.com/Duncaen/meshcore-rs) +treats the path_len byte as a raw byte count rather than decoding hash_count and +hash_size from the bitfields. For example, a path_len value of `0x42` (binary +`01 000010`) should decode as hash_size=2, hash_count=2, path_bytes=4. The +incorrect interpretation reads it as a raw count of 66 bytes. + +Test vectors in this corpus specifically exercise multi-byte hash sizes to catch +this class of bug. + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Overall packet structure +- [Section 2: Header](02-header.md) — Header encoding +- [Section 15: Identity](15-identity.md) — Hash derivation from public keys +- Test vectors: `corpus/wire-format/path/` + +### Reference Implementation + +- `Packet::getPathHashSize()` in `src/Packet.h` — `(path_len >> 6) + 1` +- `Packet::getPathHashCount()` in `src/Packet.h` — `path_len & 63` +- `Packet::getPathByteLen()` in `src/Packet.h` — `getPathHashCount() * getPathHashSize()` +- `Packet::setPathHashSizeAndCount()` in `src/Packet.h` — `((sz - 1) << 6) | (n & 63)` +- `Packet::isValidPathLen()` in `src/Packet.cpp` — Validation logic +- `Packet::writePath()` in `src/Packet.cpp` — Path serialization diff --git a/spec/04-payload-ack.md b/spec/04-payload-ack.md new file mode 100644 index 0000000..e0bc4a8 --- /dev/null +++ b/spec/04-payload-ack.md @@ -0,0 +1,57 @@ +# MeshCore Protocol Specification + +## Section 4: Payload — ACK + +### Overview + +The ACK (acknowledgment) payload is the simplest payload type. It consists of a +single 4-byte CRC value used to confirm receipt of a previously sent message. +The CRC is computed by the sender from the original message's timestamp, text, +and sender public key. + +### Payload Type + +Header payload type field: `0x03` (ACK) + +### Wire Format + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| ACK CRC (uint32_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| ack_crc | 0 | 4 bytes | uint32_le | CRC identifying the acknowledged message | + +### Encoding + +1. Compute the 4-byte CRC value for the message being acknowledged. +2. Write the CRC as a little-endian 32-bit unsigned integer. +3. Set payload_len to 4. + +### Decoding + +1. Read 4 bytes from the payload as a little-endian uint32. +2. If fewer than 4 bytes are available, the packet is INVALID. + +### Constraints + +- The payload MUST be exactly 4 bytes. +- Implementations SHOULD log or discard ACK packets with payload_len < 4. + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Packet framing +- [Section 11: Multipart](11-payload-multipart.md) — Multipart ACK encoding +- Test vectors: `corpus/payloads/ack/` + +### Reference Implementation + +- `Mesh::createAck()` in `src/Mesh.cpp` +- ACK reception in `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ACK` diff --git a/spec/05-payload-advert.md b/spec/05-payload-advert.md new file mode 100644 index 0000000..21de754 --- /dev/null +++ b/spec/05-payload-advert.md @@ -0,0 +1,123 @@ +# MeshCore Protocol Specification + +## Section 5: Payload — Advertisement + +### Overview + +The advertisement payload allows a node to announce its identity to the mesh. It +contains the node's Ed25519 public key, a timestamp, a signature proving +ownership of the key, and optional application data describing the node's type, +location, features, and name. + +### Payload Type + +Header payload type field: `0x04` (ADVERT) + +### Wire Format + +``` + 0 31 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +| Ed25519 Public Key (32 bytes) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Timestamp (uint32_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +| Ed25519 Signature (64 bytes) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| App Data (0-32 bytes) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| pub_key | 0 | 32 bytes | raw | Ed25519 public key of the advertising node | +| timestamp | 32 | 4 bytes | uint32_le | Unix epoch timestamp of advertisement creation | +| signature | 36 | 64 bytes | raw | Ed25519 signature (see Signature section below) | +| app_data | 100 | 0-32 bytes | structured | Optional application data (see App Data section below) | + +### Minimum and Maximum Sizes + +- Minimum payload: 100 bytes (pub_key + timestamp + signature, no app_data) +- Maximum payload: 132 bytes (100 + MAX_ADVERT_DATA_SIZE of 32) + +### Signature + +The signature is computed over the concatenation of: + +``` +message = pub_key(32) || timestamp_le(4) || app_data(0-32) +``` + +The node signs this message using its Ed25519 private key. Receivers MUST verify +the signature using the public key from the payload. Packets with invalid +signatures MUST be discarded. + +### App Data Format + +When present (payload_len > 100), app_data is a structured field beginning with +a flags byte: + +``` + Bit: 7 6 5 4 3 2 1 0 + +-------+-------+-------+-------+---+---+---+---+ + |has_ |has_ |has_ |has_ | Node Type | + |name |feat2 |feat1 |loc | (4 bits) | + +-------+-------+-------+-------+---+---+---+---+ +``` + +| Field | Offset | Size | Condition | Type | Description | +|-------|--------|------|-----------|------|-------------| +| flags | 0 | 1 byte | Always | uint8 | See bit layout above | +| latitude | 1 | 4 bytes | flags bit 4 set | int32_le | Latitude × 1,000,000 | +| longitude | 5 | 4 bytes | flags bit 4 set | int32_le | Longitude × 1,000,000 | +| feat1 | varies | 2 bytes | flags bit 5 set | uint16_le | Feature field 1 | +| feat2 | varies | 2 bytes | flags bit 6 set | uint16_le | Feature field 2 | +| name | varies | remaining | flags bit 7 set | UTF-8 | Node name (no null terminator) | + +Fields appear in the order listed. The offset of each field depends on which +preceding optional fields are present. + +### Node Types (flags bits 0-3) + +| Value | Name | Description | +|-------|------|-------------| +| 0 | None | Unspecified | +| 1 | Chat | Chat client node | +| 2 | Repeater | Mesh repeater node | +| 3 | Room | Room server node | +| 4 | Sensor | Sensor node | + +### Decoding Algorithm + +1. Read 32 bytes as pub_key. +2. Read 4 bytes as timestamp (uint32_le). +3. Read 64 bytes as signature. +4. If offset (100) > payload_len, the packet is INVALID (incomplete). +5. If offset (100) == payload_len, there is no app_data. Done. +6. Read remaining bytes as app_data (up to MAX_ADVERT_DATA_SIZE = 32 bytes). + If more than 32 bytes remain, implementations MUST truncate to 32. +7. Construct the signature verification message: pub_key || timestamp_le || app_data. +8. Verify the Ed25519 signature. If invalid, the receiver MUST discard the packet. +9. Parse app_data: + a. Read 1 byte as flags. + b. If flags bit 4 set: read 4 bytes latitude (int32_le), 4 bytes longitude (int32_le). + c. If flags bit 5 set: read 2 bytes feat1 (uint16_le). + d. If flags bit 6 set: read 2 bytes feat2 (uint16_le). + e. If flags bit 7 set: read remaining bytes as UTF-8 name. + +### Cross-References + +- [Section 15: Identity](15-identity.md) — Ed25519 key management +- [Section 14: Cryptography](14-crypto.md) — Signature operations +- Test vectors: `corpus/payloads/advert/` + +### Reference Implementation + +- `Mesh::createAdvert()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ADVERT` in `src/Mesh.cpp` — Decoding and verification diff --git a/spec/06-payload-encrypted.md b/spec/06-payload-encrypted.md new file mode 100644 index 0000000..d16fb64 --- /dev/null +++ b/spec/06-payload-encrypted.md @@ -0,0 +1,100 @@ +# MeshCore Protocol Specification + +## Section 6: Payload — Encrypted (REQ / RESPONSE / TXT_MSG) + +### Overview + +The REQUEST, RESPONSE, and TXT_MSG payload types share an identical wire format. +They consist of a destination hash, source hash, and an encrypt-then-MAC +ciphertext blob. The three types differ only in their semantic meaning at the +application layer. + +### Payload Types + +| Value | Name | Description | +|-------|------|-------------| +| 0x00 | REQUEST | Encrypted request (e.g., login, data query) | +| 0x01 | RESPONSE | Encrypted response to a REQUEST or ANON_REQ | +| 0x02 | TXT_MSG | Encrypted text message | + +### Wire Format + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +| Ciphertext (N × 16 bytes, N ≥ 1) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| dest_hash | 0 | PATH_HASH_SIZE (1) | raw | First byte of recipient's Ed25519 public key | +| src_hash | 1 | PATH_HASH_SIZE (1) | raw | First byte of sender's Ed25519 public key | +| cipher_mac | 2 | CIPHER_MAC_SIZE (2) | raw | Truncated HMAC-SHA256 over ciphertext | +| ciphertext | 4 | variable (multiple of 16) | raw | AES-128-ECB encrypted data | + +### Minimum Size + +- Minimum payload: 4 + 16 = 20 bytes (dest_hash + src_hash + MAC + 1 AES block) + +### Encryption + +The ciphertext is produced using the encrypt-then-MAC scheme described in +[Section 14](14-crypto.md): + +1. Compute the ECDH shared secret between sender and recipient. +2. Encrypt the plaintext using AES-128-ECB with the first 16 bytes of the + shared secret as the key. Zero-pad the final block. +3. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared secret + as the HMAC key. Truncate to 2 bytes. +4. Prepend the 2-byte MAC to the ciphertext. + +### Decryption + +1. Extract dest_hash and src_hash. +2. If dest_hash matches this node, search for peers matching src_hash. +3. For each matching peer, compute or retrieve the shared secret. +4. Verify the MAC: compute HMAC-SHA256 over the ciphertext portion using the + full 32-byte shared secret. Compare the first 2 bytes with cipher_mac. +5. If the MAC is valid, decrypt the ciphertext using AES-128-ECB with the first + 16 bytes of the shared secret. The decrypted data may contain trailing zero + bytes from padding. +6. If no peer's MAC matches, the packet is not for this node (or the peer is + unknown). The packet MAY still be forwarded. + +### Plaintext Format (TXT_MSG) + +For TXT_MSG payloads, the decrypted plaintext has this structure: + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| timestamp | 0 | 4 bytes | uint32_le | Message timestamp | +| txt_type_attempt | 4 | 1 byte | uint8 | Upper 6 bits: message type, lower 2 bits: attempt (0-3) | +| text | 5 | remaining | UTF-8 | Message text | + +### Plaintext Format (REQUEST / RESPONSE) + +The plaintext format for REQUEST and RESPONSE is application-defined. Common +patterns include: + +- **REQUEST**: `[timestamp(4)][request_data...]` +- **RESPONSE**: `[timestamp(4)][response_data...]` + +### Cross-References + +- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC details +- [Section 15: Identity](15-identity.md) — ECDH shared secret computation +- Test vectors: `corpus/payloads/encrypted/` + +### Reference Implementation + +- `Mesh::createDatagram()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, cases `PAYLOAD_TYPE_REQ/RESPONSE/TXT_MSG` — Decoding +- `Utils::encryptThenMAC()` in `src/Utils.cpp` — Encryption +- `Utils::MACThenDecrypt()` in `src/Utils.cpp` — Decryption and verification diff --git a/spec/07-payload-anon-req.md b/spec/07-payload-anon-req.md new file mode 100644 index 0000000..adafc00 --- /dev/null +++ b/spec/07-payload-anon-req.md @@ -0,0 +1,74 @@ +# MeshCore Protocol Specification + +## Section 7: Payload — Anonymous Request + +### Overview + +The anonymous request payload allows a node to send an encrypted message to a +recipient without the recipient needing to know the sender in advance. Instead of +a 1-byte source hash, the full 32-byte Ed25519 public key of the sender is +included, enabling the recipient to compute the shared secret for decryption. + +### Payload Type + +Header payload type field: `0x07` (ANON_REQ) + +### Wire Format + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| dest_hash(1) | | ++-+-+-+-+-+-+-+-+ | +| Sender Public Key (32 bytes) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Cipher MAC (2 bytes) | | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | +| Ciphertext (N × 16 bytes, N ≥ 1) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| dest_hash | 0 | 1 byte | raw | First byte of recipient's Ed25519 public key | +| sender_pub_key | 1 | 32 bytes | raw | Full Ed25519 public key of sender | +| cipher_mac | 33 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext | +| ciphertext | 35 | variable (multiple of 16) | raw | AES-128-ECB encrypted data | + +### Minimum Size + +- Minimum payload: 1 + 32 + 2 + 16 = 51 bytes + +### Encryption + +1. Sender computes ECDH shared secret using its private key and the recipient's + public key. +2. Encrypt plaintext using AES-128-ECB with first 16 bytes of shared secret. +3. Compute HMAC-SHA256 over ciphertext with full 32-byte shared secret. + Truncate to 2 bytes. +4. Assemble: dest_hash || sender_pub_key || MAC || ciphertext. + +### Decryption + +1. Read dest_hash. If it does not match this node, the packet is not for us + (but MAY be forwarded). +2. Read 32-byte sender_pub_key. Construct an Identity from it. +3. Compute ECDH shared secret between this node's private key and sender's + public key. +4. Verify the MAC and decrypt as in [Section 14](14-crypto.md). + +### Cross-References + +- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Standard encrypted format +- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC +- [Section 15: Identity](15-identity.md) — ECDH key exchange +- Test vectors: `corpus/payloads/anon-req/` + +### Reference Implementation + +- `Mesh::createAnonDatagram()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ANON_REQ` — Decoding diff --git a/spec/08-payload-group.md b/spec/08-payload-group.md new file mode 100644 index 0000000..184d911 --- /dev/null +++ b/spec/08-payload-group.md @@ -0,0 +1,81 @@ +# MeshCore Protocol Specification + +## Section 8: Payload — Group (GRP_TXT / GRP_DATA) + +### Overview + +Group payloads enable encrypted communication within a channel. All members of a +channel share a symmetric secret. The payload begins with a 1-byte channel hash +(derived from the channel's shared key), followed by an encrypt-then-MAC blob. + +### Payload Types + +| Value | Name | Description | +|-------|------|-------------| +| 0x05 | GRP_TXT | Group text message | +| 0x06 | GRP_DATA | Group datagram (binary data) | + +### Wire Format + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|channel_hash(1)| Cipher MAC (2 bytes) | | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | +| Ciphertext (N × 16 bytes, N ≥ 1) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| channel_hash | 0 | 1 byte | raw | First byte of SHA-256(channel_shared_key) | +| cipher_mac | 1 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext | +| ciphertext | 3 | variable (multiple of 16) | raw | AES-128-ECB encrypted data | + +### Minimum Size + +- Minimum payload: 1 + 2 + 16 = 19 bytes + +### Channel Hash Derivation + +The channel_hash is the first byte of the SHA-256 hash of the channel's 32-byte +shared key (PUB_KEY_SIZE bytes): + +``` +channel_hash = SHA256(channel_shared_key)[0] +``` + +This means hash collisions are expected (1 in 256 chance). When a receiver finds +a matching channel_hash, it attempts decryption with each matching channel's +secret. Only a successful MAC verification confirms the correct channel. + +### Encryption + +1. Encrypt plaintext using AES-128-ECB with first 16 bytes of the channel's + shared key (GroupChannel.secret). +2. Compute HMAC-SHA256 over ciphertext using all 32 bytes of the channel's + shared key. Truncate to 2 bytes. +3. Assemble: channel_hash || MAC || ciphertext. + +### Decryption + +1. Read channel_hash (1 byte). +2. Search local channel database for channels with matching hash. Multiple + channels may match (up to 4 in reference implementation). +3. For each matching channel, attempt MAC verification and decryption using the + channel's shared key. +4. The first channel that produces a valid MAC yields the correct decryption. + +### Cross-References + +- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC +- Test vectors: `corpus/payloads/group/` + +### Reference Implementation + +- `Mesh::createGroupDatagram()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, cases `PAYLOAD_TYPE_GRP_TXT/GRP_DATA` — Decoding +- `Mesh::searchChannelsByHash()` — Channel lookup diff --git a/spec/09-payload-path.md b/spec/09-payload-path.md new file mode 100644 index 0000000..f7cb20d --- /dev/null +++ b/spec/09-payload-path.md @@ -0,0 +1,90 @@ +# MeshCore Protocol Specification + +## Section 9: Payload — Path Return + +### Overview + +The PATH payload carries an encrypted return path from one node to another. It +uses the same dest_hash + src_hash + encrypt-then-MAC envelope as REQ/RESPONSE/ +TXT_MSG, but the decrypted contents contain routing path data and optional extra +application data. + +### Payload Type + +Header payload type field: `0x08` (PATH) + +### Wire Format (Outer) + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +| Ciphertext (N × 16 bytes, N ≥ 1) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +The outer format is identical to [Section 6](06-payload-encrypted.md). + +### Decrypted Inner Format + +``` ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| path_len(1) | Path (hash_count × hash_size) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| extra_type(1) | Extra Data (variable) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +| Field | Size | Type | Description | +|-------|------|------|-------------| +| path_len | 1 byte | encoded | Same bitfield encoding as packet path_len (see [Section 3](03-path.md)) | +| path | hash_count × hash_size | raw | The return path to the sender | +| extra_type | 1 byte | uint8 | Type of extra data (lower 4 bits used; upper 4 reserved) | +| extra | remaining | raw | Extra data (may include trailing zero padding from AES) | + +### Extra Type Values + +| Value | Description | +|-------|-------------| +| 0x00 | No meaningful extra data (reciprocal path) | +| 0xFF | Dummy/padding (used when no real extra data; followed by 4 random bytes) | +| Other | Application-defined | + +When creating a path return with no extra data, the reference implementation +appends `0xFF` as extra_type followed by 4 random bytes to ensure the packet +hash is unique. + +### Encoding + +1. Build the inner plaintext: + a. Write path_len byte (encoded hash_size and hash_count). + b. Write path bytes (hash_count × hash_size). + c. If extra data is provided: write extra_type (lower 4 bits), write extra. + d. If no extra data: write `0xFF`, write 4 random bytes. +2. Encrypt the inner plaintext using encrypt-then-MAC with the shared secret. +3. Prepend dest_hash and src_hash. + +### Decoding + +1. Decode the outer envelope (dest_hash, src_hash, MAC, ciphertext) as in + [Section 6](06-payload-encrypted.md). +2. After decryption: + a. Read 1 byte as path_len. Decode hash_size and hash_count. + b. Read hash_count × hash_size bytes as the path. + c. Read 1 byte as extra_type (use lower 4 bits only). + d. Remaining bytes are extra data (may include AES zero-padding). + +### Cross-References + +- [Section 3: Path](03-path.md) — Path length encoding +- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Outer envelope format +- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC +- Test vectors: `corpus/payloads/path-return/` + +### Reference Implementation + +- `Mesh::createPathReturn()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, PATH case within REQ/RESPONSE/TXT_MSG handling — Decoding diff --git a/spec/10-payload-trace.md b/spec/10-payload-trace.md new file mode 100644 index 0000000..ef99943 --- /dev/null +++ b/spec/10-payload-trace.md @@ -0,0 +1,106 @@ +# MeshCore Protocol Specification + +## Section 10: Payload — Trace + +### Overview + +The TRACE payload enables path discovery with per-hop signal quality measurement. +A trace packet travels along a specified direct path, and each intermediate node +appends its received SNR value to the packet's path field. When the trace reaches +its final hop, the accumulated SNR values and node hashes are delivered. + +### Payload Type + +Header payload type field: `0x09` (TRACE) + +### Wire Format + +The trace payload has a fixed 9-byte header followed by an optional path hashes +section (appended when sent via direct routing): + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Tag (uint32_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Auth Code (uint32_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Flags | Path Hashes (variable) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| tag | 0 | 4 bytes | uint32_le | Random tag set by the initiator for correlation | +| auth_code | 4 | 4 bytes | uint32_le | Authentication code | +| flags | 8 | 1 byte | uint8 | Flags (see below) | +| path_hashes | 9 | variable | raw | Node hashes for the path to trace (appended for direct send) | + +### Flags Field + +``` + Bit: 7 6 5 4 3 2 1 0 + +---+---+---+---+---+---+---+---+ + | Reserved | S1 S0| + +---+---+---+---+---+---+---+---+ +``` + +| Bits | Description | +|------|-------------| +| 0-1 | Path hash size for the trace path hashes (as a power of 2: 0=1 byte, 1=2 bytes, 2=4 bytes) | +| 2-7 | Reserved (must be zero) | + +Note: The flags field path hash size encoding (`1 << (flags & 0x03)`) differs +from the packet path_len encoding (`(code >> 6) + 1`). + +### Direct Sending + +When a TRACE packet is sent via direct routing, the path hashes are appended +directly to the payload (after the 9-byte header), not placed in the packet's +path field. The packet's path field is instead used to accumulate SNR values +from intermediate nodes. + +``` +On creation: payload = [tag(4)][auth_code(4)][flags(1)] (9 bytes) +On sendDirect: payload += path_hashes (9 + N bytes) + packet.path_len = 0 (path used for SNR) +``` + +### SNR Accumulation + +As each intermediate node forwards a TRACE packet, it appends its received SNR +value as a single signed byte to the packet's path field: + +``` +path[path_len++] = (int8_t)(SNR × 4) +``` + +The SNR value is stored as SNR × 4 for 0.25 dB precision, matching the format +used in RxMeta frames. + +### Trace Completion + +A TRACE is considered complete when the path_len counter (used as an offset into +the path hashes in the payload) reaches or exceeds the remaining path hashes. +At that point, `onTraceRecv` is called with: +- The accumulated SNR values (in packet.path) +- The path hashes (in packet.payload, after byte 9) +- The path length (number of hops traversed) + +### Minimum Size + +- Minimum payload: 9 bytes (tag + auth_code + flags, no path hashes) + +### Cross-References + +- [Section 16: Packet Hash](16-packet-hash.md) — TRACE packets include path_len in hash +- Test vectors: `corpus/payloads/trace/` + +### Reference Implementation + +- `Mesh::createTrace()` in `src/Mesh.cpp` — Creation (9-byte base) +- `Mesh::sendDirect()` — Path appended to payload for TRACE type +- `Mesh::onRecvPacket()`, TRACE handling — SNR accumulation and completion check diff --git a/spec/11-payload-multipart.md b/spec/11-payload-multipart.md new file mode 100644 index 0000000..2c3f20d --- /dev/null +++ b/spec/11-payload-multipart.md @@ -0,0 +1,88 @@ +# MeshCore Protocol Specification + +## Section 11: Payload — Multipart + +### Overview + +The MULTIPART payload wraps another payload type to indicate it is part of a +multi-packet sequence. The first byte encodes the number of remaining packets in +the sequence and the sub-payload type. Currently, only multipart ACK is defined +in the reference implementation. + +### Payload Type + +Header payload type field: `0x0A` (MULTIPART) + +### Wire Format + +``` + 0 1 + 0 1 2 3 4 5 6 7 8 9 0 ... ++-+-+-+-+-+-+-+-+-+-+-+-+ +|Rem| SubType | Sub-payload | ++-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +First byte encoding: + +``` + Bit: 7 6 5 4 3 2 1 0 + +---+---+---+---+---+---+---+---+ + | Remaining (4) | Sub-Type (4) | + +---+---+---+---+---+---+---+---+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| remaining | 0 (bits 4-7) | 4 bits | uint | Number of remaining packets in sequence (0-15) | +| sub_type | 0 (bits 0-3) | 4 bits | uint | Payload type of the wrapped content | +| sub_payload | 1 | variable | raw | The wrapped payload data | + +``` +first_byte = (remaining << 4) | (sub_type & 0x0F) +``` + +### Multipart ACK + +The only currently defined multipart sub-type is ACK (sub_type = 0x03): + +``` ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| (rem<<4)|0x03| ACK CRC (uint32_le) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +Total payload: 5 bytes (1 byte header + 4 byte CRC). + +Multipart ACKs are used to send additional ACK retransmissions for reliability. +The `remaining` field indicates how many more ACK packets follow this one. + +### Encoding (Multipart ACK) + +1. Set first byte to `(remaining << 4) | PAYLOAD_TYPE_ACK`. +2. Copy the 4-byte ACK CRC starting at offset 1. +3. Set payload_len to 5. + +### Decoding (Multipart ACK) + +1. Read first byte. Extract remaining = byte >> 4, sub_type = byte & 0x0F. +2. If sub_type == 0x03 (ACK) and payload_len >= 5: + a. Read 4 bytes at offset 1 as ACK CRC (uint32_le). +3. For other sub_types: reserved for future use. + +### Constraints + +- Payload MUST be at least 2 bytes (1 header + 1 sub-payload minimum). +- For multipart ACK, payload MUST be at least 5 bytes. + +### Cross-References + +- [Section 4: ACK](04-payload-ack.md) — ACK CRC format +- Test vectors: `corpus/payloads/multipart/` + +### Reference Implementation + +- `Mesh::createMultiAck()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_MULTIPART` — Decoding diff --git a/spec/12-payload-control.md b/spec/12-payload-control.md new file mode 100644 index 0000000..5d4f7f5 --- /dev/null +++ b/spec/12-payload-control.md @@ -0,0 +1,68 @@ +# MeshCore Protocol Specification + +## Section 12: Payload — Control + +### Overview + +The CONTROL payload carries control and discovery data. Control packets are raw +byte payloads whose structure is determined by the first byte. A subset of +control packets (those with bit 7 of the first byte set) are restricted to +zero-hop delivery only. + +### Payload Type + +Header payload type field: `0x0B` (CONTROL) + +### Wire Format + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Control Byte | Data... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| control_byte | 0 | 1 byte | uint8 | Control type identifier | +| data | 1 | variable | raw | Control-specific data | + +### Zero-Hop Restriction + +When bit 7 (`0x80`) of the control byte is set, the packet is restricted to +zero-hop delivery only. The reference implementation enforces this: + +- If `(payload[0] & 0x80) != 0` AND the packet is direct-routed: + - It is processed only if `getPathHashCount() == 0`. + - It is NOT forwarded to other nodes. + +### Discovery Protocols + +Control packets are commonly used for node discovery. The specific sub-protocol +formats are application-defined, but typically include: + +- **Discovery Request**: Sent zero-hop to find nearby nodes +- **Discovery Response**: Contains node type, name, SNR, and other metadata + +### Encoding + +1. Construct the raw control data bytes. +2. If the control packet should be zero-hop only, set bit 7 of the first byte. +3. Copy data to payload, set payload_len. + +### Constraints + +- Payload MUST be at least 1 byte (the control byte). +- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD). + +### Cross-References + +- [Section 17: Routing](17-routing.md) — Zero-hop delivery +- Test vectors: `corpus/payloads/control/` + +### Reference Implementation + +- `Mesh::createControlData()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, CONTROL handling — Zero-hop check and delivery diff --git a/spec/13-payload-raw-custom.md b/spec/13-payload-raw-custom.md new file mode 100644 index 0000000..8b47f6e --- /dev/null +++ b/spec/13-payload-raw-custom.md @@ -0,0 +1,49 @@ +# MeshCore Protocol Specification + +## Section 13: Payload — Raw Custom + +### Overview + +The RAW_CUSTOM payload carries application-defined raw bytes with no prescribed +structure. It is intended for applications that implement their own encryption, +framing, or payload formats on top of the MeshCore transport. + +### Payload Type + +Header payload type field: `0x0F` (RAW_CUSTOM) + +### Wire Format + +``` + 0 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Raw Data (1-184 bytes) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Fields + +| Field | Offset | Size | Type | Description | +|-------|--------|------|------|-------------| +| data | 0 | 1-184 bytes | raw | Application-defined payload | + +### Routing + +In the current reference implementation, RAW_CUSTOM packets are only processed +when received via direct routing. They are NOT flood-routed. + +### Constraints + +- Payload MUST be at least 1 byte. +- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD). +- Maximum total data: limited by `sizeof(Packet::payload)` which is + MAX_PACKET_PAYLOAD (184). + +### Cross-References + +- Test vectors: `corpus/payloads/raw-custom/` + +### Reference Implementation + +- `Mesh::createRawData()` in `src/Mesh.cpp` — Encoding +- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_RAW_CUSTOM` — Processing (direct only) diff --git a/spec/14-crypto.md b/spec/14-crypto.md new file mode 100644 index 0000000..09db99f --- /dev/null +++ b/spec/14-crypto.md @@ -0,0 +1,120 @@ +# MeshCore Protocol Specification + +## Section 14: Cryptography + +### Overview + +MeshCore uses a combination of symmetric and asymmetric cryptography. Data +confidentiality is provided by AES-128 in ECB mode with zero padding. Data +integrity is provided by HMAC-SHA256 truncated to 2 bytes. These are combined +in an encrypt-then-MAC scheme. + +### Algorithms + +| Operation | Algorithm | Key Size | Output | +|-----------|-----------|----------|--------| +| Symmetric Encryption | AES-128-ECB | 16 bytes | Multiple of 16 bytes | +| Message Authentication | HMAC-SHA256 | 32 bytes | 2 bytes (truncated) | +| Digital Signature | Ed25519 | 32/64 bytes | 64 bytes | +| Key Exchange | X25519 (ECDH) | 32 bytes | 32 bytes | +| Hashing | SHA-256 | — | Up to 32 bytes | + +### AES-128-ECB Encryption + +MeshCore uses AES-128 in Electronic Codebook (ECB) mode with zero-byte padding. + +**Key**: The first 16 bytes (CIPHER_KEY_SIZE) of the shared secret. + +**Encryption**: + +1. Set the AES-128 key to `shared_secret[0..15]`. +2. Process the plaintext in 16-byte blocks: + a. For each complete 16-byte block, encrypt in place. + b. For the final partial block (< 16 bytes): + - Copy the remaining bytes to a 16-byte buffer. + - Fill remaining bytes with zeros. + - Encrypt the padded block. +3. If the plaintext is empty, encrypt a 16-byte all-zero block. +4. The output is always a multiple of 16 bytes. + +**Decryption**: + +1. Set the AES-128 key to `shared_secret[0..15]`. +2. Process the ciphertext in 16-byte blocks, decrypting each. +3. The output length equals the ciphertext length. +4. The final block MAY contain trailing zero bytes from padding. + The application must know the original plaintext length or use + a length-prefixed format to determine where meaningful data ends. + +### HMAC-SHA256 (Truncated to 2 Bytes) + +MeshCore computes HMAC-SHA256 over the ciphertext and truncates the result to +CIPHER_MAC_SIZE (2) bytes. + +**Key**: The full 32 bytes (PUB_KEY_SIZE) of the shared secret. + +Note: The HMAC key uses the full 32-byte shared secret, while the AES key uses +only the first 16 bytes. This is an important distinction. + +**Computation**: + +``` +mac = HMAC-SHA256(key=shared_secret[0..31], message=ciphertext)[0..1] +``` + +The first 2 bytes of the HMAC-SHA256 output are used as the MAC. + +### Encrypt-then-MAC + +The encrypt-then-MAC scheme combines AES-128-ECB encryption with HMAC-SHA256 +authentication: + +**Encoding** (`encryptThenMAC`): + +1. Encrypt the plaintext using AES-128-ECB (see above). + Store ciphertext at `dest[2..]` (offset by MAC size). +2. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared + secret. Truncate to 2 bytes. +3. Store the 2-byte MAC at `dest[0..1]`. +4. Return total length: 2 + ciphertext_length. + +Output format: +``` +[MAC (2 bytes)][Ciphertext (N × 16 bytes)] +``` + +**Decoding** (`MACThenDecrypt`): + +1. If input length ≤ 2 (CIPHER_MAC_SIZE), return failure (invalid). +2. Recompute HMAC-SHA256 over `src[2..]` (the ciphertext portion) using the + full 32-byte shared secret. Truncate to 2 bytes. +3. Compare the computed MAC with `src[0..1]`. +4. If MACs do not match, return failure (0 = invalid HMAC). +5. If MACs match, decrypt `src[2..]` using AES-128-ECB. +6. Return the decrypted plaintext length. + +### Security Notes + +- **ECB mode**: AES-ECB encrypts each 16-byte block independently. Identical + plaintext blocks produce identical ciphertext blocks. This is a known + weakness of ECB mode, but MeshCore's short payloads and mesh-network context + make this acceptable for its use case. +- **2-byte MAC**: The truncated HMAC provides only 16 bits of authentication. + This means there is approximately a 1-in-65536 chance of a forged MAC being + accepted. This trade-off prioritizes bandwidth over authentication strength. +- **Key derivation**: The shared secret is used directly as both AES key + (first 16 bytes) and HMAC key (full 32 bytes). No key derivation function + (KDF) is applied. + +### Cross-References + +- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Usage in packet payloads +- [Section 15: Identity](15-identity.md) — Shared secret computation via ECDH +- Test vectors: `corpus/crypto/` + +### Reference Implementation + +- `Utils::encrypt()` in `src/Utils.cpp` — AES-128-ECB encryption +- `Utils::decrypt()` in `src/Utils.cpp` — AES-128-ECB decryption +- `Utils::encryptThenMAC()` in `src/Utils.cpp` — Encrypt-then-MAC +- `Utils::MACThenDecrypt()` in `src/Utils.cpp` — MAC-then-Decrypt diff --git a/spec/15-identity.md b/spec/15-identity.md new file mode 100644 index 0000000..e62491d --- /dev/null +++ b/spec/15-identity.md @@ -0,0 +1,92 @@ +# MeshCore Protocol Specification + +## Section 15: Identity + +### Overview + +Every MeshCore node has an Ed25519 key pair. The 32-byte public key serves as +the node's identity. Hashes used in routing are truncated prefixes of the public +key. Shared secrets for encryption are computed via ECDH key exchange using +X25519 (Ed25519 keys transposed to Curve25519). + +### Key Types + +| Type | Size | Description | +|------|------|-------------| +| Public Key | 32 bytes (PUB_KEY_SIZE) | Ed25519 public key | +| Private Key | 64 bytes (PRV_KEY_SIZE) | Ed25519 private key (seed + public) | +| Signature | 64 bytes (SIGNATURE_SIZE) | Ed25519 signature | +| Shared Secret | 32 bytes (PUB_KEY_SIZE) | ECDH shared secret | + +### Hash Derivation + +A node's routing hash is simply a prefix of its public key: + +``` +hash = pub_key[0..hash_size-1] +``` + +Where `hash_size` is 1, 2, or 3 bytes depending on the protocol version and +path encoding (see [Section 3](03-path.md)). + +For V1, `PATH_HASH_SIZE` is 1, meaning a node's hash is the first byte of its +public key. This gives only 256 unique hash values, so hash collisions are +common and expected. The protocol handles this by attempting decryption with all +peers matching a given hash. + +### Hash Matching + +To check if a hash matches a node's identity: + +``` +match = memcmp(hash, pub_key, hash_size) == 0 +``` + +### Ed25519 Signing + +Used for advertisement signatures (see [Section 5](05-payload-advert.md)): + +1. Construct the message to sign (e.g., pub_key || timestamp || app_data). +2. Sign using the Ed25519 private key. +3. The 64-byte signature is included in the advertisement payload. + +Verification: + +1. Extract the public key and signature from the payload. +2. Reconstruct the message. +3. Verify the signature using Ed25519 verification. +4. Receivers MUST discard the packet if verification fails. + +### ECDH Key Exchange (X25519) + +To compute a shared secret between two nodes: + +1. Convert the Ed25519 keys to X25519 (Curve25519) format. +2. Perform X25519 Diffie-Hellman: `shared_secret = X25519(my_private, their_public)`. +3. The resulting 32-byte shared secret is used for: + - AES-128 key: `shared_secret[0..15]` + - HMAC-SHA256 key: `shared_secret[0..31]` + +### Identity Serialization + +Identities can be serialized for storage: + +- **Public Identity**: 32 bytes (public key only) +- **Local Identity**: 64 bytes (private key) + 32 bytes (public key) = 96 bytes + +Hex representation uses uppercase characters with 2 hex digits per byte. + +### Cross-References + +- [Section 3: Path](03-path.md) — Hash size in path encoding +- [Section 5: Advertisement](05-payload-advert.md) — Signature usage +- [Section 14: Cryptography](14-crypto.md) — Shared secret usage +- Test vectors: `corpus/crypto/ed25519/`, `corpus/crypto/ecdh/` + +### Reference Implementation + +- `Identity` class in `src/Identity.h` — Public key and verification +- `LocalIdentity` class in `src/Identity.h` — Key pair, signing, ECDH +- `Identity::copyHashTo()` — Hash derivation (prefix copy) +- `Identity::isHashMatch()` — Hash comparison +- `LocalIdentity::calcSharedSecret()` — ECDH key exchange diff --git a/spec/16-packet-hash.md b/spec/16-packet-hash.md new file mode 100644 index 0000000..3660fe7 --- /dev/null +++ b/spec/16-packet-hash.md @@ -0,0 +1,67 @@ +# MeshCore Protocol Specification + +## Section 16: Packet Hash + +### Overview + +MeshCore uses SHA-256 hashing for packet deduplication. Each node maintains a +table of recently seen packet hashes and discards packets it has already +processed. The hash is computed over the payload type and payload contents, with +a special case for TRACE packets. + +### Hash Computation + +The packet hash is computed as: + +``` +hash = SHA-256(payload_type_byte || payload_data)[0..MAX_HASH_SIZE-1] +``` + +Where: +- `payload_type_byte` is a single byte: `(header >> 2) & 0x0F` +- `payload_data` is the raw payload bytes (payload_len bytes) +- The SHA-256 output is truncated to MAX_HASH_SIZE (8) bytes + +### TRACE Packet Special Case + +For TRACE packets (payload_type = 0x09), the path_len byte is included in the +hash to distinguish trace packets that revisit the same node on their return +path: + +``` +hash = SHA-256(payload_type_byte || path_len_byte || payload_data)[0..MAX_HASH_SIZE-1] +``` + +### Algorithm + +``` +function calculatePacketHash(packet): + sha = SHA256_init() + type_byte = getPayloadType(packet.header) // single byte + SHA256_update(sha, type_byte) + if type_byte == 0x09: // TRACE + SHA256_update(sha, packet.path_len) // 1 byte (as uint8) + SHA256_update(sha, packet.payload, packet.payload_len) + hash = SHA256_finalize(sha) + return hash[0..7] // first MAX_HASH_SIZE bytes +``` + +### Properties + +- The hash does NOT include the header byte, path, or transport codes. This + means the same logical message received via different routes produces the + same hash. +- The hash does NOT include the route type or version. Only the payload type + and payload content determine the hash. +- For TRACE packets, including path_len ensures that the same trace payload + at different stages of traversal produces different hashes. + +### Cross-References + +- [Section 10: Trace](10-payload-trace.md) — TRACE packet special handling +- Test vectors: `corpus/crypto/sha256/packet-hash.json` + +### Reference Implementation + +- `Packet::calculatePacketHash()` in `src/Packet.cpp` +- `MeshTables::hasSeen()` — Deduplication table lookup diff --git a/spec/17-routing.md b/spec/17-routing.md new file mode 100644 index 0000000..fc4902b --- /dev/null +++ b/spec/17-routing.md @@ -0,0 +1,124 @@ +# MeshCore Protocol Specification + +## Section 17: Routing + +### Overview + +MeshCore supports two routing modes: flood routing and direct routing. Flood +routing broadcasts packets to all neighbors, building up a path as the packet +propagates. Direct routing sends packets along a pre-determined path. + +### Flood Routing + +In flood routing, the sender emits a packet with an empty path. Each +intermediate node that forwards the packet appends its hash to the path before +retransmitting. + +**Sending (flood)**: + +1. Set route type to FLOOD (0x01) or TRANSPORT_FLOOD (0x00). +2. Set path to empty: `setPathHashSizeAndCount(hash_size, 0)`. +3. Transmit the packet. + +**Forwarding (flood)**: + +1. Receive a flood-routed packet. +2. Check the deduplication table. If already seen, discard. +3. Process the packet (decrypt if addressed to this node, etc.). +4. If the packet should be forwarded (node is a repeater): + a. Verify `(hash_count + 1) × hash_size <= MAX_PATH_SIZE`. + b. Append this node's hash at `path[hash_count × hash_size]`. + c. Increment hash_count. + d. Retransmit with a random delay. + +**Path growth**: The path grows by one hash per hop. When the path reaches +MAX_PATH_SIZE (64 bytes), no more nodes can be appended and the packet stops +propagating. + +### Direct Routing + +In direct routing, the sender provides the complete path. Each intermediate +node checks whether it is the next hop, removes itself from the path, and +forwards to the next node. + +**Sending (direct)**: + +1. Set route type to DIRECT (0x02) or TRANSPORT_DIRECT (0x03). +2. Set the path to the destination's known route. +3. Transmit the packet. + +**Forwarding (direct)**: + +1. Receive a direct-routed packet. +2. Check if the first hash in the path matches this node. +3. If yes, and the node allows forwarding: + a. Remove the first hash from the path (shift remaining hashes left). + b. Decrement hash_count. + c. Retransmit. +4. If no, discard (this node is not the next hop). + +**Path removal** (`removeSelfFromPath`): + +1. Decrement hash_count. +2. Shift the path array: for each index k from 0 to (hash_count × hash_size), + copy `path[k + hash_size]` to `path[k]`. + +### Zero-Hop Delivery + +Zero-hop packets are direct-routed packets with hash_count = 0. They reach only +immediate neighbors (nodes within radio range). Used for: + +- Control packets with bit 7 set in the control byte +- Discovery protocols +- Local-only operations + +### Transport Codes + +Transport codes (present in TRANSPORT_FLOOD and TRANSPORT_DIRECT) enable +regional scoping. Nodes can filter packets based on transport codes, accepting +only packets from their region. + +### TRACE Routing + +TRACE packets are a special case. They are sent via direct routing, but instead +of the normal path field, the path hashes are appended to the payload. The +packet's path field is repurposed to accumulate SNR values from each hop +(see [Section 10](10-payload-trace.md)). + +### Retransmission Priority + +Packets are prioritized for transmission: + +| Priority | Packet Type | Description | +|----------|-------------|-------------| +| 0 (highest) | Direct routed | Routed traffic | +| 1 | Path return, standard | Most flood packets | +| 2 | Path return (flood) | Path packets | +| 3 | Advertisement | De-prioritized | +| 5 | Trace | Trace forwarding | +| N (hash_count) | Flood forwarded | Lower priority for more distant sources | + +### Packet Deduplication + +Before processing or forwarding any packet, nodes check if the packet has +already been seen using `hasSeen()` with the packet hash from +[Section 16](16-packet-hash.md). This prevents infinite loops in flood routing. + +Once a packet is marked as seen, it is NOT forwarded again even if received via +a different route. This is a "first packet wins" approach. + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Packet structure +- [Section 3: Path](03-path.md) — Path encoding +- [Section 10: Trace](10-payload-trace.md) — TRACE routing special case +- [Section 16: Packet Hash](16-packet-hash.md) — Deduplication + +### Reference Implementation + +- `Mesh::sendFlood()` in `src/Mesh.cpp` — Flood send +- `Mesh::sendDirect()` in `src/Mesh.cpp` — Direct send +- `Mesh::sendZeroHop()` in `src/Mesh.cpp` — Zero-hop send +- `Mesh::routeRecvPacket()` in `src/Mesh.cpp` — Flood forwarding +- `Mesh::removeSelfFromPath()` in `src/Mesh.cpp` — Path manipulation +- `Mesh::onRecvPacket()` in `src/Mesh.cpp` — Direct forwarding diff --git a/spec/18-companion-protocol.md b/spec/18-companion-protocol.md new file mode 100644 index 0000000..0e3612a --- /dev/null +++ b/spec/18-companion-protocol.md @@ -0,0 +1,96 @@ +# MeshCore Protocol Specification + +## Section 18: Companion Protocol + +### Overview + +The companion protocol enables BLE (Bluetooth Low Energy) and serial +communication between a MeshCore radio and a companion app (phone, tablet, or +computer). It uses a binary framing format over the Nordic UART Service (NUS) +BLE profile. + +### BLE Service + +| UUID | Description | +|------|-------------| +| `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` | Nordic UART Service | +| `6E400002-...` | RX Characteristic (app writes to radio) | +| `6E400003-...` | TX Characteristic (radio notifies app) | + +### Frame Format + +Each companion protocol frame consists of a 1-byte type identifier followed by +variable-length data: + +``` +[type(1)][data(0-171)] +``` + +Maximum frame size is 172 bytes. + +### Message Types (Radio to App) + +| Type | Value | Description | +|------|-------|-------------| +| PACKET_CHANNEL_MSG_RECV | 0x01 | Channel message received | +| PACKET_CONTACT_MSG_RECV | 0x02 | Contact message received | +| PACKET_ADV_RECV | 0x03 | Advertisement received | +| PACKET_CONTACT_MSG_RECV_V3 | 0x04 | Contact message V3 (includes SNR) | +| PACKET_CHANNEL_MSG_RECV_V3 | 0x05 | Channel message V3 (includes SNR) | + +### Command Types (App to Radio) + +| Command | Value | Description | +|---------|-------|-------------| +| CMD_APP_START | 0x01 | Initialize connection | +| CMD_DEVICE_QUERY | 0x02 | Query device info | +| CMD_SET_CHANNEL | 0x03 | Set active channel | +| CMD_SEND_CHANNEL_MESSAGE | 0x04 | Send message to channel | +| CMD_SEND_CONTACT_MESSAGE | 0x05 | Send message to contact | +| CMD_GET_CONTACTS | 0x06 | Get contact list | +| CMD_GET_CHANNELS | 0x07 | Get channel list | +| CMD_SET_TIME | 0x08 | Set device time | +| CMD_ADD_CONTACT | 0x09 | Add a contact | +| CMD_ADD_CHANNEL | 0x0A | Add a channel | +| CMD_SEND_LOGIN | 0x0B | Login to room/repeater | +| CMD_GET_SETTINGS | 0x0C | Get device settings | +| CMD_SET_SETTINGS | 0x0D | Set device settings | +| CMD_REMOVE_CONTACT | 0x0E | Remove a contact | +| CMD_REMOVE_CHANNEL | 0x0F | Remove a channel | +| CMD_SHARE_CONTACT | 0x10 | Share contact info | +| CMD_SET_ADV_NAME | 0x11 | Set advertisement name | +| CMD_REBOOT | 0x12 | Reboot device | +| CMD_SEND_RAW | 0x13 | Send raw packet | + +### Connection Sequence + +1. Scan for BLE devices advertising the Nordic UART Service. +2. Connect and discover services. +3. Enable notifications on the TX characteristic. +4. Send `CMD_APP_START` to initialize. +5. Send `CMD_DEVICE_QUERY` to get device info. +6. Send `CMD_SET_TIME` to synchronize clock. +7. Send `CMD_GET_CONTACTS` and `CMD_GET_CHANNELS` to fetch state. + +### Byte Order + +All multi-byte integers in the companion protocol are **little-endian**, except +for CayenneLPP sensor data which uses **big-endian**. + +### Message Length Limit + +Text messages are limited to 133 characters. + +### MTU Considerations + +The default BLE MTU is 23 bytes (20 bytes of payload). For complex operations, +implementations SHOULD request a larger MTU (up to 512 bytes). + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Underlying packet format +- Test vectors: `corpus/companion/` + +### Reference Implementation + +- `docs/companion_protocol.md` in the MeshCore repository diff --git a/spec/19-kiss-protocol.md b/spec/19-kiss-protocol.md new file mode 100644 index 0000000..b3e54a6 --- /dev/null +++ b/spec/19-kiss-protocol.md @@ -0,0 +1,180 @@ +# MeshCore Protocol Specification + +## Section 19: KISS Modem Protocol + +### Overview + +The KISS (Keep It Simple, Stupid) modem protocol provides a serial interface to +MeshCore LoRa radios. It follows the KA9Q/K3MC KISS TNC specification with +MeshCore-specific SetHardware extensions for cryptographic operations, radio +control, and telemetry. + +### Serial Configuration + +- Baud rate: 115200 +- Data bits: 8 +- Parity: None +- Stop bits: 1 +- Flow control: None + +### Frame Format + +``` +┌──────┬───────────┬──────────────┬──────┐ +│ FEND │ Type Byte │ Data (escaped)│ FEND │ +│ 0xC0 │ 1 byte │ 0-510 bytes │ 0xC0 │ +└──────┴───────────┴──────────────┴──────┘ +``` + +### Special Bytes + +| Byte | Name | Value | Description | +|------|------|-------|-------------| +| FEND | Frame End | 0xC0 | Frame delimiter | +| FESC | Frame Escape | 0xDB | Escape character | +| TFEND | Transposed FEND | 0xDC | FESC + TFEND represents 0xC0 in data | +| TFESC | Transposed FESC | 0xDD | FESC + TFESC represents 0xDB in data | + +### Byte Stuffing + +To send a data byte of 0xC0 (FEND), emit: 0xDB 0xDC (FESC TFEND). +To send a data byte of 0xDB (FESC), emit: 0xDB 0xDD (FESC TFESC). + +### Type Byte + +``` + Bit: 7 6 5 4 3 2 1 0 + +---+---+---+---+---+---+---+---+ + | Port (4) | Command (4) | + +---+---+---+---+---+---+---+---+ +``` + +Port is 0 for single-port TNC (standard for MeshCore). + +### Standard KISS Commands (Host to TNC) + +| Command | Value | Data | Description | +|---------|-------|------|-------------| +| DataFrame | 0x00 | Raw packet | Queue packet for transmission | +| TXDELAY | 0x01 | 1 byte | Transmitter keyup delay (× 10ms) | +| Persistence | 0x02 | 1 byte | CSMA persistence (0-255) | +| SlotTime | 0x03 | 1 byte | CSMA slot interval (× 10ms) | +| TXtail | 0x04 | 1 byte | Post-TX hold time (× 10ms) | +| FullDuplex | 0x05 | 1 byte | 0=half, nonzero=full | +| SetHardware | 0x06 | Sub-cmd + data | MeshCore extensions | +| Return | 0xFF | — | Exit KISS mode (no-op) | + +### TNC to Host + +| Type | Value | Data | Description | +|------|-------|------|-------------| +| DataFrame | 0x00 | Raw packet | Received packet from radio | + +Data frames carry raw MeshCore packets (up to 255 bytes, MAX_TRANS_UNIT). + +### SetHardware Extensions (0x06) + +MeshCore extends the KISS protocol via the SetHardware command. The first data +byte is a sub-command identifier. + +**Request Sub-commands (Host to TNC):** + +| Sub-cmd | Value | Data | Description | +|---------|-------|------|-------------| +| GetIdentity | 0x01 | — | Get node's Ed25519 public key | +| GetRandom | 0x02 | len(1) | Get random bytes (1-64) | +| VerifySignature | 0x03 | pubkey(32)+sig(64)+data | Verify Ed25519 signature | +| SignData | 0x04 | data | Ed25519 sign | +| EncryptData | 0x05 | key(32)+plaintext | AES-128 encrypt | +| DecryptData | 0x06 | key(32)+mac(2)+ciphertext | AES-128 decrypt with MAC | +| KeyExchange | 0x07 | remote_pub(32) | X25519 ECDH | +| Hash | 0x08 | data | SHA-256 hash | +| SetRadio | 0x09 | freq(4)+bw(4)+sf(1)+cr(1) | Set radio parameters | +| SetTxPower | 0x0A | power(1) | Set TX power (dBm) | +| GetRadio | 0x0B | — | Get radio parameters | +| GetTxPower | 0x0C | — | Get TX power | +| GetCurrentRssi | 0x0D | — | Get current RSSI | +| IsChannelBusy | 0x0E | — | Check if channel busy | +| GetAirtime | 0x0F | pkt_len(1) | Estimate air time | +| GetNoiseFloor | 0x10 | — | Get noise floor | +| GetVersion | 0x11 | — | Get firmware version | +| GetStats | 0x12 | — | Get RX/TX statistics | +| GetBattery | 0x13 | — | Get battery voltage | +| GetMCUTemp | 0x14 | — | Get MCU temperature | +| GetSensors | 0x15 | perms(1) | Get sensor data | +| GetDeviceName | 0x16 | — | Get device name | +| Ping | 0x17 | — | Ping | +| Reboot | 0x18 | — | Reboot device | +| SetSignalReport | 0x19 | enable(1) | Enable/disable RxMeta | +| GetSignalReport | 0x1A | — | Get signal report status | + +**Response Sub-commands (TNC to Host):** + +Response codes: `response = command | 0x80` + +| Sub-cmd | Value | Data | +|---------|-------|------| +| Identity | 0x81 | pubkey(32) | +| Random | 0x82 | random_bytes(1-64) | +| Verify | 0x83 | result(1): 0=invalid, 1=valid | +| Signature | 0x84 | signature(64) | +| Encrypted | 0x85 | mac(2)+ciphertext | +| Decrypted | 0x86 | plaintext | +| SharedSecret | 0x87 | secret(32) | +| HashResult | 0x88 | hash(32) | +| Radio | 0x8B | freq(4)+bw(4)+sf(1)+cr(1) | +| TxPower | 0x8C | power(1) | +| CurrentRssi | 0x8D | rssi(1, signed) | +| ChannelBusy | 0x8E | busy(1): 0=clear, 1=busy | +| Airtime | 0x8F | millis(4) | +| NoiseFloor | 0x90 | dBm(2, signed) | +| Version | 0x91 | version(1)+reserved(1) | +| Stats | 0x92 | rx(4)+tx(4)+errors(4) | +| Battery | 0x93 | millivolts(2) | +| MCUTemp | 0x94 | temp(2, signed, tenths °C) | +| Sensors | 0x95 | CayenneLPP data | +| DeviceName | 0x96 | name(UTF-8) | +| Pong | 0x97 | — | +| SignalReport | 0x9A | status(1) | +| OK | 0xF0 | — | +| Error | 0xF1 | error_code(1) | +| TxDone | 0xF8 | result(1): 0=fail, 1=success | +| RxMeta | 0xF9 | snr(1)+rssi(1) | + +### Error Codes + +| Code | Value | Description | +|------|-------|-------------| +| InvalidLength | 0x01 | Request data too short | +| InvalidParam | 0x02 | Invalid parameter value | +| NoCallback | 0x03 | Feature not available | +| MacFailed | 0x04 | MAC verification failed | +| UnknownCmd | 0x05 | Unknown sub-command | +| EncryptFailed | 0x06 | Encryption failed | + +### Unsolicited Events + +**TxDone (0xF8)**: Sent after packet transmission. 0x01 = success, 0x00 = fail. + +**RxMeta (0xF9)**: Sent after each received data frame. Contains: +- SNR: 1 byte, signed, × 4 for 0.25 dB precision +- RSSI: 1 byte, signed, dBm + +### Data Format Notes + +- Maximum payload per frame: 255 bytes (MAX_TRANS_UNIT) +- Frames larger than 255 unescaped bytes are silently dropped +- All multi-byte integers are little-endian +- Radio frequency is in Hz (e.g., 869618000) +- Battery voltage in millivolts +- MCU temperature in tenths of °C (e.g., 253 = 25.3°C) + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Packet format within data frames +- [Section 14: Cryptography](14-crypto.md) — Crypto operations via SetHardware +- Test vectors: `corpus/kiss/` + +### Reference Implementation + +- `docs/kiss_modem_protocol.md` in the MeshCore repository diff --git a/spec/20-bridge-protocol.md b/spec/20-bridge-protocol.md new file mode 100644 index 0000000..ea62622 --- /dev/null +++ b/spec/20-bridge-protocol.md @@ -0,0 +1,69 @@ +# MeshCore Protocol Specification + +## Section 20: Bridge Protocol + +### Overview + +The bridge protocol enables MeshCore packet forwarding over non-LoRa transports +such as RS232 serial links and ESP-NOW WiFi. It adds a simple framing layer +with a magic number and checksum around the raw MeshCore packet. + +### Frame Format + +``` +┌──────────────┬──────────────┬──────────────┬──────────────┐ +│ Magic (2B) │ Payload │ Checksum (2B)│ │ +│ 0xC03E │ (variable) │ Fletcher-16 │ │ +└──────────────┴──────────────┴──────────────┴──────────────┘ +``` + +### Fields + +| Field | Size | Type | Description | +|-------|------|------|-------------| +| Magic | 2 bytes | uint16 | `0xC03E` — identifies the frame as a MeshCore bridge packet | +| Payload | variable | raw | Raw MeshCore packet (as produced by `Packet::writeTo()`) | +| Checksum | 2 bytes | Fletcher-16 | Fletcher-16 checksum over the payload | + +### RS232 Variant + +The RS232 variant adds a length field between the magic and payload: + +``` +┌──────────────┬──────────────┬──────────────┬──────────────┐ +│ Magic (2B) │ Length (2B) │ Payload │ Checksum (2B)│ +│ 0xC03E │ uint16_le │ (variable) │ Fletcher-16 │ +└──────────────┴──────────────┴──────────────┴──────────────┘ +``` + +| Field | Size | Type | Description | +|-------|------|------|-------------| +| Length | 2 bytes | uint16_le | Length of the payload in bytes | + +### Fletcher-16 Checksum + +The Fletcher-16 checksum is computed over the payload bytes: + +``` +function fletcher16(data): + sum1 = 0 + sum2 = 0 + for byte in data: + sum1 = (sum1 + byte) mod 255 + sum2 = (sum2 + sum1) mod 255 + return (sum2 << 8) | sum1 +``` + +### ESP-NOW Variant + +For ESP-NOW transport, the frame format is the same as the basic format (magic + +payload + checksum) without the length field, since ESP-NOW provides its own +length framing. + +### Cross-References + +- [Section 1: Wire Format](01-wire-format.md) — Packet format within payload + +### Reference Implementation + +- Bridge handling in MeshCore firmware (board-specific implementations) diff --git a/tools/validate.py b/tools/validate.py new file mode 100644 index 0000000..ce14c9d --- /dev/null +++ b/tools/validate.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +MeshCore Spec Corpus Validator + +Validates all test vector JSON files against the schema and checks internal +consistency of test vectors (hex encoding, field relationships, etc.). + +Usage: + python tools/validate.py [--verbose] [path...] + +If no paths given, validates all JSON files under corpus/. +""" + +import argparse +import json +import re +import struct +import sys +from pathlib import Path + +# Schema validation is optional (requires jsonschema) +try: + import jsonschema + HAS_JSONSCHEMA = True +except ImportError: + HAS_JSONSCHEMA = False + + +REPO_ROOT = Path(__file__).resolve().parent.parent +CORPUS_DIR = REPO_ROOT / "corpus" +SCHEMA_PATH = CORPUS_DIR / "schema" / "test-vector.schema.json" + +# Maps payload type names to their numeric values +PAYLOAD_TYPE_MAP = { + "request": 0x00, "response": 0x01, "txt_msg": 0x02, "ack": 0x03, + "advert": 0x04, "grp_txt": 0x05, "grp_data": 0x06, "anon_req": 0x07, + "path": 0x08, "trace": 0x09, "multipart": 0x0A, "control": 0x0B, + "raw_custom": 0x0F, +} + +ROUTE_TYPE_MAP = { + "transport_flood": 0x00, "flood": 0x01, "direct": 0x02, + "transport_direct": 0x03, +} + +# Wire format sizes (bytes) +HEADER_SIZE = 1 +TRANSPORT_CODES_SIZE = 4 +PATH_LEN_SIZE = 1 + + +def strip_hex(s): + """Remove whitespace from hex string and return uppercase.""" + return re.sub(r'\s+', '', s).upper() + + +def hex_to_bytes(s): + """Convert hex string (with optional whitespace) to bytes.""" + return bytes.fromhex(strip_hex(s)) + + +def compute_header(version, payload_type, route_type): + """Compute the header byte from structured fields.""" + pt = PAYLOAD_TYPE_MAP.get(payload_type) + rt = ROUTE_TYPE_MAP.get(route_type) + if pt is None or rt is None: + return None + return (version << 6) | (pt << 2) | rt + + +def compute_path_len_byte(hash_size, hash_count): + """Compute the path_len byte from hash_size and hash_count.""" + return ((hash_size - 1) << 6) | (hash_count & 0x3F) + + +class ValidationError: + """A single validation error with file location and optional vector context.""" + + def __init__(self, file_path, vector_id, message): + self.file_path = file_path + self.vector_id = vector_id + self.message = message + + def __str__(self): + loc = str(self.file_path) + if self.vector_id: + loc += f" [{self.vector_id}]" + return f" ERROR: {loc}: {self.message}" + + +def validate_json_syntax(file_path): + """Validate that the file is valid JSON.""" + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f), [] + except FileNotFoundError: + return None, [ValidationError(file_path, None, f"File not found: {file_path}")] + except json.JSONDecodeError as e: + return None, [ValidationError(file_path, None, f"Invalid JSON: {e}")] + + +def validate_schema(data, file_path, schema): + """Validate against JSON Schema.""" + errors = [] + try: + jsonschema.validate(data, schema) + except jsonschema.ValidationError as e: + errors.append(ValidationError(file_path, None, f"Schema: {e.message}")) + return errors + + +def validate_hex_string(hex_str, field_name, file_path, vector_id): + """Validate a hex string is well-formed.""" + errors = [] + stripped = strip_hex(hex_str) + if len(stripped) % 2 != 0: + errors.append(ValidationError(file_path, vector_id, + f"Odd-length hex in {field_name}: '{stripped}'")) + if not re.match(r'^[0-9A-F]*$', stripped): + errors.append(ValidationError(file_path, vector_id, + f"Invalid or lowercase hex chars in {field_name} (must be uppercase)")) + return errors + + +def validate_vector_consistency(vector, file_path): + """Validate internal consistency of a single test vector.""" + errors = [] + vid = vector.get("id", "?") + vtype = vector.get("type") + + # Validate binary hex + if "binary" in vector: + errors.extend(validate_hex_string(vector["binary"], "binary", file_path, vid)) + + # For encode_decode vectors, verify header byte matches structured form + if vtype in ("encode_decode", "decode_only") and "structured" in vector and "binary" in vector: + structured = vector["structured"] + binary = hex_to_bytes(vector["binary"]) + + if len(binary) < 1: + errors.append(ValidationError(file_path, vid, "Binary too short for header")) + return errors + + # Check header byte + hdr = structured.get("header", {}) + expected_header = compute_header( + hdr.get("version", 0), + hdr.get("payload_type", ""), + hdr.get("route_type", "") + ) + if expected_header is not None and binary[0] != expected_header: + errors.append(ValidationError(file_path, vid, + f"Header mismatch: binary[0]=0x{binary[0]:02X}, " + f"expected=0x{expected_header:02X}")) + + # Check transport codes presence and values + rt = hdr.get("route_type", "") + has_tc = rt in ("transport_flood", "transport_direct") + offset = HEADER_SIZE + if has_tc: + if "transport_codes" not in structured: + errors.append(ValidationError(file_path, vid, + "Route type requires transport_codes but none in structured")) + elif len(binary) >= HEADER_SIZE + TRANSPORT_CODES_SIZE: + # Verify transport code LE values + tc = structured["transport_codes"] + tc0 = struct.unpack_from('