#![allow(clippy::similar_names)]
use super::{Nip104Crypto, Nip104Error, Session};
use crate::Nip44;
use nostro2_traits::{NostrKeypair, SignerError, hex::Hexable as _};
use zeroize::Zeroize;
type Result<T> = std::result::Result<T, Nip104Error>;
pub const INVITE_EVENT_KIND: u32 = 30078;
pub const INVITE_RESPONSE_KIND: u32 = 1059;
#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
struct AcceptPayload {
#[bourne(rename = "sessionKey")]
session_key: String,
#[bourne(rename = "ownerPublicKey")]
#[bourne(skip_if_none)]
owner_public_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
struct InnerEvent {
pubkey: String,
content: String,
created_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InviteResponse {
pub invitee_identity: String,
pub invitee_session_pubkey: String,
pub owner_public_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invite {
pub inviter_ephemeral_pubkey: String,
pub shared_secret: String,
pub inviter: String,
pub inviter_ephemeral_privkey: Option<String>,
pub device_id: Option<String>,
}
impl Drop for Invite {
fn drop(&mut self) {
if let Some(sk) = self.inviter_ephemeral_privkey.as_mut() {
sk.zeroize();
}
self.shared_secret.zeroize();
}
}
impl Invite {
pub fn create_new<K: NostrKeypair>(inviter: &str, device_id: Option<&str>) -> Result<Self> {
let ephemeral = K::generate();
let mut secret = [0_u8; 32];
getrandom::fill(&mut secret)
.map_err(|e| Nip104Error::Signer(SignerError::Backend(format!("getrandom: {e}"))))?;
Ok(Self {
inviter_ephemeral_pubkey: ephemeral.public_key(),
shared_secret: secret.to_hex(),
inviter: inviter.to_owned(),
inviter_ephemeral_privkey: Some(ephemeral.secret_key()),
device_id: device_id.map(str::to_owned),
})
}
#[must_use]
pub fn to_url(&self, root: &str) -> String {
let json = format!(
r#"{{"inviter":"{}","ephemeralKey":"{}","sharedSecret":"{}"}}"#,
self.inviter, self.inviter_ephemeral_pubkey, self.shared_secret
);
format!("{root}#{}", Self::urlencode(&json))
}
pub fn from_url(url: &str) -> Result<Self> {
let hash = url
.split_once('#')
.map(|(_, h)| h)
.filter(|h| !h.is_empty())
.ok_or_else(|| Nip104Error::InvalidInvite("no invite data in URL hash".into()))?;
let decoded = Self::urldecode(hash);
let inviter = Self::json_str_field(&decoded, "inviter")
.ok_or_else(|| Nip104Error::InvalidInvite("missing inviter".into()))?;
let ephemeral = Self::json_str_field(&decoded, "ephemeralKey")
.or_else(|| Self::json_str_field(&decoded, "inviterEphemeralPublicKey"))
.ok_or_else(|| Nip104Error::InvalidInvite("missing ephemeralKey".into()))?;
let shared = Self::json_str_field(&decoded, "sharedSecret")
.ok_or_else(|| Nip104Error::InvalidInvite("missing sharedSecret".into()))?;
Ok(Self {
inviter_ephemeral_pubkey: ephemeral,
shared_secret: shared,
inviter,
inviter_ephemeral_privkey: None,
device_id: None,
})
}
pub fn to_event(&self, created_at: i64) -> Result<nostro2::NostrNote> {
let device_id = self
.device_id
.as_deref()
.ok_or_else(|| Nip104Error::InvalidInvite("device id required".into()))?;
let mut tags = nostro2::NostrTags::new();
tags.add_custom_tag("ephemeralKey", &self.inviter_ephemeral_pubkey);
tags.add_custom_tag("sharedSecret", &self.shared_secret);
tags.add_custom_tag("d", &format!("double-ratchet/invites/{device_id}"));
tags.add_custom_tag("l", "double-ratchet/invites");
Ok(nostro2::NostrNote {
kind: INVITE_EVENT_KIND,
pubkey: self.inviter.clone(),
content: String::new(),
created_at,
tags,
..Default::default()
})
}
pub fn from_event(event: &nostro2::NostrNote) -> Result<Self> {
use nostro2::NostrEvent as _;
if event.kind != INVITE_EVENT_KIND {
return Err(Nip104Error::InvalidInvite("wrong kind".into()));
}
if !event.verify() {
return Err(Nip104Error::InvalidInvite("bad signature".into()));
}
let ephemeral = Self::first_tag(event, "ephemeralKey")
.ok_or_else(|| Nip104Error::InvalidInvite("missing ephemeralKey".into()))?;
let shared = Self::first_tag(event, "sharedSecret")
.ok_or_else(|| Nip104Error::InvalidInvite("missing sharedSecret".into()))?;
let device_id = Self::first_tag(event, "d")
.and_then(|d| d.split('/').nth(2).map(str::to_owned))
.filter(|id| id != "public");
Ok(Self {
inviter_ephemeral_pubkey: ephemeral,
shared_secret: shared,
inviter: event.pubkey.clone(),
inviter_ephemeral_privkey: None,
device_id,
})
}
pub fn accept<K: NostrKeypair>(
&self,
invitee: &K,
owner_pubkey: Option<&str>,
created_at: i64,
) -> Result<(Session<K>, nostro2::NostrNote)> {
let shared_secret = K::decode_hex_32(&self.shared_secret)?;
let their_ephemeral = K::decode_hex_32(&self.inviter_ephemeral_pubkey)?;
let session_kp = K::generate();
let session = Session::<K>::new_initiator(
&their_ephemeral,
&session_kp.secret_bytes(),
&shared_secret,
)?;
let payload = AcceptPayload {
session_key: session_kp.public_key(),
owner_public_key: owner_pubkey.map(str::to_owned),
};
let payload_json = json_bourne::to_string(&payload)?;
let dh_encrypted = invitee
.nip_44_encrypt(&payload_json, &self.inviter)?
.into_owned();
let inner_content = K::encrypt_with_message_key(&shared_secret, dh_encrypted.as_bytes())?;
let inner_event = InnerEvent {
pubkey: invitee.public_key(),
content: inner_content,
created_at,
};
let inner_json = json_bourne::to_string(&inner_event)?;
let random_sender = K::generate();
let envelope_content = random_sender
.nip_44_encrypt(&inner_json, &self.inviter_ephemeral_pubkey)?
.into_owned();
let mut tags = nostro2::NostrTags::new();
tags.add_pubkey_tag(&self.inviter_ephemeral_pubkey, None);
let mut envelope = nostro2::NostrNote {
kind: INVITE_RESPONSE_KIND,
content: envelope_content,
created_at,
tags,
..Default::default()
};
envelope
.sign_with(&random_sender)
.map_err(|_| Nip104Error::Signer(SignerError::InvalidSignature))?;
Ok((session, envelope))
}
pub fn receive<K: NostrKeypair>(
&self,
event: &nostro2::NostrNote,
inviter_identity: &K,
) -> Result<(Session<K>, InviteResponse)> {
use nostro2::NostrEvent as _;
if event.kind != INVITE_RESPONSE_KIND {
return Err(Nip104Error::InvalidInvite("wrong kind".into()));
}
if !event.verify() {
return Err(Nip104Error::InvalidInvite("bad signature".into()));
}
let ephemeral_sk = self
.inviter_ephemeral_privkey
.as_deref()
.ok_or_else(|| Nip104Error::InvalidInvite("ephemeral secret unavailable".into()))?;
let ephemeral_kp = K::from_secret_bytes(&K::decode_hex_32(ephemeral_sk)?)?;
let shared_secret = K::decode_hex_32(&self.shared_secret)?;
let inner_json = ephemeral_kp.nip_44_decrypt(&event.content, &event.pubkey)?;
let inner_event: InnerEvent = json_bourne::parse_str(&inner_json)?;
let invitee_identity = inner_event.pubkey;
let dh_encrypted_bytes = K::decrypt_with_message_key(&shared_secret, &inner_event.content)?;
let dh_encrypted = String::from_utf8(dh_encrypted_bytes)
.map_err(|e| Nip104Error::Json(format!("inner utf8: {e}")))?;
let payload_json = inviter_identity.nip_44_decrypt(&dh_encrypted, &invitee_identity)?;
let payload: AcceptPayload = json_bourne::parse_str(&payload_json)?;
let their_session = K::decode_hex_32(&payload.session_key)?;
let session = Session::<K>::new_responder(
&their_session,
&ephemeral_kp.secret_bytes(),
&shared_secret,
)?;
Ok((
session,
InviteResponse {
invitee_identity,
invitee_session_pubkey: payload.session_key,
owner_public_key: payload.owner_public_key,
},
))
}
}
impl Invite {
fn first_tag(event: &nostro2::NostrNote, name: &str) -> Option<String> {
event
.tags
.iter()
.find(|row| row.first().is_some_and(|t| t == name))
.and_then(|row| row.get(1).cloned())
}
fn json_str_field(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)? + needle.len();
let rest = &json[start..];
let colon = rest.find(':')?;
let after = &rest[colon + 1..];
let q1 = after.find('"')? + 1;
let q2 = after[q1..].find('"')?;
Some(after[q1..q1 + q2].to_owned())
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
other => {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
out.push(HEX[(other >> 4) as usize] as char);
out.push(HEX[(other & 0xf) as usize] as char);
}
}
}
out
}
fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char)
.to_digit(16)
.and_then(|d| u8::try_from(d).ok());
let lo = (bytes[i + 2] as char)
.to_digit(16)
.and_then(|d| u8::try_from(d).ok());
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostro2_traits::NostrSigner as _;
type K = crate::tests::NipTester;
fn ident(seed: u8) -> K {
K::from_secret_bytes(&[seed; 32]).unwrap()
}
#[test]
fn full_invite_handshake_bootstraps_a_session() {
let inviter_id = ident(0xA1);
let invitee_id = ident(0xB2);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev1")).unwrap();
assert!(invite.inviter_ephemeral_privkey.is_some());
let (mut invitee_session, response) = invite
.accept::<K>(&invitee_id, None, 1_700_000_000)
.unwrap();
assert_eq!(response.kind, INVITE_RESPONSE_KIND);
let (mut inviter_session, recovered) = invite.receive::<K>(&response, &inviter_id).unwrap();
assert_eq!(recovered.invitee_identity, invitee_id.public_key());
let (s1, env) = invitee_session.plan_send(b"hello inviter").unwrap();
invitee_session.apply(s1);
let (s2, pt) = inviter_session.plan_receive(&env).unwrap();
inviter_session.apply(s2);
assert_eq!(pt, b"hello inviter");
let (s3, env2) = inviter_session.plan_send(b"hello invitee").unwrap();
inviter_session.apply(s3);
let (s4, pt2) = invitee_session.plan_receive(&env2).unwrap();
invitee_session.apply(s4);
assert_eq!(pt2, b"hello invitee");
}
#[test]
fn owner_pubkey_round_trips() {
let inviter_id = ident(0x11);
let invitee_id = ident(0x22);
let owner = ident(0x33).public_key();
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let (_s, response) = invite
.accept::<K>(&invitee_id, Some(&owner), 1_700_000_000)
.unwrap();
let (_session, recovered) = invite.receive::<K>(&response, &inviter_id).unwrap();
assert_eq!(recovered.owner_public_key.as_deref(), Some(owner.as_str()));
}
#[test]
fn wrong_identity_key_cannot_receive() {
let inviter_id = ident(0x44);
let invitee_id = ident(0x55);
let impostor = ident(0x66);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let (_s, response) = invite
.accept::<K>(&invitee_id, None, 1_700_000_000)
.unwrap();
assert!(invite.receive::<K>(&response, &impostor).is_err());
}
#[test]
fn invite_event_round_trips() {
let inviter_id = ident(0x77);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev9")).unwrap();
let mut event = invite.to_event(1_700_000_000).unwrap();
event.sign_with(&inviter_id).unwrap();
let parsed = Invite::from_event(&event).unwrap();
assert_eq!(
parsed.inviter_ephemeral_pubkey,
invite.inviter_ephemeral_pubkey
);
assert_eq!(parsed.shared_secret, invite.shared_secret);
assert_eq!(parsed.inviter, inviter_id.public_key());
assert_eq!(parsed.device_id.as_deref(), Some("dev9"));
}
#[test]
fn url_round_trips() {
let inviter_id = ident(0x88);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let url = invite.to_url("https://chat.iris.to");
let parsed = Invite::from_url(&url).unwrap();
assert_eq!(parsed.inviter, invite.inviter);
assert_eq!(
parsed.inviter_ephemeral_pubkey,
invite.inviter_ephemeral_pubkey
);
assert_eq!(parsed.shared_secret, invite.shared_secret);
}
#[test]
fn tampered_envelope_rejected() {
let inviter_id = ident(0x99);
let invitee_id = ident(0xAA);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let (_s, mut response) = invite
.accept::<K>(&invitee_id, None, 1_700_000_000)
.unwrap();
response.content.push('A'); assert!(invite.receive::<K>(&response, &inviter_id).is_err());
}
#[test]
fn wrong_shared_secret_cannot_accept() {
let inviter_id = ident(0x12);
let invitee_id = ident(0x34);
let real = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let mut forged = real.clone();
forged.shared_secret = [0xEE_u8; 32].to_hex();
let (_s, response) = forged
.accept::<K>(&invitee_id, None, 1_700_000_000)
.unwrap();
assert!(real.receive::<K>(&response, &inviter_id).is_err());
}
#[test]
fn receive_wrong_kind_rejected() {
let inviter_id = ident(0x56);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let mut note = nostro2::NostrNote {
kind: INVITE_RESPONSE_KIND + 1,
content: "x".into(),
..Default::default()
};
note.sign_with(&inviter_id).unwrap();
assert!(matches!(
invite.receive::<K>(¬e, &inviter_id),
Err(Nip104Error::InvalidInvite(_))
));
}
#[test]
fn receive_without_ephemeral_secret_rejected() {
let inviter_id = ident(0x78);
let invitee_id = ident(0x9A);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev")).unwrap();
let (_s, response) = invite
.accept::<K>(&invitee_id, None, 1_700_000_000)
.unwrap();
let mut ev = invite.to_event(1_700_000_000).unwrap();
ev.sign_with(&inviter_id).unwrap();
let public_copy = Invite::from_event(&ev).unwrap();
assert!(public_copy.inviter_ephemeral_privkey.is_none());
assert!(matches!(
public_copy.receive::<K>(&response, &inviter_id),
Err(Nip104Error::InvalidInvite(_))
));
}
#[test]
fn malformed_urls_rejected() {
assert!(Invite::from_url("https://chat.iris.to").is_err());
assert!(Invite::from_url("https://chat.iris.to#").is_err());
assert!(Invite::from_url("not-a-url").is_err());
assert!(Invite::from_url("https://x#%7B%22inviter%22%3A%22ab%22%7D").is_err());
}
#[test]
fn urldecode_does_not_panic_on_non_ascii_after_percent() {
assert!(Invite::from_url("https://x#%a\u{e9}").is_err());
assert!(Invite::from_url("https://x#%\u{20ac}").is_err());
assert!(Invite::from_url("https://x#abc%").is_err());
assert!(Invite::from_url("https://x#abc%a").is_err());
}
#[test]
fn from_event_validates() {
let inviter_id = ident(0xBC);
let mut wrong = nostro2::NostrNote {
kind: INVITE_EVENT_KIND + 1,
pubkey: inviter_id.public_key(),
..Default::default()
};
wrong.sign_with(&inviter_id).unwrap();
assert!(Invite::from_event(&wrong).is_err());
let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("d")).unwrap();
let unsigned = invite.to_event(1_700_000_000).unwrap();
assert!(Invite::from_event(&unsigned).is_err());
}
#[test]
fn two_invitees_one_invite_are_independent() {
let inviter_id = ident(0xC1);
let alice = ident(0xC2);
let bob = ident(0xC3);
let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
let (_sa, ra) = invite.accept::<K>(&alice, None, 1_700_000_000).unwrap();
let (_sb, rb) = invite.accept::<K>(&bob, None, 1_700_000_000).unwrap();
let (_s1, rec_a) = invite.receive::<K>(&ra, &inviter_id).unwrap();
let (_s2, rec_b) = invite.receive::<K>(&rb, &inviter_id).unwrap();
assert_eq!(rec_a.invitee_identity, alice.public_key());
assert_eq!(rec_b.invitee_identity, bob.public_key());
assert_ne!(rec_a.invitee_session_pubkey, rec_b.invitee_session_pubkey);
}
}