use std::time::Duration;
use nostr_sdk::prelude::*;
use crate::error::{Error, Result};
use crate::models::mediation::TranscriptParty;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundEnvelope {
pub party: TranscriptParty,
pub shared_pubkey: String,
pub inner_event_id: String,
pub inner_created_at: i64,
pub outer_event_id: String,
pub content: String,
pub inner_sender: String,
}
#[derive(Debug, Clone)]
pub struct UnwrappedInner {
pub event_id: EventId,
pub content: String,
pub created_at: i64,
pub sender: PublicKey,
}
#[derive(Debug, Clone)]
pub struct PartyChatMaterial<'a> {
pub party: TranscriptParty,
pub shared_keys: &'a Keys,
pub expected_author: PublicKey,
}
pub fn unwrap_with_shared_key(shared_keys: &Keys, event: &Event) -> Result<UnwrappedInner> {
let decrypted = nip44::decrypt(shared_keys.secret_key(), &event.pubkey, &event.content)
.map_err(|e| Error::ChatTransport(format!("NIP-44 decrypt failed: {e}")))?;
let inner = Event::from_json(&decrypted)
.map_err(|e| Error::ChatTransport(format!("invalid inner chat event JSON: {e}")))?;
if inner.kind != Kind::TextNote {
return Err(Error::ChatTransport(format!(
"inner chat event must be kind TextNote, got {}",
inner.kind.as_u16()
)));
}
inner
.verify()
.map_err(|e| Error::ChatTransport(format!("inner chat event signature invalid: {e}")))?;
Ok(UnwrappedInner {
event_id: inner.id,
created_at: inner.created_at.as_secs() as i64,
sender: inner.pubkey,
content: inner.content,
})
}
pub async fn fetch_inbound(
client: &Client,
parties: &[PartyChatMaterial<'_>],
fetch_timeout: Duration,
) -> Result<Vec<InboundEnvelope>> {
let now = Timestamp::now();
let since_window = Timestamp::from_secs(now.as_secs().saturating_sub(7 * 24 * 60 * 60));
let mut out: Vec<InboundEnvelope> = Vec::new();
for party in parties {
let shared_pubkey = party.shared_keys.public_key();
let filter = Filter::new()
.kind(Kind::GiftWrap)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
shared_pubkey.to_hex(),
)
.since(since_window);
let events = client
.fetch_events(filter, fetch_timeout)
.await
.map_err(|e| {
Error::ChatTransport(format!(
"fetch_events failed for party shared pubkey {}: {}",
shared_pubkey.to_hex(),
e
))
})?;
tracing::trace!(
party = %party.party,
count = events.len(),
"inbound fetch: candidates for shared pubkey"
);
for wrapped in events.iter() {
match unwrap_with_shared_key(party.shared_keys, wrapped) {
Ok(inner) => {
if inner.sender != party.expected_author {
tracing::warn!(
party = %party.party,
outer_event_id = %wrapped.id.to_hex(),
expected_author = %party.expected_author.to_hex(),
actual_author = %inner.sender.to_hex(),
"dropping inbound gift-wrap: inner signer does not match expected party trade pubkey"
);
continue;
}
out.push(InboundEnvelope {
party: party.party,
shared_pubkey: shared_pubkey.to_hex(),
inner_event_id: inner.event_id.to_hex(),
inner_created_at: inner.created_at,
outer_event_id: wrapped.id.to_hex(),
content: inner.content,
inner_sender: inner.sender.to_hex(),
});
}
Err(e) => {
tracing::warn!(
party = %party.party,
outer_event_id = %wrapped.id.to_hex(),
error = %e,
"dropping inbound gift-wrap that failed decrypt / verify"
);
}
}
}
}
out.sort_by_key(|e| e.inner_created_at);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::outbound::build_wrap;
use crate::chat::shared_key::derive_shared_keys;
#[tokio::test]
async fn unwrap_returns_inner_content_and_signer_not_outer_metadata() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let shared = derive_shared_keys(&serbero, &buyer.public_key()).unwrap();
let built = build_wrap(&serbero, &shared.public_key(), "buyer, please confirm")
.await
.unwrap();
let inner = unwrap_with_shared_key(&shared, &built.outer).unwrap();
assert_eq!(inner.content, "buyer, please confirm");
assert_eq!(inner.created_at, built.inner_created_at);
assert_eq!(inner.event_id, built.inner_event_id);
assert_eq!(
inner.sender,
serbero.public_key(),
"inner signer must be the sender's keys, not the ephemeral outer signer"
);
assert_ne!(
inner.sender, built.outer.pubkey,
"the outer signer is the NIP-59 ephemeral key and must not be reported as the inner sender"
);
}
#[tokio::test]
async fn unwrap_rejects_tampered_inner_ciphertext() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let shared = derive_shared_keys(&serbero, &buyer.public_key()).unwrap();
let built = build_wrap(&serbero, &shared.public_key(), "original message")
.await
.unwrap();
let mut bytes = built.outer.content.as_bytes().to_vec();
let mid = bytes.len() / 2;
bytes[mid] ^= 0x01;
let corrupted_content =
String::from_utf8(bytes).expect("bit flip in base64 must stay valid UTF-8");
let ephem = Keys::generate();
let tampered = EventBuilder::new(Kind::GiftWrap, corrupted_content)
.tag(Tag::public_key(shared.public_key()))
.custom_created_at(built.outer.created_at)
.sign_with_keys(&ephem)
.unwrap();
let err = unwrap_with_shared_key(&shared, &tampered)
.expect_err("tampered ciphertext must not produce a verified inner event");
let msg = err.to_string();
assert!(
msg.contains("NIP-44 decrypt failed")
|| msg.contains("invalid inner chat event JSON")
|| msg.contains("signature invalid"),
"error should name the verification stage that failed: {msg}"
);
}
#[tokio::test]
async fn unwrap_rejects_non_text_note_inner_kinds() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let shared = derive_shared_keys(&serbero, &buyer.public_key()).unwrap();
let inner = EventBuilder::new(Kind::Custom(40), "{\"name\":\"evil\"}")
.build(serbero.public_key())
.sign(&serbero)
.await
.unwrap();
let ephem = Keys::generate();
let encrypted = nip44::encrypt(
ephem.secret_key(),
&shared.public_key(),
inner.as_json(),
nip44::Version::V2,
)
.unwrap();
let wrap = EventBuilder::new(Kind::GiftWrap, encrypted)
.tag(Tag::public_key(shared.public_key()))
.custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&ephem)
.unwrap();
let err = unwrap_with_shared_key(&shared, &wrap)
.expect_err("non-TextNote inner must be rejected");
assert!(
err.to_string().contains("must be kind TextNote"),
"error should flag the kind guard: {err}"
);
}
#[tokio::test]
async fn unwrap_rejects_inner_resigned_by_wrong_key() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let attacker = Keys::generate();
let shared = derive_shared_keys(&serbero, &buyer.public_key()).unwrap();
let inner = EventBuilder::text_note("forged content")
.build(serbero.public_key())
.sign(&attacker)
.await
.unwrap();
let ephem = Keys::generate();
let encrypted = nip44::encrypt(
ephem.secret_key(),
&shared.public_key(),
inner.as_json(),
nip44::Version::V2,
)
.unwrap();
let forged = EventBuilder::new(Kind::GiftWrap, encrypted)
.tag(Tag::public_key(shared.public_key()))
.custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&ephem)
.unwrap();
let err = unwrap_with_shared_key(&shared, &forged)
.expect_err("inner event signed by a different key must be rejected");
assert!(
err.to_string().contains("signature invalid"),
"expected signature-invalid error, got {err}"
);
}
}