prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use super::*;
use crate::crypto::{ed25519_public_key, ed25519_sign, sha256, Ed25519SecretKey};
use crate::wire::{WireContext, HEADER_MIN_LEN};

const RAW: &str = "010016f8a6d3f7d7c5b6f106d293804d73140002281f6d21232cbba9d12e516183197f08e\
                   59b7afba27e99e4fe39f01b0d4d2583a5920220253970a16861e82e52e955a05ee39e2b6d2\
                   0a2331f515512f667009618ccc8f5ebce0600845468d9b829006a172e839fc07deb9b065b91\
                   7b2891e6d143e6bfc3b80cbdca33f1f85a9ef68835693cb252ba60f558f84436c91761e6f97\
                   4d0daa069e56495df1870f85d6e6b5af2640868656c6c6f2d706572736f6e616c";

fn bytes_from_hex(s: &str) -> Vec<u8> {
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
        .collect()
}
fn a<const N: usize>(s: &str) -> [u8; N] {
    bytes_from_hex(s).try_into().expect("expected length")
}

#[test]
fn from_wire_validates_real_rns_announce() {
    let raw = bytes_from_hex(RAW);
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    let announce = Announce::from_wire(&header, payload).unwrap();

    assert_eq!(
        announce.destination,
        DestinationHash::new(a("16f8a6d3f7d7c5b6f106d293804d7314")),
    );
    assert_eq!(
        announce.public_keys.encryption,
        IdentityEncryptionPublicKey::new(X25519PublicKey(a(
            "02281f6d21232cbba9d12e516183197f08e59b7afba27e99e4fe39f01b0d4d25"
        ))),
    );
    assert_eq!(
        announce.public_keys.signing,
        IdentitySigningPublicKey::new(Ed25519PublicKey(a(
            "83a5920220253970a16861e82e52e955a05ee39e2b6d20a2331f515512f66700"
        ))),
    );
    assert_eq!(
        announce.dotted_name_hash,
        DottedNameHash::new(a("9618ccc8f5ebce060084"))
    );
    assert_eq!(
        announce.announce_id,
        AnnounceId::from_wire(a("5468d9b829006a172e83"))
    );
    assert_eq!(announce.ratchet, None);
    assert_eq!(announce.app_data, b"hello-personal");
}

#[test]
fn to_wire_reproduces_the_real_payload_exactly() {
    let raw = bytes_from_hex(RAW);
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    let announce = Announce::from_wire(&header, payload).unwrap();

    let mut buf = [0u8; BROADCAST_MTU];
    let n = announce.to_wire(&mut buf).unwrap();
    assert_eq!(n, payload.len());
    assert_eq!(&buf[..n], payload);
    assert_eq!(n, announce.wire_bytes());
}

#[test]
fn a_path_response_is_a_normal_announce_with_only_the_context_byte_flipped() {
    let raw = bytes_from_hex(RAW);
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    let announce = Announce::from_wire(&header, payload).unwrap();

    let mut normal = [0u8; 500];
    let n = write_announce_wire_packet(&announce, 0, &mut normal).unwrap();
    let mut response = [0u8; 500];
    let m = write_path_response_announce_wire_packet(&announce, 0, &mut response).unwrap();
    assert_eq!(n, m);

    let context_offset = HEADER_MIN_LEN - 1;
    assert_eq!(normal[context_offset], WireContext::None.to_byte());
    assert_eq!(
        response[context_offset],
        WireContext::PathResponse.to_byte()
    );

    let mut patched = response;
    patched[context_offset] = WireContext::None.to_byte();
    assert_eq!(
        &patched[..m],
        &normal[..n],
        "the only difference from a normal announce is the context byte",
    );

    let (re_header, re_payload) = WirePacketHeader::parse(&response[..m]).unwrap();
    assert_eq!(re_header.context, WireContext::PathResponse);
    assert_eq!(re_header.packet_type, PacketType::Announce);
    assert_eq!(
        Announce::from_wire(&re_header, re_payload).unwrap(),
        announce
    );
}

#[test]
fn rejects_tampered_signature() {
    let mut raw = bytes_from_hex(RAW);
    raw[103] ^= 1;
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    assert_eq!(
        Announce::from_wire(&header, payload),
        Err(AnnounceValidationError::InvalidSignature),
    );
}

#[test]
fn rejects_non_single_destination() {
    let mut raw = bytes_from_hex(RAW);
    raw[0] |= 0b0000_0100;
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    assert_eq!(
        Announce::from_wire(&header, payload),
        Err(AnnounceValidationError::NotSingleDestination),
    );
}

#[test]
fn rejects_truncated_payload() {
    let raw = bytes_from_hex(RAW);
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    assert_eq!(
        Announce::from_wire(&header, &payload[..100]),
        Err(AnnounceValidationError::PayloadTooSmall),
    );
}

#[test]
fn rejects_oversized_payload() {
    let raw = bytes_from_hex(RAW);
    let (header, _) = WirePacketHeader::parse(&raw).unwrap();
    let oversized = [0u8; 600];
    assert_eq!(
        Announce::from_wire(&header, &oversized),
        Err(AnnounceValidationError::PayloadTooBig),
    );
}

fn synthetic_announce(
    signed_destination: [u8; 16],
    ratchet: Option<[u8; RATCHET_BYTE_LEN]>,
    app_data: &[u8],
) -> Vec<u8> {
    let secret = Ed25519SecretKey::new([0x11u8; 32]);
    let signing = ed25519_public_key(&secret).0;
    let mut pubkey = [0u8; 64];
    pubkey[..32].copy_from_slice(&[0x22u8; 32]);
    pubkey[32..].copy_from_slice(&signing);
    let name_hash = [0x33u8; 10];
    let announce_id = [0x44u8; 10];

    let mut signed = Vec::new();
    signed.extend_from_slice(&signed_destination);
    signed.extend_from_slice(&pubkey);
    signed.extend_from_slice(&name_hash);
    signed.extend_from_slice(&announce_id);
    if let Some(ratchet) = ratchet {
        signed.extend_from_slice(&ratchet);
    }
    signed.extend_from_slice(app_data);
    let sig = ed25519_sign(&secret, &signed).0;

    let mut payload = Vec::new();
    payload.extend_from_slice(&pubkey);
    payload.extend_from_slice(&name_hash);
    payload.extend_from_slice(&announce_id);
    if let Some(ratchet) = ratchet {
        payload.extend_from_slice(&ratchet);
    }
    payload.extend_from_slice(&sig);
    payload.extend_from_slice(app_data);

    let flags = if ratchet.is_some() { 0x21 } else { 0x01 };
    let mut raw = vec![flags, 0x00];
    raw.extend_from_slice(&signed_destination);
    raw.push(0x00);
    raw.extend_from_slice(&payload);
    raw
}

fn bound_destination() -> [u8; 16] {
    let signing = ed25519_public_key(&Ed25519SecretKey::new([0x11u8; 32])).0;
    let mut pubkey = [0u8; 64];
    pubkey[..32].copy_from_slice(&[0x22u8; 32]);
    pubkey[32..].copy_from_slice(&signing);
    let mut idh = [0u8; 16];
    idh.copy_from_slice(&sha256(&pubkey)[..16]);
    *derive_destination_hash(&IdentityHash::new(idh), &DottedNameHash::new([0x33u8; 10])).as_bytes()
}

#[test]
fn synthetic_announce_with_correct_binding_validates() {
    let raw = synthetic_announce(bound_destination(), None, b"app");
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    let announce = Announce::from_wire(&header, payload).unwrap();
    assert_eq!(announce.app_data, b"app");
    assert_eq!(
        announce.destination,
        DestinationHash::new(bound_destination())
    );
}

#[test]
fn synthetic_ratchet_announce_validates_and_round_trips() {
    let ratchet = [0x55u8; RATCHET_BYTE_LEN];
    let raw = synthetic_announce(bound_destination(), Some(ratchet), b"ratchet-app");
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    assert_eq!(header.context_flag, ContextFlag::Set);

    let announce = Announce::from_wire(&header, payload).unwrap();
    assert_eq!(announce.ratchet, Some(RatchetKey::new(ratchet)));
    assert_eq!(announce.app_data, b"ratchet-app");

    let mut buf = [0u8; BROADCAST_MTU];
    let n = announce.to_wire(&mut buf).unwrap();
    assert_eq!(n, payload.len());
    assert_eq!(&buf[..n], payload);
}

#[test]
fn rejects_destination_mismatch() {
    let raw = synthetic_announce([0x99u8; 16], None, b"app");
    let (header, payload) = WirePacketHeader::parse(&raw).unwrap();
    assert_eq!(
        Announce::from_wire(&header, payload),
        Err(AnnounceValidationError::DestinationMismatch),
    );
}

#[test]
fn derive_destination_hash_matches_rns_1_4_2() {
    let identity_hash = IdentityHash::new(a("4cd0cc45a7405dbd5cf9b5be1ef92f10"));
    let dotted_name_hash = DottedNameHash::new(a("8794b70072dbf251144b"));
    assert_eq!(
        derive_destination_hash(&identity_hash, &dotted_name_hash),
        DestinationHash::new(a("33d610d1d6a7f4f809ebfe62c0ce7d43")),
    );

    let real_pubkey = a::<64>(
        "02281f6d21232cbba9d12e516183197f08e59b7afba27e99e4fe39f01b0d4d25\
         83a5920220253970a16861e82e52e955a05ee39e2b6d20a2331f515512f66700",
    );
    let mut real_identity_hash = [0u8; 16];
    real_identity_hash.copy_from_slice(&sha256(&real_pubkey)[..16]);
    assert_eq!(
        derive_destination_hash(
            &IdentityHash::new(real_identity_hash),
            &DottedNameHash::new(a("9618ccc8f5ebce060084")),
        ),
        DestinationHash::new(a("16f8a6d3f7d7c5b6f106d293804d7314")),
    );
}

#[test]
fn derive_plain_destination_hash_matches_rns_1_4_2() {
    let name = expand_name("rnstransport", &["path", "request"]).unwrap();
    assert_eq!(name, DottedNameHash::new(a("7926bbe7dd7f9aba88b0")));
    assert_eq!(
        derive_plain_destination_hash(&name),
        DestinationHash::new(a("6b9f66014d9853faab220fba47d02761")),
    );
}

#[test]
fn derive_single_destination_hash_composes_the_rns_1_4_2_address_from_name_parts() {
    let identity_hash = IdentityHash::new(a("4cd0cc45a7405dbd5cf9b5be1ef92f10"));
    assert_eq!(
        derive_single_destination_hash(&identity_hash, "personal", &["node"]),
        Ok(DestinationHash::new(a("c3cfae69b36bb6e3bbfd96a3b5867a59"))),
    );
    assert_eq!(
        derive_single_destination_hash(&identity_hash, "per.sonal", &["node"]),
        Err(ExpandNameError::DotInComponent),
    );
}

#[test]
fn expand_name_matches_rns_1_4_2() {
    assert_eq!(
        expand_name("personal", &["announce"]).unwrap(),
        DottedNameHash::new(a("8794b70072dbf251144b")),
    );
    assert_eq!(
        expand_name("personal", &["node"]).unwrap(),
        DottedNameHash::new(a("ab49baa826f122c1437f")),
    );
    assert_eq!(
        expand_name("personal", &[]).unwrap(),
        DottedNameHash::new(a("4a0a339b0c6d05538977")),
    );
}

#[test]
fn expand_name_rejects_dots_in_components_like_rns() {
    assert_eq!(
        expand_name("per.sonal", &["node"]),
        Err(ExpandNameError::DotInComponent),
    );
    assert_eq!(
        expand_name("personal", &["no.de"]),
        Err(ExpandNameError::DotInComponent),
    );
}

#[test]
fn expand_name_rejects_names_past_the_bound() {
    let overlong = "x".repeat(MAX_DOTTED_NAME_LEN + 1);
    assert_eq!(
        expand_name(&overlong, &[]),
        Err(ExpandNameError::NameTooLong),
    );
}

#[test]
fn build_signed_matches_rns_1_4_2() {
    use crate::identity::in_memory::InMemoryNodeIdentity;
    let mut secret_key_bytes = [0u8; 64];
    secret_key_bytes[..32].fill(0x22);
    secret_key_bytes[32..].fill(0x11);
    let identity = InMemoryNodeIdentity::from_secret_key_bytes(&secret_key_bytes);

    let announce = Announce::build_signed(
        &identity,
        DottedNameHash::new(a("8794b70072dbf251144b")),
        AnnounceId::from_wire([0x44; ANNOUNCE_ID_WIRE_LEN]),
        None,
        b"hello-personal",
    )
    .unwrap();

    assert_eq!(
        announce.destination,
        DestinationHash::new(a("33d610d1d6a7f4f809ebfe62c0ce7d43")),
    );
    let mut buf = [0u8; BROADCAST_MTU];
    let n = announce.to_wire(&mut buf).unwrap();
    assert_eq!(
        &buf[..n],
        bytes_from_hex(
            "0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20\
            d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737\
            8794b70072dbf251144b\
            44444444444444444444\
            77000516a77f83f26b6fd0abc4b9b8a0de0fd8bc51f82fe55e14b75628b41955\
            c895395870fe4cd0b69afc85e4969cc3b70dbeb14d8c3c7ddc08692e0968010e\
            68656c6c6f2d706572736f6e616c"
        )
        .as_slice(),
    );
}

#[test]
fn build_signed_round_trips_through_the_validator() {
    use crate::identity::in_memory::InMemoryNodeIdentity;
    let mut secret_key_bytes = [0u8; 64];
    secret_key_bytes[..32].fill(0x07);
    secret_key_bytes[32..].fill(0x09);
    let identity = InMemoryNodeIdentity::from_secret_key_bytes(&secret_key_bytes);

    let built = Announce::build_signed(
        &identity,
        DottedNameHash::new([0xAB; DOTTED_NAME_HASH_BYTE_LEN]),
        AnnounceId::from_wire([0x01; ANNOUNCE_ID_WIRE_LEN]),
        Some(RatchetKey::new([0x55; RATCHET_BYTE_LEN])),
        b"round-trip",
    )
    .unwrap();

    let mut payload = [0u8; BROADCAST_MTU];
    let n = built.to_wire(&mut payload).unwrap();
    let mut raw = vec![0x21u8, 0x00];
    raw.extend_from_slice(built.destination.as_bytes());
    raw.push(0x00);
    raw.extend_from_slice(&payload[..n]);

    let (header, parsed_payload) = WirePacketHeader::parse(&raw).unwrap();
    let parsed = Announce::from_wire(&header, parsed_payload).unwrap();
    assert_eq!(parsed, built);
}