mirror of
https://github.com/ratspeak/C6-Reticulum-ASM
synced 2026-08-12 18:07:18 -04:00
implement lxmf message parsing
This commit is contained in:
parent
e7796b02a1
commit
4e4aed82b2
5 changed files with 739 additions and 3 deletions
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
168
proofs/lxmf/lxmf_message_parse.py
Normal file
168
proofs/lxmf/lxmf_message_parse.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
132
src/lxmf/lxmf_message_parse.S
Normal file
132
src/lxmf/lxmf_message_parse.S
Normal 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
|
||||
418
tests/lxmf/test_lxmf_message_parse.py
Normal file
418
tests/lxmf/test_lxmf_message_parse.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue