iris-chat-protocol 0.3.18

Reusable Iris Chat double-ratchet protocol engine
Documentation
use super::*;

pub(super) fn protocol_effects_from_prepared(
    prepared: &PreparedSend,
    engine: &ProtocolEngine,
    inner_event_id: Option<String>,
    chat_id: String,
    event_ids: &mut Vec<String>,
) -> anyhow::Result<Vec<ProtocolEffect>> {
    let authored_at_secs = protocol_payload_authored_at(&prepared.payload);
    let mut publishes = Vec::new();
    for response in &prepared.invite_responses {
        let event = engine.cached_invite_response_with_owner_proof(response)?;
        publishes.push(ProtocolPublish {
            authored_at_secs,
            event,
            chat_id: chat_id.clone(),
            inner_event_id: None,
            carrier: ProtocolCarrier::Unmarked,
        });
    }
    for delivery in &prepared.deliveries {
        let event = message_event_for_delivery(delivery)?;
        event_ids.push(event.id.to_string());
        let publish = ProtocolPublish {
            authored_at_secs,
            event,
            chat_id: chat_id.clone(),
            inner_event_id: inner_event_id.clone(),
            carrier: pairwise_carrier(delivery)?,
        };
        publishes.push(publish);
    }
    Ok(publishes.into_iter().map(ProtocolEffect::Publish).collect())
}

pub(super) fn protocol_effects_from_group_prepared_publish(
    prepared: &GroupPreparedPublish,
    engine: &ProtocolEngine,
    inner_event_id: Option<String>,
    chat_id: String,
    event_ids: &mut Vec<String>,
) -> anyhow::Result<Vec<ProtocolEffect>> {
    let mut publishes = Vec::new();
    for response in &prepared.invite_responses {
        let event = engine.cached_invite_response_with_owner_proof(response)?;
        publishes.push(ProtocolPublish {
            authored_at_secs: None,
            event,
            chat_id: chat_id.clone(),
            inner_event_id: None,
            carrier: ProtocolCarrier::Unmarked,
        });
    }
    for delivery in &prepared.deliveries {
        let event = message_event_for_delivery(delivery)?;
        event_ids.push(event.id.to_string());
        let publish = ProtocolPublish {
            authored_at_secs: None,
            event,
            chat_id: chat_id.clone(),
            inner_event_id: inner_event_id.clone(),
            carrier: pairwise_carrier(delivery)?,
        };
        publishes.push(publish);
    }
    for sender_key_message in &prepared.sender_key_messages {
        let event = group_sender_key_message_event(sender_key_message)?;
        event_ids.push(event.id.to_string());
        publishes.push(ProtocolPublish {
            authored_at_secs: None,
            event,
            chat_id: chat_id.clone(),
            inner_event_id: inner_event_id.clone(),
            carrier: ProtocolCarrier::SharedGroup,
        });
    }
    Ok(publishes.into_iter().map(ProtocolEffect::Publish).collect())
}

pub(super) fn message_event_for_delivery(delivery: &Delivery) -> anyhow::Result<Event> {
    // The wire names neither end: the signer is the ratchet session key and no
    // tag names the recipient device. The target travels beside the event.
    #[cfg(test)]
    if tests::legacy_recipient_tags() {
        return tests::legacy_recipient_message_event(&delivery.envelope, delivery.device_pubkey);
    }
    Ok(nostr_double_ratchet::message_event(&delivery.envelope)?)
}

pub(super) fn pairwise_carrier(delivery: &Delivery) -> anyhow::Result<ProtocolCarrier> {
    Ok(ProtocolCarrier::Pairwise {
        device: public_device(delivery.device_pubkey)?,
    })
}

/// Resolve the single owner claim carried by an invite and optional caller
/// hint. Duplicated compatibility fields must agree; the hint may supply a
/// missing claim but may never override one already present in the invite.
pub fn resolve_invite_owner(
    invite: &Invite,
    owner_pubkey_hint: Option<PublicKey>,
) -> anyhow::Result<PublicKey> {
    let inviter_device = public_device(invite.inviter_device_pubkey)?;
    if invite.inviter != inviter_device {
        anyhow::bail!("invite author does not match inviter device");
    }

    let owner_alias = invite.owner_public_key;
    let owner_protocol = invite.inviter_owner_pubkey.map(public_owner).transpose()?;
    if owner_alias
        .zip(owner_protocol)
        .is_some_and(|(alias, protocol)| alias != protocol)
    {
        anyhow::bail!("invite owner fields disagree");
    }
    let embedded_owner = owner_alias.or(owner_protocol);
    if owner_pubkey_hint
        .zip(embedded_owner)
        .is_some_and(|(hint, embedded)| hint != embedded)
    {
        anyhow::bail!("invite owner hint disagrees with invite payload");
    }

    Ok(owner_pubkey_hint
        .or(embedded_owner)
        .unwrap_or(inviter_device))
}

pub(super) fn app_keys_device_pubkeys(app_keys: &AppKeys) -> BTreeSet<PublicKey> {
    app_keys
        .get_all_devices()
        .into_iter()
        .map(|device| device.identity_pubkey)
        .collect()
}

pub(super) fn classify_group_pairwise_payload(payload: &[u8]) -> anyhow::Result<(bool, bool)> {
    let codec = JsonGroupPayloadCodecV1;
    let Some(command) = codec.decode_pairwise_command(payload)? else {
        return Ok((false, false));
    };
    let supported = match command {
        GroupPairwiseCommand::MetadataSnapshot { snapshot } => snapshot.protocol.is_sender_key_v1(),
        GroupPairwiseCommand::SenderKeyDistribution { .. }
        | GroupPairwiseCommand::SenderKeyRepairRequest { .. } => true,
        _ => false,
    };
    Ok((true, supported))
}

fn pending_retry_delay_secs(created_at_secs: u64, now: NdrUnixSeconds) -> u64 {
    let age_secs = now.get().saturating_sub(created_at_secs);
    match age_secs {
        0..=29 => PENDING_RETRY_DELAY_SECS,
        30..=119 => 15,
        _ => 60,
    }
}

pub(super) fn next_pending_retry_at_secs(created_at_secs: u64, now: NdrUnixSeconds) -> u64 {
    now.get()
        .saturating_add(pending_retry_delay_secs(created_at_secs, now))
}

pub(super) fn collect_expected_sender_pubkeys(
    session: &SessionState,
    out: &mut HashSet<PublicKey>,
) {
    if let Some(current) = session.their_current_nostr_public_key {
        if let Ok(pubkey) = public_device(current) {
            out.insert(pubkey);
        }
    }
    if let Some(next) = session.their_next_nostr_public_key {
        if let Ok(pubkey) = public_device(next) {
            out.insert(pubkey);
        }
    }
    for device in session.skipped_keys.keys() {
        if let Ok(pubkey) = public_device(*device) {
            out.insert(pubkey);
        }
    }
}

pub(super) fn session_state_matches_sender(
    session: &SessionState,
    sender: NdrDevicePubkey,
) -> bool {
    session.their_current_nostr_public_key == Some(sender)
        || session.their_next_nostr_public_key == Some(sender)
        || session.skipped_keys.contains_key(&sender)
}

pub(super) fn sender_resolution_owner_matches(
    resolution: ProtocolSenderOwnerResolution,
    owner: NdrOwnerPubkey,
) -> bool {
    match resolution {
        ProtocolSenderOwnerResolution::Verified {
            owner: resolved_owner,
        }
        | ProtocolSenderOwnerResolution::ProvisionalDeviceOwner {
            owner: resolved_owner,
        } => resolved_owner == owner,
        ProtocolSenderOwnerResolution::PendingOwnerClaim { claimed_owner, .. } => {
            claimed_owner == owner
        }
    }
}

pub(super) fn pending_inbound_owner_hexes_from_resolution(
    resolution: ProtocolSenderOwnerResolution,
) -> (Option<String>, Option<String>) {
    match resolution {
        ProtocolSenderOwnerResolution::Verified { owner }
        | ProtocolSenderOwnerResolution::ProvisionalDeviceOwner { owner } => {
            (Some(owner.to_hex()), None)
        }
        ProtocolSenderOwnerResolution::PendingOwnerClaim {
            storage_owner,
            claimed_owner,
            ..
        } => (Some(storage_owner.to_hex()), Some(claimed_owner.to_hex())),
    }
}

pub(super) fn pending_inbound_sender_pubkey(
    pending: &ProtocolPendingInbound,
) -> Option<NdrDevicePubkey> {
    if let Some(envelope) = pending.envelope.as_ref() {
        return Some(envelope.sender);
    }
    pending
        .sender_message_pubkey_hex
        .as_deref()
        .and_then(|pubkey_hex| PublicKey::parse(pubkey_hex).ok())
        .map(ndr_device)
}

pub(super) fn apply_pending_inbound_metadata(
    pending: &mut ProtocolPendingInbound,
    metadata: ProtocolPendingInboundMetadata,
) -> bool {
    let mut changed = false;
    if pending.event_id.is_empty() && !metadata.event_id.is_empty() {
        pending.event_id = metadata.event_id;
        changed = true;
    }
    if pending.envelope.is_none() && metadata.envelope.is_some() {
        pending.envelope = metadata.envelope;
        changed = true;
    }
    if pending.sender_message_pubkey_hex.is_none() && metadata.sender_message_pubkey_hex.is_some() {
        pending.sender_message_pubkey_hex = metadata.sender_message_pubkey_hex;
        changed = true;
    }
    if pending.resolved_owner_pubkey_hex.is_none() && metadata.resolved_owner_pubkey_hex.is_some() {
        pending.resolved_owner_pubkey_hex = metadata.resolved_owner_pubkey_hex;
        changed = true;
    }
    if pending.claimed_owner_pubkey_hex.is_none() && metadata.claimed_owner_pubkey_hex.is_some() {
        pending.claimed_owner_pubkey_hex = metadata.claimed_owner_pubkey_hex;
        changed = true;
    }
    if metadata.metadata_verified && !pending.metadata_verified {
        pending.metadata_verified = true;
        changed = true;
    }
    changed
}

pub(super) fn provisional_owner_from_sender_pubkey(sender: NdrDevicePubkey) -> NdrOwnerPubkey {
    NdrOwnerPubkey::from_bytes(sender.to_bytes())
}

pub(super) fn local_sibling_payload(
    conversation_owner: PublicKey,
    payload: &[u8],
) -> anyhow::Result<Vec<u8>> {
    local_sibling_payload_with_original_sender(conversation_owner, None, payload)
}

pub(super) fn local_sibling_payload_with_original_sender(
    conversation_owner: PublicKey,
    original_sender_device: Option<PublicKey>,
    payload: &[u8],
) -> anyhow::Result<Vec<u8>> {
    use base64::Engine;
    let wrapper = LocalSiblingPayload {
        protocol: LOCAL_SIBLING_PROTOCOL.to_string(),
        version: 1,
        conversation_owner: conversation_owner.to_hex(),
        original_sender_device: original_sender_device.map(|pubkey| pubkey.to_hex()),
        payload: base64::engine::general_purpose::STANDARD.encode(payload),
    };
    Ok(serde_json::to_vec(&wrapper)?)
}

pub(super) fn decode_local_sibling_payload(
    payload: &[u8],
) -> Option<(PublicKey, Option<PublicKey>, Vec<u8>)> {
    use base64::Engine;
    let wrapper: LocalSiblingPayload = serde_json::from_slice(payload).ok()?;
    if wrapper.protocol != LOCAL_SIBLING_PROTOCOL || wrapper.version != 1 {
        return None;
    }
    let owner = PublicKey::parse(&wrapper.conversation_owner).ok()?;
    let original_sender_device = match wrapper.original_sender_device {
        Some(device) => Some(PublicKey::parse(&device).ok()?),
        None => None,
    };
    let payload = base64::engine::general_purpose::STANDARD
        .decode(wrapper.payload)
        .ok()?;
    Some((owner, original_sender_device, payload))
}

#[derive(Debug, Serialize, Deserialize)]
struct LocalSiblingPayload {
    protocol: String,
    version: u32,
    conversation_owner: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    original_sender_device: Option<String>,
    payload: String,
}

pub(super) fn ndr_owner(pubkey: PublicKey) -> NdrOwnerPubkey {
    NdrOwnerPubkey::from_bytes(pubkey.to_bytes())
}

pub(super) fn ndr_device(pubkey: PublicKey) -> NdrDevicePubkey {
    NdrDevicePubkey::from_bytes(pubkey.to_bytes())
}

pub(super) fn protocol_event_has_tag(event: &Event, name: &str) -> bool {
    event
        .tags
        .iter()
        .any(|tag| tag.as_slice().first().map(|value| value.as_str()) == Some(name))
}

pub(super) fn public_owner(pubkey: NdrOwnerPubkey) -> anyhow::Result<PublicKey> {
    Ok(PublicKey::from_slice(&pubkey.to_bytes())?)
}

pub(super) fn public_device(pubkey: NdrDevicePubkey) -> anyhow::Result<PublicKey> {
    Ok(PublicKey::from_slice(&pubkey.to_bytes())?)
}

pub(super) fn current_unix_millis() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
        .try_into()
        .unwrap_or(u64::MAX)
}

fn protocol_payload_authored_at(payload: &[u8]) -> Option<u64> {
    let decoded = decode_local_sibling_payload(payload);
    let payload = decoded
        .as_ref()
        .map_or(payload, |(_, _, inner)| inner.as_slice());
    serde_json::from_slice::<UnsignedEvent>(payload)
        .ok()
        .map(|event| event.created_at.as_secs())
}