implement lxmf message parsing

This commit is contained in:
DeFiDude 2026-05-04 18:24:27 -06:00
parent e7796b02a1
commit 4e4aed82b2
5 changed files with 739 additions and 3 deletions

View file

@ -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) |

View file

@ -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()

View file

@ -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.

View file

@ -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

View file

@ -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