From 4e4aed82b28aca9125ccae12e0146670e19a2b79 Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Mon, 4 May 2026 18:24:27 -0600 Subject: [PATCH] implement lxmf message parsing --- FUNCTIONS.md | 6 +- proofs/lxmf/lxmf_message_parse.py | 168 +++++++++++ src/include/lxmf.S | 18 ++ src/lxmf/lxmf_message_parse.S | 132 ++++++++ tests/lxmf/test_lxmf_message_parse.py | 418 ++++++++++++++++++++++++++ 5 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 proofs/lxmf/lxmf_message_parse.py create mode 100644 src/lxmf/lxmf_message_parse.S create mode 100644 tests/lxmf/test_lxmf_message_parse.py diff --git a/FUNCTIONS.md b/FUNCTIONS.md index 54a5757..4f0ab80 100644 --- a/FUNCTIONS.md +++ b/FUNCTIONS.md @@ -54,7 +54,7 @@ post-milestone-1 task. Run `./verify --all` for the live tally.) (run with `pytest --hardware tests/hardware/`). - Total functions registered: **140** (excludes placeholder rows like "(functions added when milestone N is activated)") -- Verified: 137 (milestone 1 software side + ENTIRE milestone-2 crypto stack: SHA-256 family + SHA-512 family + HMAC + HKDF + AES-256-CBC stack + 13 X25519 functions + 11 Ed25519 functions (scalar arith, point ops, scalarmult, compress/decompress, keypair, sign, verify — all match pyca/cryptography under QEMU; RFC 7748 §5.2/§6.1 + RFC 8032 §7.1 vectors plus tampering rejection) + production HMAC-DRBG-SHA-256 RNG (NIST SP 800-90A Rev. 1 §10.1.2; `rng_entropy` raw-source layer + `hmac_drbg_update` + `rng_init` + `rng_bytes` together discharge the canonical NIST CAVP DRBGVS COUNT=0 KAT symbolically; on TARGET_C6 the entropy source is the on-chip LPPERI hardware RNG) + milestone-3 identity/destination/announce TX helpers (`identity_create`, `identity_hash`, `destination_name_hash`, `destination_hash`, `announce_build`, `announce_send`) + milestone-4 qemu flash model (`flash_init`, `flash_read`, `flash_write_page`, `flash_erase_sector`) + milestone-4 identity persistence (`identity_save`, `identity_load`) + milestone-5 transport RX helpers (`announce_parse`, `announce_validate`, `transport_path_init`, `transport_path_update`, `transport_path_lookup`, `transport_process_announce`) + milestone-6 link request/key/handshake/session/dispatch helpers (`link_request_build`, `link_request_parse`, `link_derive_keys`, `link_handshake_init`, `link_handshake_accept`, `link_session_encrypt`, `link_session_decrypt`, `link_process_packet`) + milestone-7 channel envelope/resource helpers (`channel_envelope_build`, `channel_envelope_parse`, `resource_advertisement_parse`, `resource_part_parse`, `resource_reassembly_init`, `resource_reassembly_update`, `resource_process_plaintext`) + milestone-8 GPIO/SPI/SX1262 foundation/TX/RX/interface glue (`gpio_config_output`, `gpio_config_input`, `gpio_write`, `gpio_read`, `spi_init`, `spi_transfer`, `sx1262_reset`, `sx1262_command_write`, `sx1262_command_read`, `sx1262_init`, `sx1262_send_frame`, `sx1262_poll_receive`, `lora_interface_init`, `lora_interface_send`, `lora_interface_poll`) + milestone-9 LXMF payload codec, message id/signing/verification, message packing, and delivery announce app-data (`lxmf_payload_build`, `lxmf_payload_parse`, `lxmf_message_id`, `lxmf_message_sign`, `lxmf_message_verify`, `lxmf_message_pack`, `lxmf_delivery_announce_build`) — all under ADR-0009) +- Verified: 138 (milestone 1 software side + ENTIRE milestone-2 crypto stack: SHA-256 family + SHA-512 family + HMAC + HKDF + AES-256-CBC stack + 13 X25519 functions + 11 Ed25519 functions (scalar arith, point ops, scalarmult, compress/decompress, keypair, sign, verify — all match pyca/cryptography under QEMU; RFC 7748 §5.2/§6.1 + RFC 8032 §7.1 vectors plus tampering rejection) + production HMAC-DRBG-SHA-256 RNG (NIST SP 800-90A Rev. 1 §10.1.2; `rng_entropy` raw-source layer + `hmac_drbg_update` + `rng_init` + `rng_bytes` together discharge the canonical NIST CAVP DRBGVS COUNT=0 KAT symbolically; on TARGET_C6 the entropy source is the on-chip LPPERI hardware RNG) + milestone-3 identity/destination/announce TX helpers (`identity_create`, `identity_hash`, `destination_name_hash`, `destination_hash`, `announce_build`, `announce_send`) + milestone-4 qemu flash model (`flash_init`, `flash_read`, `flash_write_page`, `flash_erase_sector`) + milestone-4 identity persistence (`identity_save`, `identity_load`) + milestone-5 transport RX helpers (`announce_parse`, `announce_validate`, `transport_path_init`, `transport_path_update`, `transport_path_lookup`, `transport_process_announce`) + milestone-6 link request/key/handshake/session/dispatch helpers (`link_request_build`, `link_request_parse`, `link_derive_keys`, `link_handshake_init`, `link_handshake_accept`, `link_session_encrypt`, `link_session_decrypt`, `link_process_packet`) + milestone-7 channel envelope/resource helpers (`channel_envelope_build`, `channel_envelope_parse`, `resource_advertisement_parse`, `resource_part_parse`, `resource_reassembly_init`, `resource_reassembly_update`, `resource_process_plaintext`) + milestone-8 GPIO/SPI/SX1262 foundation/TX/RX/interface glue (`gpio_config_output`, `gpio_config_input`, `gpio_write`, `gpio_read`, `spi_init`, `spi_transfer`, `sx1262_reset`, `sx1262_command_write`, `sx1262_command_read`, `sx1262_init`, `sx1262_send_frame`, `sx1262_poll_receive`, `lora_interface_init`, `lora_interface_send`, `lora_interface_poll`) + milestone-9 LXMF payload codec, message id/signing/verification, message packing/parsing, and delivery announce app-data (`lxmf_payload_build`, `lxmf_payload_parse`, `lxmf_message_id`, `lxmf_message_sign`, `lxmf_message_verify`, `lxmf_message_pack`, `lxmf_message_parse`, `lxmf_delivery_announce_build`) — all under ADR-0009) - Tier A coverage extended: the SHA-512 family (`sha512_init`, `sha512_compress`, `sha512_update`, `sha512_final`) now carries a Cryptol+SAW Tier A proof alongside the QEMU/hashlib KAT bridge. The SAW drivers discharge FIPS 180-4 §C.1 + §C.2 KATs symbolically over the 80-round transform + 16-word schedule, plus K-table constants, ROTR/ch/maj algebraic sanity, streaming associativity (small chunkings), and both padding paths (bl ≤ 111 single-block + bl > 111 two-block). - Tier A coverage extended: the Ed25519 lower stack (8 of 11 functions: `sc_reduce`, `sc_muladd`, `point_add`, `point_double`, `scalarmult`, `point_compress`, `point_decompress`, `field_pow_p5d8`) now carries a Cryptol+SAW Tier A proof — `Ed25519Scalar.cry` (sc_reduce/sc_muladd boundary KATs vs (a*b+c) mod L), `Ed25519Point.cry` (BBJLP add/double on edwards25519: identity, additive inverse, double=add-at-equal, commutativity sanity), `Ed25519Encoding.cry` (compress(B) RFC vector, decompress(B) round-trip, scalarmult bit-pattern KATs, sqrt(-1)^2 = -1). - Tier B (Binsec/Rel constant-time) coverage landed 2026-05-02 for the @@ -80,7 +80,7 @@ post-milestone-1 task. Run `./verify --all` for the live tally.) - The X25519 algorithmic spec [proofs/crypto/x25519/X25519.cry](proofs/crypto/x25519/X25519.cry) and SAW driver are landed and proven against RFC 7748 §5.2 / §6.1 KATs; each of the 14 listed functions hangs off the same shared model. The asm implementation follows in subsequent commits. - Tested: 0 - In progress: 0 -- Planned: 3 (`uart_isr` plus the remaining milestone-9 LXMF foundation functions) +- Planned: 2 (`uart_isr` plus the remaining milestone-9 LXMF foundation functions) The end-to-end milestone-1 demo path is observable: KISS-framed Reticulum packets sent to qemu's stdin produce `boot.ready`, `kiss.rx_frame`, and @@ -423,7 +423,7 @@ LXMF messaging on top of Reticulum. | `lxmf_message_sign` | ◉ verified | | `lxmf_payload_build`, `lxmf_message_id`, `identity_load`, `ed25519_sign` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#message-id-and-signatures) | | `lxmf_message_verify` | ◉ verified | | `lxmf_message_id`, `ed25519_verify` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#message-id-and-signatures) | | `lxmf_message_pack` | ◉ verified | | `lxmf_payload_build`, `lxmf_message_sign` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#message-envelope-codec) | -| `lxmf_message_parse` | ☐ planned | | `lxmf_payload_parse`, `lxmf_message_id`, `lxmf_message_verify` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#message-envelope-codec) | +| `lxmf_message_parse` | ◉ verified | | `lxmf_payload_parse`, `lxmf_message_id`, `lxmf_message_verify` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#message-envelope-codec) | | `lxmf_delivery_announce_build` | ◉ verified | | — | 0001, 0002, 0005, 0006, 0007 | [milestone-9](docs/milestones/milestone-9.md#delivery-announce-app-data) | | `lxmf_inbound_dispatch` | ☐ planned | | `lxmf_message_parse`, `transport_path_lookup` | 0001, 0002, 0005, 0006, 0007, 0009 | [milestone-9](docs/milestones/milestone-9.md#inbound-delivery-dispatch) | diff --git a/proofs/lxmf/lxmf_message_parse.py b/proofs/lxmf/lxmf_message_parse.py new file mode 100644 index 0000000..f2d25a5 --- /dev/null +++ b/proofs/lxmf/lxmf_message_parse.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Source/contract verifier for LXMF message parsing.""" + +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +UPSTREAM_RETICULUM = REPO_ROOT.parent / "upstream" / "Reticulum" +sys.path.insert(0, str(UPSTREAM_RETICULUM)) +sys.path.insert(0, str(REPO_ROOT / "tests")) + +import RNS.vendor.umsgpack as msgpack # noqa: E402 +from harness import oracle # noqa: E402 + + +class ProofError(AssertionError): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ProofError(message) + + +def read(path: str) -> str: + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def parse_equ(*paths: str) -> dict[str, int]: + values: dict[str, int] = {} + pending: list[tuple[str, str]] = [] + for path in paths: + for line in read(path).splitlines(): + m = re.match(r"\s*\.equ\s+([A-Z0-9_]+),\s*(.+?)\s*(?:/\*.*)?$", line) + if m: + pending.append((m.group(1), m.group(2).strip())) + changed = True + while changed and pending: + changed = False + next_pending: list[tuple[str, str]] = [] + for name, expr in pending: + try: + safe_expr = re.sub( + r"\b[A-Z][A-Z0-9_]*\b", + lambda m: str(values[m.group(0)]), + expr, + ) + values[name] = int(eval(safe_expr, {"__builtins__": {}}, {})) + changed = True + except (KeyError, NameError, SyntaxError): + next_pending.append((name, expr)) + pending = next_pending + return values + + +EQU = parse_equ("src/include/config.S", "src/include/lxmf.S", "src/include/ed25519.S") + + +def body(path: str, symbol: str) -> str: + text = read(path) + start = text.index(f"{symbol}:") + end = text.index(f".size {symbol}, . - {symbol}", start) + return text[start:end] + + +def ordered(src: str, patterns: list[str], description: str) -> None: + pos = -1 + for pattern in patterns: + match = re.search(pattern, src[pos + 1:], re.MULTILINE) + require(match is not None, f"{description}: missing {pattern!r}") + pos = pos + 1 + match.start() + + +def no_stamp_payload(raw_payload: bytes) -> bytes: + unpacked = msgpack.unpackb(raw_payload) + return msgpack.packb(unpacked[:4]) if len(unpacked) > 4 else raw_payload + + +def sign(seed: bytes, dest: bytes, source: bytes, raw_payload: bytes) -> bytes: + payload = no_stamp_payload(raw_payload) + message_id = hashlib.sha256(dest + source + payload).digest() + return oracle.ed25519_sign(seed, dest + source + payload + message_id) + + +def prove_constants() -> None: + require(EQU["LXMF_HASH_SIZE"] == 16, "hash size") + require(EQU["LXMF_SIGNATURE_SIZE"] == 64, "signature size") + require(EQU["LXMF_PACKED_PREFIX_SIZE"] == 96, "packed prefix") + require(EQU["LXMF_ERR_SHORT"] == -6, "short status") + require(EQU["LXMF_MESSAGE_PARSED_OFF_MESSAGE_ID"] == 28, "message id offset") + require(EQU["LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PARSED"] == 60, "payload parsed offset") + require(EQU["LXMF_MESSAGE_PARSED_T_SIZE"] == 516, "message parsed size") + require(EQU["ED25519_PUB_BYTES"] == 32, "public key size") + + +def prove_vectors() -> None: + xsk = bytes((i * 7 + 3) & 0xFF for i in range(32)) + esk = bytes.fromhex( + "4ccd089b28ff96da9db6c346ec114e0f" + "5b8a319f35aba624da8cf6ed4fb8a6fb" + ) + identity = oracle.identity_from_private_parts(xsk, esk) + dest = bytes(range(16)) + base_payload = msgpack.packb([1.5, b"Title", b"Body", {}]) + stamped_payload = bytes([EQU["LXMF_MSGPACK_FIXARRAY5"]]) + base_payload[1:] + b"\xc4\x10" + (b"\x11" * 16) + + for raw_payload in (base_payload, stamped_payload): + stripped_payload = no_stamp_payload(raw_payload) + signature = sign(identity.ed25519_seed, dest, identity.hash, raw_payload) + packed = dest + identity.hash + signature + raw_payload + require(packed[:16] == dest, "destination prefix") + require(packed[16:32] == identity.hash, "source prefix") + require(packed[32:96] == signature, "signature prefix") + require(packed[96:] == raw_payload, "payload suffix") + message_id = hashlib.sha256(dest + identity.hash + stripped_payload).digest() + require( + oracle.ed25519_verify( + identity.ed25519_public, + signature, + dest + identity.hash + stripped_payload + message_id, + ), + "signature verifies over no-stamp payload", + ) + + +def prove_source_shape() -> None: + src = body("src/lxmf/lxmf_message_parse.S", "lxmf_message_parse") + ordered( + src, + [ + r"\bbeqz\s+s0,\s*\.Llmr_invalid", + r"\bbeqz\s+s2,\s*\.Llmr_invalid", + r"\bbeqz\s+s3,\s*\.Llmr_invalid", + r"\bli\s+t0,\s*LXMF_PACKED_PREFIX_SIZE", + r"\bbltu\s+s1,\s*t0,\s*\.Llmr_short", + r"\bsub\s+s4,\s*s1,\s*t0", + r"\baddi\s+s5,\s*s2,\s*LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PARSED", + r"\baddi\s+a0,\s*s0,\s*LXMF_PACKED_PREFIX_SIZE", + r"\bcall\s+lxmf_payload_parse", + r"\bsw\s+s0,\s*LXMF_MESSAGE_PARSED_OFF_DEST_HASH_PTR\(s2\)", + r"\bsw\s+t0,\s*LXMF_MESSAGE_PARSED_OFF_SOURCE_HASH_PTR\(s2\)", + r"\bsw\s+t0,\s*LXMF_MESSAGE_PARSED_OFF_SIGNATURE_PTR\(s2\)", + r"\bsw\s+t0,\s*LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PTR\(s2\)", + r"\blw\s+s6,\s*LXMF_PAYLOAD_PARSED_OFF_WITHOUT_STAMP_PTR\(s5\)", + r"\blw\s+s7,\s*LXMF_PAYLOAD_PARSED_OFF_WITHOUT_STAMP_LEN\(s5\)", + r"\baddi\s+a4,\s*s2,\s*LXMF_MESSAGE_PARSED_OFF_MESSAGE_ID", + r"\bcall\s+lxmf_message_id", + r"\baddi\s+a4,\s*s0,\s*LXMF_SIGNING_PREFIX_SIZE", + r"\bcall\s+lxmf_message_verify", + r"\bli\s+a0,\s*LXMF_ERR_SHORT", + ], + "message parse validation/payload/id/verify order", + ) + require("@ct: not-required" in read("src/lxmf/lxmf_message_parse.S"), "ct annotation") + + +def main() -> None: + prove_constants() + prove_vectors() + prove_source_shape() + + +if __name__ == "__main__": + main() diff --git a/src/include/lxmf.S b/src/include/lxmf.S index 7fa1e2d..8b5d30c 100644 --- a/src/include/lxmf.S +++ b/src/include/lxmf.S @@ -8,6 +8,7 @@ .equ LXMF_ERR_MISSING, -3 .equ LXMF_ERR_IO, -4 .equ LXMF_ERR_BAD_SIGNATURE, -5 + .equ LXMF_ERR_SHORT, -6 .equ LXMF_HASH_SIZE, 16 .equ LXMF_MESSAGE_ID_SIZE, 32 @@ -76,6 +77,23 @@ .equ LXMF_PAYLOAD_PARSED_OFF_WITHOUT_STAMP_BUF, 56 .equ LXMF_PAYLOAD_PARSED_T_SIZE, 456 + /* lxmf_message_parsed_t struct offsets. + * + * The fixed views point into the inbound packed message. The embedded + * payload parser struct owns the no-stamp copy buffer used when a fifth + * LXMF stamp element is present. + */ + .equ LXMF_MESSAGE_PARSED_OFF_DEST_HASH_PTR, 0 + .equ LXMF_MESSAGE_PARSED_OFF_SOURCE_HASH_PTR, 4 + .equ LXMF_MESSAGE_PARSED_OFF_SIGNATURE_PTR, 8 + .equ LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PTR, 12 + .equ LXMF_MESSAGE_PARSED_OFF_PAYLOAD_LEN, 16 + .equ LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_PTR, 20 + .equ LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_LEN, 24 + .equ LXMF_MESSAGE_PARSED_OFF_MESSAGE_ID, 28 + .equ LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PARSED, 60 + .equ LXMF_MESSAGE_PARSED_T_SIZE, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PARSED + LXMF_PAYLOAD_PARSED_T_SIZE + /* Upstream LXMF delivery announce app-data: * msgpack([display_name_or_nil, stamp_cost_or_nil]). * Display names are encoded as bin8, so 255 bytes is the hard cap. diff --git a/src/lxmf/lxmf_message_parse.S b/src/lxmf/lxmf_message_parse.S new file mode 100644 index 0000000..e9cb8bf --- /dev/null +++ b/src/lxmf/lxmf_message_parse.S @@ -0,0 +1,132 @@ +# ============================================================================ +# @function: lxmf_message_parse +# @module: lxmf +# @inputs: a0 = packed message ptr +# a1 = packed message len +# a2 = lxmf_message_parsed_t* out +# a3 = source Ed25519 public key ptr (32 bytes) +# @outputs: a0 = 0 on success, negative errno on failure +# @clobbers: a0..a7, t0..t6, ra +# @preserves: sp, gp, s0..s7 +# @stack: 48 +# @cycles: bounded (payload parse + SHA-256 + Ed25519 verify) +# @ct: not-required +# @spec: milestone-9 message envelope codec; upstream LXMF/LXMessage.py unpack +# @verify: proofs/lxmf/lxmf_message_parse.py +# @tests: tests/lxmf/test_lxmf_message_parse.py +# @adrs: 0001, 0002, 0005, 0006, 0007, 0009 +# @status: verified +# ============================================================================ + +/* Parse and validate the milestone-9 LXMF message subset: + * + * destination_hash || source_hash || signature || payload + * + * The payload parser preserves the exact inbound payload bytes and exposes a + * no-stamp view for message-id/signature validation. The caller supplies the + * recalled source Ed25519 public key; unknown-source handling belongs to the + * later inbound dispatch layer. + */ + + .include "lxmf.S" + + .equ LMR_S0_OFF, 0 + .equ LMR_S1_OFF, 4 + .equ LMR_S2_OFF, 8 + .equ LMR_S3_OFF, 12 + .equ LMR_S4_OFF, 16 + .equ LMR_S5_OFF, 20 + .equ LMR_S6_OFF, 24 + .equ LMR_S7_OFF, 28 + .equ LMR_RA_OFF, 44 + .equ LMR_FRAME, 48 + + .section .text.lxmf_message_parse, "ax", @progbits + .global lxmf_message_parse + .type lxmf_message_parse, @function +lxmf_message_parse: + addi sp, sp, -LMR_FRAME + sw s0, LMR_S0_OFF(sp) + sw s1, LMR_S1_OFF(sp) + sw s2, LMR_S2_OFF(sp) + sw s3, LMR_S3_OFF(sp) + sw s4, LMR_S4_OFF(sp) + sw s5, LMR_S5_OFF(sp) + sw s6, LMR_S6_OFF(sp) + sw s7, LMR_S7_OFF(sp) + sw ra, LMR_RA_OFF(sp) + + mv s0, a0 /* packed message ptr */ + mv s1, a1 /* packed message len */ + mv s2, a2 /* parsed output */ + mv s3, a3 /* source public key */ + + beqz s0, .Llmr_invalid + beqz s2, .Llmr_invalid + beqz s3, .Llmr_invalid + + li t0, LXMF_PACKED_PREFIX_SIZE + bltu s1, t0, .Llmr_short + sub s4, s1, t0 /* payload len */ + + addi s5, s2, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PARSED + addi a0, s0, LXMF_PACKED_PREFIX_SIZE + mv a1, s4 + mv a2, s5 + call lxmf_payload_parse + bnez a0, .Llmr_done + + sw s0, LXMF_MESSAGE_PARSED_OFF_DEST_HASH_PTR(s2) + addi t0, s0, LXMF_HASH_SIZE + sw t0, LXMF_MESSAGE_PARSED_OFF_SOURCE_HASH_PTR(s2) + addi t0, s0, LXMF_SIGNING_PREFIX_SIZE + sw t0, LXMF_MESSAGE_PARSED_OFF_SIGNATURE_PTR(s2) + addi t0, s0, LXMF_PACKED_PREFIX_SIZE + sw t0, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PTR(s2) + sw s4, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_LEN(s2) + + lw s6, LXMF_PAYLOAD_PARSED_OFF_WITHOUT_STAMP_PTR(s5) + lw s7, LXMF_PAYLOAD_PARSED_OFF_WITHOUT_STAMP_LEN(s5) + sw s6, LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_PTR(s2) + sw s7, LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_LEN(s2) + + mv a0, s0 + addi a1, s0, LXMF_HASH_SIZE + mv a2, s6 + mv a3, s7 + addi a4, s2, LXMF_MESSAGE_PARSED_OFF_MESSAGE_ID + call lxmf_message_id + bnez a0, .Llmr_done + + mv a0, s0 + addi a1, s0, LXMF_HASH_SIZE + mv a2, s6 + mv a3, s7 + addi a4, s0, LXMF_SIGNING_PREFIX_SIZE + mv a5, s3 + call lxmf_message_verify + bnez a0, .Llmr_done + + li a0, LXMF_OK + j .Llmr_done + +.Llmr_invalid: + li a0, LXMF_ERR_INVAL + j .Llmr_done +.Llmr_short: + li a0, LXMF_ERR_SHORT + +.Llmr_done: + lw s0, LMR_S0_OFF(sp) + lw s1, LMR_S1_OFF(sp) + lw s2, LMR_S2_OFF(sp) + lw s3, LMR_S3_OFF(sp) + lw s4, LMR_S4_OFF(sp) + lw s5, LMR_S5_OFF(sp) + lw s6, LMR_S6_OFF(sp) + lw s7, LMR_S7_OFF(sp) + lw ra, LMR_RA_OFF(sp) + addi sp, sp, LMR_FRAME + ret + + .size lxmf_message_parse, . - lxmf_message_parse diff --git a/tests/lxmf/test_lxmf_message_parse.py b/tests/lxmf/test_lxmf_message_parse.py new file mode 100644 index 0000000..6c3886f --- /dev/null +++ b/tests/lxmf/test_lxmf_message_parse.py @@ -0,0 +1,418 @@ +"""Direct QEMU tests for LXMF message parsing and signature verification.""" + +from __future__ import annotations + +import dataclasses +import hashlib +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from harness import build, oracle, target + +REPO_ROOT = Path(__file__).resolve().parents[2] +UPSTREAM_RETICULUM = REPO_ROOT.parent / "upstream" / "Reticulum" +sys.path.insert(0, str(UPSTREAM_RETICULUM)) + +import RNS.vendor.umsgpack as msgpack # noqa: E402 + +ASM_SOURCES = ( + "src/lxmf/lxmf_message_parse.S", + "src/lxmf/lxmf_payload_parse.S", + "src/lxmf/lxmf_message_id.S", + "src/lxmf/lxmf_message_verify.S", + "src/crypto/ed25519/ed25519_verify.S", + "src/crypto/ed25519/ed25519_scalarmult.S", + "src/crypto/ed25519/ed25519_point_add.S", + "src/crypto/ed25519/ed25519_point_double.S", + "src/crypto/ed25519/ed25519_point_compress.S", + "src/crypto/ed25519/ed25519_point_decompress.S", + "src/crypto/ed25519/ed25519_field_pow_p5d8.S", + "src/crypto/ed25519/ed25519_sc_reduce.S", + "src/crypto/x25519/x25519_field_add.S", + "src/crypto/x25519/x25519_field_sub.S", + "src/crypto/x25519/x25519_field_mul.S", + "src/crypto/x25519/x25519_field_sq.S", + "src/crypto/x25519/x25519_field_inv.S", + "src/crypto/x25519/x25519_field_pack.S", + "src/crypto/x25519/x25519_field_unpack.S", + "src/crypto/sha512/sha512_init.S", + "src/crypto/sha512/sha512_update.S", + "src/crypto/sha512/sha512_final.S", + "src/crypto/sha512/sha512_compress.S", + "src/crypto/sha256/sha256_init.S", + "src/crypto/sha256/sha256_update.S", + "src/crypto/sha256/sha256_final.S", + "src/crypto/sha256/sha256_compress.S", + "src/state/ed25519.S", + "src/state/x25519.S", + "src/state/sha512.S", + "src/state/sha256.S", +) + +HASH_SIZE = 16 +SIGNATURE_SIZE = 64 +PACKED_PREFIX = 96 +PARSED_SIZE = 516 +MAX_STAMPED = 431 + + +def _identity_material(seed: bytes) -> oracle.IdentityMaterial: + xsk = bytes((i * 7 + 3) & 0xFF for i in range(32)) + return oracle.identity_from_private_parts(xsk, seed) + + +IDENTITY = _identity_material( + bytes.fromhex( + "4ccd089b28ff96da9db6c346ec114e0f" + "5b8a319f35aba624da8cf6ed4fb8a6fb" + ) +) +WRONG_IDENTITY = _identity_material(bytes([0xA5]) * 32) +DEST = bytes(range(16)) +NO_STAMP = msgpack.packb([1.5, b"Title", b"Body", {}]) +ALT_PAYLOAD = msgpack.packb([1.5, b"Title", b"Body!", {}]) +STAMP16 = bytes([0x95]) + NO_STAMP[1:] + b"\xc4\x10" + (b"\x11" * 16) + + +@dataclasses.dataclass(frozen=True) +class Case: + name: str + payload: bytes = NO_STAMP + sign_payload: bytes = NO_STAMP + dest: bytes = DEST + source: bytes = IDENTITY.hash + public_key: bytes = IDENTITY.ed25519_public + expect_ret: int = 0 + flip_sig: bool = False + raw_override: bytes | None = None + null_raw: bool = False + null_out: bool = False + null_pk: bool = False + + +def _signature(case: Case) -> bytes: + message_id = hashlib.sha256(case.dest + case.source + case.sign_payload).digest() + sig = bytearray( + oracle.ed25519_sign( + IDENTITY.ed25519_seed, + case.dest + case.source + case.sign_payload + message_id, + ) + ) + if case.flip_sig: + sig[0] ^= 0x01 + return bytes(sig) + + +def _packed(case: Case) -> bytes: + if case.raw_override is not None: + return case.raw_override + return case.dest + case.source + _signature(case) + case.payload + + +CASES = ( + Case("basic"), + Case("stamped", payload=STAMP16, sign_payload=NO_STAMP), + Case("bad-signature", expect_ret=-5, flip_sig=True), + Case("tampered-payload", payload=ALT_PAYLOAD, sign_payload=NO_STAMP, expect_ret=-5), + Case("wrong-public-key", public_key=WRONG_IDENTITY.ed25519_public, expect_ret=-5), + Case("short-prefix", raw_override=bytes(PACKED_PREFIX - 1), expect_ret=-6), + Case("malformed-payload", raw_override=DEST + IDENTITY.hash + bytes(SIGNATURE_SIZE) + b"\x93", expect_ret=-1), + Case( + "payload-overflow", + raw_override=DEST + IDENTITY.hash + bytes(SIGNATURE_SIZE) + bytes(MAX_STAMPED + 1), + expect_ret=-2, + ), + Case("null-raw", expect_ret=-1, null_raw=True), + Case("null-out", expect_ret=-1, null_out=True), + Case("null-pk", expect_ret=-1, null_pk=True), +) + + +@pytest.fixture(scope="module") +def artifacts() -> build.BuildArtifacts: + return build.build("qemu-virt") + + +def _require_tool(name: str) -> str: + path = shutil.which(name) + if path is None: + pytest.skip(f"{name} not on PATH") + return path + + +def _run(cmd: list[str]) -> None: + proc = subprocess.run( + cmd, + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, ( + f"{' '.join(cmd)} failed with exit {proc.returncode}\n" + f"stdout:\n{proc.stdout}\n" + f"stderr:\n{proc.stderr}" + ) + + +def _byte_list(data: bytes) -> str: + if not data: + return "0x00" + return ", ".join(f"0x{b:02x}" for b in data) + + +def _emit_helpers() -> str: + return """ +.Lemit_hex32: + addi sp, sp, -16 + sw ra, 12(sp) + mv t2, a0 + li t3, 28 +1: srl t4, t2, t3 + andi a0, t4, 15 + call .Lnibble_to_ascii + call .Lputc + addi t3, t3, -4 + bgez t3, 1b + lw ra, 12(sp) + addi sp, sp, 16 + ret + +.Lemit_sep: + addi sp, sp, -16 + sw ra, 12(sp) + li a0, 58 + call .Lputc + lw ra, 12(sp) + addi sp, sp, 16 + ret + +.Lemit_bytes: + addi sp, sp, -16 + sw s0, 0(sp) + sw s1, 4(sp) + sw ra, 12(sp) + mv s0, a0 + mv s1, a1 +2: beqz s1, 3f + lbu t2, 0(s0) + srli a0, t2, 4 + call .Lnibble_to_ascii + call .Lputc + lbu t2, 0(s0) + andi a0, t2, 15 + call .Lnibble_to_ascii + call .Lputc + addi s0, s0, 1 + addi s1, s1, -1 + j 2b +3: lw s0, 0(sp) + lw s1, 4(sp) + lw ra, 12(sp) + addi sp, sp, 16 + ret + +.Lnibble_to_ascii: + li t0, 10 + bltu a0, t0, 4f + addi a0, a0, 87 + ret +4: addi a0, a0, 48 + ret + +.Lputc: + li t0, 0x10000000 +5: lbu t1, 5(t0) + andi t1, t1, 0x20 + beqz t1, 5b + sb a0, 0(t0) + ret +""" + + +def _harness(case: Case) -> str: + raw = _packed(case) + raw_ptr = "mv a0, zero" if case.null_raw else "la a0, packed_msg" + out_ptr = "mv a2, zero" if case.null_out else "la a2, parsed" + pk_ptr = "mv a3, zero" if case.null_pk else "la a3, source_pk" + return f""" + .include "lxmf.S" + + .section .text._reset, "ax", @progbits + .global _reset + .type _reset, @function +_reset: + la sp, __stack_top + + .option push + .option norelax + la gp, __global_pointer$ + .option pop + + {raw_ptr} + li a1, {len(raw)} + {out_ptr} + {pk_ptr} + call lxmf_message_parse + mv s0, a0 + call .Lemit_hex32 + blt s0, zero, .Ldone + + call .Lemit_sep + la t0, parsed + addi a0, t0, LXMF_MESSAGE_PARSED_OFF_MESSAGE_ID + li a1, 32 + call .Lemit_bytes + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_LEN(t0) + call .Lemit_hex32 + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_LEN(t0) + call .Lemit_hex32 + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_SOURCE_HASH_PTR(t0) + li a1, 16 + call .Lemit_bytes + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_SIGNATURE_PTR(t0) + li a1, 64 + call .Lemit_bytes + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_PTR(t0) + lw a1, LXMF_MESSAGE_PARSED_OFF_PAYLOAD_LEN(t0) + call .Lemit_bytes + + call .Lemit_sep + la t0, parsed + lw a0, LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_PTR(t0) + lw a1, LXMF_MESSAGE_PARSED_OFF_WITHOUT_STAMP_LEN(t0) + call .Lemit_bytes + +.Ldone: + li a0, 13 + call .Lputc + li a0, 10 + call .Lputc +.Lhalt: + wfi + j .Lhalt + +{_emit_helpers()} + + .section .rodata.input, "a", @progbits +packed_msg: + .byte {_byte_list(raw)} +source_pk: + .byte {_byte_list(case.public_key)} + + .section .bss.output, "aw", @nobits + .balign 4 +parsed: + .skip {PARSED_SIZE} +""" + + +def _build_test_elf(tmp_path: Path, asm: str) -> Path: + as_bin = _require_tool("riscv64-elf-as") + ld_bin = _require_tool("riscv64-elf-ld") + + harness = tmp_path / "lxmf_message_parse_harness.S" + harness.write_text(asm, encoding="utf-8") + + objects: list[Path] = [] + for idx, src in enumerate((harness, *(REPO_ROOT / s for s in ASM_SOURCES))): + obj = tmp_path / f"{idx:02d}_{Path(src).stem}.o" + _run([ + as_bin, + "-march=rv32imac", + "-mabi=ilp32", + "--defsym", + "TARGET_QEMU_VIRT=1", + "-I", + str(REPO_ROOT / "src" / "include"), + "-o", + str(obj), + str(src), + ]) + objects.append(obj) + + elf = tmp_path / "lxmf_message_parse.elf" + _run([ + ld_bin, + "-nostdlib", + "-static", + "--no-warn-rwx-segments", + "-T", + str(REPO_ROOT / "toolchain" / "qemu-virt.ld"), + "-o", + str(elf), + *(str(obj) for obj in objects), + ]) + return elf + + +def _run_qemu(tmp_path: Path, asm: str) -> bytes: + cfg = target.TargetConfig(binary=_build_test_elf(tmp_path, asm)) + emu = target.EmuTarget(cfg) + if not emu.is_available(): + pytest.skip("qemu-system-riscv32 not available") + + out = bytearray() + with emu: + for _ in range(240): + out.extend(emu.read(2048, timeout=0.5)) + if out.count(b"\n") >= 1: + break + return bytes(out).splitlines()[0] + + +def _parse_ret(ret_hex: bytes) -> int: + ret_u32 = int(ret_hex, 16) + return ret_u32 - (1 << 32) if ret_u32 & 0x80000000 else ret_u32 + + +def _parse_line(line: bytes) -> tuple[int, list[bytes]]: + parts = line.split(b":") + ret = _parse_ret(parts[0]) + if ret < 0: + return ret, [] + return ret, [bytes.fromhex(p.decode()) for p in parts[1:]] + + +def _u32(data: bytes) -> int: + return int.from_bytes(data, "big") + + +def test_symbol_exists(artifacts: build.BuildArtifacts) -> None: + assert build.symbol_address(artifacts.elf, "lxmf_message_parse") > 0 + + +@pytest.mark.parametrize("case", CASES, ids=[c.name for c in CASES]) +def test_lxmf_message_parse_qemu(tmp_path: Path, case: Case) -> None: + ret, outputs = _parse_line(_run_qemu(tmp_path, _harness(case))) + assert ret == case.expect_ret + if ret < 0: + assert outputs == [] + return + + message_id, payload_len, without_len, source, signature, payload, without = outputs + assert message_id == hashlib.sha256(case.dest + case.source + case.sign_payload).digest() + assert _u32(payload_len) == len(case.payload) + assert _u32(without_len) == len(case.sign_payload) + assert source == case.source + assert signature == _signature(case) + assert payload == case.payload + assert without == case.sign_payload