rings-node 0.20.0

Rings is a structured peer-to-peer network implementation using WebRTC, Chord algorithm, and full WebAssembly (WASM) support.
Documentation
use rings_core::dht::entry;
use rings_core::ecc::SecretKey;
use rings_core::message::Encoder;
use rings_core::session::SessionSk;

use super::super::*;

fn service(name: &str) -> OnionExitService {
    OnionExitService::new(name, OnionExitTransport::Tcp).expect("valid test service")
}

fn signed_exit_at(heartbeat_at_ms: u128, expires_at_ms: u128) -> Result<OnionExitDescriptor> {
    let key = SecretKey::random();
    let session_sk = SessionSk::new_with_seckey(&key).map_err(Error::CoreError)?;
    signed_exit_for_session_at(
        &session_sk,
        service("web"),
        heartbeat_at_ms,
        expires_at_ms,
        "test",
    )
}

fn signed_exit_for_session_at(
    session_sk: &SessionSk,
    service: OnionExitService,
    heartbeat_at_ms: u128,
    expires_at_ms: u128,
    version: &str,
) -> Result<OnionExitDescriptor> {
    let did = session_sk.account_did();
    OnionExitDescriptor::new_signed(
        OnionExitDescriptorBody {
            did,
            public_key: session_sk
                .session()
                .account_verification_pubkey()
                .map_err(Error::CoreError)?,
            session_public_key: session_sk.session_public_key(),
            node_type: OnlineNodeType::Native,
            network_id: 1,
            service,
            policy: OnionExitPolicy {
                allowed_targets: vec![OnionExitTarget::parse("example.com:443")?],
                denied_targets: vec![],
                max_circuits: 16,
                max_streams_per_circuit: 4,
                max_bytes_per_minute: 1024,
            },
            started_at_ms: 1,
            heartbeat_at_ms,
            expires_at_ms,
            version: version.to_string(),
        },
        session_sk,
    )
    .map_err(Error::CoreError)
}

#[test]
fn test_default_exit_services_include_native_tcp_and_https() {
    assert_eq!(default_onion_exit_services(), vec![
        OnionExitService::tcp(),
        OnionExitService::https()
    ]);
    assert_eq!(https_onion_exit_services(), vec![OnionExitService::https()]);
}

#[test]
fn test_reserved_service_name_accepts_tcp_and_legacy_https_for_routes() {
    assert!(OnionExitService::https().matches_route_service("https"));
    assert!(OnionExitService::new("https", OnionExitTransport::Tcp)
        .expect("valid service")
        .matches_route_service("https"));
    assert!(OnionExitService::new("https", OnionExitTransport::Https)
        .expect("valid service")
        .matches_route_service("https"));
    assert!(OnionExitService::new("custom", OnionExitTransport::Tcp)
        .expect("valid service")
        .matches_route_service("custom"));
}

#[test]
fn test_legacy_https_descriptor_satisfies_tcp_proxy_transport_filter() -> Result<()> {
    let session = SessionSk::new_with_seckey(&SecretKey::random()).map_err(Error::CoreError)?;
    let descriptor = signed_exit_for_session_at(
        &session,
        OnionExitService::new("https", OnionExitTransport::Https)?,
        20,
        100,
        "legacy",
    )?;

    assert!(descriptor.offers_service("https"));
    assert!(descriptor.offers_service_transport("https", OnionExitTransport::Tcp));
    Ok(())
}

#[test]
fn test_onion_exit_service_name_is_validated_and_canonicalized() -> Result<()> {
    let service = OnionExitService::new("WeB-Api.1", OnionExitTransport::Tcp)?;

    assert_eq!(service.name.as_str(), "web-api.1");
    assert!(OnionExitService::new("", OnionExitTransport::Tcp).is_err());
    assert!(OnionExitService::new(" web", OnionExitTransport::Tcp).is_err());
    assert!(OnionExitService::new("web!", OnionExitTransport::Tcp).is_err());
    Ok(())
}

#[test]
fn test_default_exit_policy_is_closed() -> Result<()> {
    let policy = OnionExitPolicy::default();
    let target = OnionExitTarget::parse("example.com:443")?;

    assert!(policy.is_closed());
    assert!(!policy.allows_target(&target));
    assert!(matches!(
        policy.validate_targets(),
        Err(Error::InvalidConfig(message)) if message.contains("allowed target")
    ));
    Ok(())
}

#[test]
fn test_exit_policy_allow_list_controls_targets() -> Result<()> {
    let policy = OnionExitPolicy::from_target_strings(
        vec![
            "Example.COM.:443".to_string(),
            "API.example.com:443".to_string(),
        ],
        vec!["api.example.com:443".to_string()],
    )?;
    let example = OnionExitTarget::parse("example.com:443")?;
    let api = OnionExitTarget::parse("api.example.com:443")?;
    let other = OnionExitTarget::parse("other.example.com:443")?;

    assert!(!policy.is_closed());
    assert!(policy.allows_target(&example));
    assert!(!policy.allows_target(&api));
    assert!(!policy.allows_target(&other));
    Ok(())
}

#[test]
fn test_exit_policy_wildcard_allows_all_targets_with_specific_denies() -> Result<()> {
    let policy = OnionExitPolicy::from_target_strings(vec!["*:*".to_string()], vec![
        "api.example.com:443".to_string(),
    ])?;
    let google = OnionExitTarget::parse("google.com:443")?;
    let example = OnionExitTarget::parse("example.com:8443")?;
    let api = OnionExitTarget::parse("api.example.com:443")?;

    assert!(!policy.is_closed());
    assert!(policy.allows_target(&google));
    assert!(policy.allows_target(&example));
    assert!(!policy.allows_target(&api));
    Ok(())
}

#[test]
fn test_exit_policy_rejects_invalid_target_entries() {
    assert!(matches!(
        OnionExitPolicy::from_target_strings(vec!["example.com".to_string()], vec![]),
        Err(Error::InvalidConfig(message)) if message.contains("expected host:port")
    ));

    assert!(matches!(
        OnionExitPolicy::from_target_strings(
            vec!["example.com:443".to_string()],
            vec!["blocked.example.com".to_string()]
        ),
        Err(Error::InvalidConfig(message)) if message.contains("expected host:port")
    ));
}

#[test]
fn test_exit_descriptor_signature_covers_policy() -> Result<()> {
    let mut descriptor = signed_exit_at(20, 100)?;
    assert!(descriptor.verify_signature());

    descriptor.policy.max_circuits = 32;

    assert!(!descriptor.verify_signature());
    Ok(())
}

#[test]
fn test_exit_descriptor_signature_covers_schema_version() -> Result<()> {
    let mut descriptor = signed_exit_at(20, 100)?;
    assert_eq!(
        descriptor.schema_version,
        ONION_EXIT_DESCRIPTOR_SCHEMA_VERSION
    );
    assert!(descriptor.verify_signature());

    descriptor.schema_version = descriptor.schema_version.saturating_add(1);

    assert!(!descriptor.verify_signature());
    Ok(())
}

#[test]
fn test_exit_registry_decode_reports_rejected_schema_values() -> Result<()> {
    let valid = signed_exit_at(20, 100)?;
    let mut unsupported = signed_exit_at(21, 100)?;
    unsupported.schema_version = unsupported.schema_version.saturating_add(1);
    let data = vec![
        valid.encode().map_err(Error::CoreError)?,
        unsupported.encode().map_err(Error::CoreError)?,
    ];
    let entry = entry::Entry::new(
        entry::Entry::gen_did(ONION_EXITS_TOPIC)?,
        data,
        entry::EntryKind::Data,
    );

    let report = OnionExitRegistration::decode_descriptors_from_entry(&entry);

    assert_eq!(report.descriptors, vec![valid]);
    assert_eq!(report.rejected_values, 1);
    Ok(())
}

#[test]
fn test_latest_valid_by_service_did_filters_expired_and_keeps_newest() -> Result<()> {
    let key = SecretKey::random();
    let session_sk = SessionSk::new_with_seckey(&key).map_err(Error::CoreError)?;
    let did = session_sk.account_did();
    let public_key = session_sk
        .session()
        .account_verification_pubkey()
        .map_err(Error::CoreError)?;

    let older = OnionExitDescriptor::new_signed(
        OnionExitDescriptorBody {
            did,
            public_key: public_key.clone(),
            session_public_key: session_sk.session_public_key(),
            node_type: OnlineNodeType::Native,
            network_id: 1,
            service: service("web"),
            policy: OnionExitPolicy::default(),
            started_at_ms: 1,
            heartbeat_at_ms: 10,
            expires_at_ms: 100,
            version: "old".to_string(),
        },
        &session_sk,
    )
    .map_err(Error::CoreError)?;
    let newer = OnionExitDescriptor::new_signed(
        OnionExitDescriptorBody {
            did,
            public_key,
            session_public_key: session_sk.session_public_key(),
            node_type: OnlineNodeType::Native,
            network_id: 1,
            service: service("web"),
            policy: OnionExitPolicy::default(),
            started_at_ms: 1,
            heartbeat_at_ms: 20,
            expires_at_ms: 100,
            version: "new".to_string(),
        },
        &session_sk,
    )
    .map_err(Error::CoreError)?;
    let other_live = signed_exit_at(25, 100)?;
    let expired = signed_exit_at(30, 40)?;

    let descriptors = OnionExitDescriptor::latest_valid_by_service_did(
        vec![
            older.clone(),
            newer.clone(),
            other_live.clone(),
            expired.clone(),
        ],
        50,
        false,
    );

    assert_eq!(descriptors.len(), 2);
    assert!(descriptors.iter().any(|descriptor| descriptor == &newer));
    assert!(descriptors
        .iter()
        .any(|descriptor| descriptor == &other_live));

    let with_expired = OnionExitDescriptor::latest_valid_by_service_did(
        vec![older, newer, other_live, expired],
        50,
        true,
    );
    assert_eq!(with_expired.len(), 3);
    Ok(())
}

#[test]
fn test_latest_valid_by_service_did_preserves_same_did_distinct_services() -> Result<()> {
    let key = SecretKey::random();
    let session_sk = SessionSk::new_with_seckey(&key).map_err(Error::CoreError)?;
    let old_tcp =
        signed_exit_for_session_at(&session_sk, OnionExitService::tcp(), 10, 100, "tcp-old")?;
    let new_tcp =
        signed_exit_for_session_at(&session_sk, OnionExitService::tcp(), 20, 100, "tcp-new")?;
    let https =
        signed_exit_for_session_at(&session_sk, OnionExitService::https(), 15, 100, "https")?;
    let custom = signed_exit_for_session_at(
        &session_sk,
        OnionExitService::new("api", OnionExitTransport::Tcp)?,
        25,
        100,
        "api",
    )?;

    let descriptors = OnionExitDescriptor::latest_valid_by_service_did(
        vec![old_tcp, new_tcp.clone(), https.clone(), custom.clone()],
        50,
        false,
    );

    assert_eq!(descriptors.len(), 3);
    assert!(descriptors.iter().any(|descriptor| descriptor == &new_tcp));
    assert!(descriptors.iter().any(|descriptor| descriptor == &https));
    assert!(descriptors.iter().any(|descriptor| descriptor == &custom));
    Ok(())
}