Three deterministic vector files complete the test-vectors/ bootstrap.
Each regenerator pins every random source so output is byte-identical
across runs against a fixed upstream RNS / LXMF version.
- announces.json: two vectors (no-ratchet + with-ratchet) signed by
Alice. Determinism via patched Identity.get_random_hash + module-
local time.time shim inside RNS.Destination.
- lxmf.json: two opportunistic-LXMF vectors Alice -> Bob, captures
full plaintext (S5.2 layout) plus Token-encrypted ciphertext (S3).
Determinism via fixed LXMessage.timestamp, ephemeral X25519 priv,
and Token CBC IV.
- links.json: full Link handshake — LINKREQUEST + LRPROOF wire bytes,
derived link_id, ECDH shared secret, and HKDF-derived session key
that both initiator and responder MUST agree on. Determinism via
three queued ephemeral priv-key blobs (initiator X25519, initiator
Ed25519, responder X25519) consumed in source-call order at
RNS/Link.py:285, :286, :278.
Status table in test-vectors/README.md and tools/README.md updated to
reflect the completed bootstrap. todo.md cleaned up to reflect actual
state (the previous "Open ⚠️ items needing a runtime verifier" section
was stale — all three verifiers were completed earlier).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
9.6 KiB
Python
243 lines
9.6 KiB
Python
"""
|
|
Regenerator for test-vectors/announces.json.
|
|
|
|
Builds two deterministic announce wire-byte vectors using Alice's
|
|
identity from `identities.json`:
|
|
|
|
1. `alice_lxmf_no_ratchet` — context_flag=0, app_data = LXMF
|
|
2-element form `[display_name_bytes, stamp_cost]` (S4.3)
|
|
2. `alice_lxmf_with_ratchet` — context_flag=1, fixed ratchet priv,
|
|
same app_data
|
|
|
|
Determinism inputs:
|
|
|
|
- Identity = Alice (private_key from `identities.json`); Ed25519
|
|
signing is deterministic per RFC 8032 so the signature byte
|
|
sequence is reproducible from the same plaintext + key.
|
|
- `random_hash[0:5]` = patched constant prefix.
|
|
- `random_hash[5:10]` = patched fixed unix-seconds value (we override
|
|
`time.time` inside `RNS.Destination` for the duration of the
|
|
announce build).
|
|
- `ratchet_priv` = fixed 32 bytes (vector #2 only).
|
|
- `app_data` = `umsgpack.packb([b"AliceTest", 0])` per S4.3.
|
|
|
|
After build we run `RNS.Identity.validate_announce` on the produced
|
|
packet and assert it accepts; this proves the recorded bytes are
|
|
upstream-valid.
|
|
|
|
Run from repo root:
|
|
|
|
python tools/regen_announces.py
|
|
|
|
Updates `test-vectors/announces.json` in place. Exit 0 on success.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
import RNS
|
|
from RNS.vendor import umsgpack
|
|
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
OUT_PATH = os.path.join(REPO_ROOT, "test-vectors", "announces.json")
|
|
IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json")
|
|
|
|
|
|
# Stable inputs. These are vector-deterministic; do not change without
|
|
# regenerating every announce vector.
|
|
FIXED_RANDOM_PREFIX = bytes.fromhex("a1a2a3a4a5") # random_hash[0:5]
|
|
FIXED_TIMESTAMP = 1700000000 # 2023-11-14, BE-uint40 -> random_hash[5:10]
|
|
FIXED_RATCHET_PRIV = bytes.fromhex("b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0")
|
|
FIXED_APP_DATA = umsgpack.packb([b"AliceTest", 0]) # S4.3 2-element form
|
|
|
|
|
|
def fail(msg: str) -> None:
|
|
print(f"FAIL: {msg}")
|
|
sys.exit(1)
|
|
|
|
|
|
def init_minimal_rns():
|
|
cfg_dir = tempfile.mkdtemp(prefix="rns-regen-announces-")
|
|
cfg_path = os.path.join(cfg_dir, "config")
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
f.write("[reticulum]\nenable_transport = No\nshare_instance = No\n")
|
|
return RNS.Reticulum(configdir=cfg_dir, loglevel=0)
|
|
|
|
|
|
def load_alice() -> RNS.Identity:
|
|
with open(IDS_PATH, "r", encoding="utf-8") as f:
|
|
ids = json.load(f)
|
|
alice = next(v for v in ids["vectors"] if v["label"] == "alice")
|
|
prv_bytes = bytes.fromhex(alice["inputs"]["private_key_hex"])
|
|
identity = RNS.Identity.from_bytes(prv_bytes)
|
|
if identity is None:
|
|
fail("Identity.from_bytes returned None for Alice")
|
|
return identity, alice
|
|
|
|
|
|
def build_deterministic_announce(identity, with_ratchet: bool) -> bytes:
|
|
"""Drive `Destination.announce(send=False)` with patched time/random
|
|
sources so the wire bytes are reproducible. Returns packed raw bytes."""
|
|
import sys as _sys
|
|
# `RNS.Identity` and `RNS.Destination` are the classes (re-exported in
|
|
# RNS/__init__.py); the underlying modules live in sys.modules.
|
|
dest_mod = _sys.modules["RNS.Destination"]
|
|
id_cls = RNS.Identity # the class, where get_random_hash is defined
|
|
|
|
# Patch the get_random_hash classmethod so random_hash[0:5] is fixed.
|
|
# Destination.announce calls it once per announce (Destination.py:282).
|
|
real_get_random_hash = id_cls.get_random_hash
|
|
def patched_get_random_hash():
|
|
return FIXED_RANDOM_PREFIX + b"\x00" * 27
|
|
id_cls.get_random_hash = staticmethod(patched_get_random_hash)
|
|
|
|
# Patch time.time inside the Destination module so
|
|
# int(time.time()).to_bytes(5,'big') -> FIXED_TIMESTAMP. Replacing
|
|
# `dest_mod.time.time` would mutate the global `time` module; instead
|
|
# swap the module-level `time` reference itself.
|
|
real_time_module = dest_mod.time
|
|
class _FixedTime:
|
|
@staticmethod
|
|
def time(): return float(FIXED_TIMESTAMP)
|
|
dest_mod.time = _FixedTime
|
|
|
|
try:
|
|
# Use a dedicated aspect per vector so the Destination cache
|
|
# doesn't reject the second registration in this same process.
|
|
aspect = "alice_announce_with_ratchet" if with_ratchet else "alice_announce_no_ratchet"
|
|
dest = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE,
|
|
"vectors", aspect)
|
|
|
|
if with_ratchet:
|
|
# Force the ratchet without going through enable_ratchets (which
|
|
# writes to disk and could pick up stale state). Setting
|
|
# latest_ratchet_time = now-eps (>= now-INTERVAL) makes
|
|
# rotate_ratchets a no-op so our fixed priv is preserved.
|
|
dest.ratchets = [FIXED_RATCHET_PRIV]
|
|
dest.ratchet_interval = RNS.Destination.RATCHET_INTERVAL
|
|
dest.latest_ratchet_time = float(FIXED_TIMESTAMP)
|
|
dest.path_responses = {}
|
|
# else: leave dest.ratchets = None so context_flag=0
|
|
|
|
pkt = dest.announce(app_data=FIXED_APP_DATA, send=False)
|
|
pkt.pack()
|
|
|
|
# Sanity: validate_announce must accept the packet we just built.
|
|
# We must rebuild the Packet from raw bytes via the inbound path
|
|
# because packet.unpack mutates state.
|
|
if not pkt.unpack():
|
|
fail(f"Packet.unpack returned False ({aspect})")
|
|
if not RNS.Identity.validate_announce(pkt):
|
|
fail(f"validate_announce rejected freshly-built vector ({aspect})")
|
|
|
|
return pkt.raw, dest.hash
|
|
finally:
|
|
id_cls.get_random_hash = staticmethod(real_get_random_hash)
|
|
dest_mod.time = real_time_module
|
|
|
|
|
|
def split_body(raw: bytes, with_ratchet: bool) -> dict:
|
|
"""Split announce wire bytes into the named fields per S4.1 so the
|
|
JSON vector is human-inspectable."""
|
|
KEYSIZE, NAME_HASH, RANDOM_HASH, RATCHET, SIG = 64, 10, 10, 32, 64
|
|
flags = raw[0]
|
|
hops = raw[1]
|
|
dst = raw[2:18]
|
|
ctx = raw[18]
|
|
|
|
body = raw[19:]
|
|
pub = body[0:KEYSIZE]
|
|
nm = body[KEYSIZE:KEYSIZE+NAME_HASH]
|
|
rh = body[KEYSIZE+NAME_HASH:KEYSIZE+NAME_HASH+RANDOM_HASH]
|
|
|
|
if with_ratchet:
|
|
rt = body[KEYSIZE+NAME_HASH+RANDOM_HASH:KEYSIZE+NAME_HASH+RANDOM_HASH+RATCHET]
|
|
sigs = KEYSIZE+NAME_HASH+RANDOM_HASH+RATCHET
|
|
else:
|
|
rt = b""
|
|
sigs = KEYSIZE+NAME_HASH+RANDOM_HASH
|
|
|
|
sig = body[sigs:sigs+SIG]
|
|
ad = body[sigs+SIG:]
|
|
|
|
fields = {
|
|
"header_flags_hex": bytes([flags]).hex(),
|
|
"header_hops_hex": bytes([hops]).hex(),
|
|
"header_dest_hash_hex": dst.hex(),
|
|
"header_context_hex": bytes([ctx]).hex(),
|
|
"body_public_key_hex": pub.hex(),
|
|
"body_name_hash_hex": nm.hex(),
|
|
"body_random_hash_hex": rh.hex(),
|
|
"body_signature_hex": sig.hex(),
|
|
"body_app_data_hex": ad.hex(),
|
|
}
|
|
if with_ratchet:
|
|
fields["body_ratchet_pub_hex"] = rt.hex()
|
|
return fields
|
|
|
|
|
|
def main():
|
|
print(f"regen_announces.py against RNS {RNS.__version__}")
|
|
init_minimal_rns()
|
|
try:
|
|
identity, alice = load_alice()
|
|
|
|
vectors = []
|
|
for label, with_ratchet in [
|
|
("alice_lxmf_no_ratchet", False),
|
|
("alice_lxmf_with_ratchet", True),
|
|
]:
|
|
raw, dest_hash = build_deterministic_announce(identity, with_ratchet)
|
|
fields = split_body(raw, with_ratchet)
|
|
vectors.append({
|
|
"label": label,
|
|
"context_flag": 1 if with_ratchet else 0,
|
|
"with_ratchet": with_ratchet,
|
|
"inputs": {
|
|
"identity_label": "alice",
|
|
"destination_full_name": "vectors." + ("alice_announce_with_ratchet" if with_ratchet else "alice_announce_no_ratchet"),
|
|
"random_hash_prefix_hex": FIXED_RANDOM_PREFIX.hex(),
|
|
"random_hash_timestamp": FIXED_TIMESTAMP,
|
|
"ratchet_priv_hex": FIXED_RATCHET_PRIV.hex() if with_ratchet else None,
|
|
"app_data_msgpack_hex": FIXED_APP_DATA.hex(),
|
|
"app_data_decoded": ["AliceTest", 0],
|
|
},
|
|
"expected": {
|
|
"destination_hash_hex": dest_hash.hex(),
|
|
"wire_bytes_hex": raw.hex(),
|
|
"fields": fields,
|
|
},
|
|
"rns_version_at_generation": RNS.__version__,
|
|
"generator_script": "tools/regen_announces.py",
|
|
"verifies_spec_sections": ["2.1", "4.1", "4.2", "4.3", "4.5"],
|
|
})
|
|
|
|
payload = {
|
|
"_about": (
|
|
"Announce test vectors. Each entry's `expected.wire_bytes_hex` "
|
|
"is the full packed announce bytes (header + body). Drop them "
|
|
"into a fresh RNS.Packet via the inbound path and call "
|
|
"RNS.Identity.validate_announce; it MUST accept. The "
|
|
"`expected.fields` block decomposes the wire bytes into "
|
|
"named slices per SPEC.md S4.1 for human inspection. "
|
|
"Regenerate with the script in `generator_script`."
|
|
),
|
|
"vectors": vectors,
|
|
}
|
|
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as f:
|
|
json.dump(payload, f, indent=2, sort_keys=False)
|
|
f.write("\n")
|
|
print(f"Wrote {OUT_PATH} with {len(vectors)} vectors")
|
|
print("ALL PASS")
|
|
finally:
|
|
try: RNS.Reticulum.exit_handler()
|
|
except Exception: pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|