use std::collections::BTreeMap; use anyhow::Context; use crate::domain::{ AuditEvent, DeviceId, FolderId, FolderRoster, ManifestMerkleNode, canonical_json_bytes, }; #[cfg(test)] use crate::domain::{Manifest, manifest_root_hash}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use iroh::{EndpointAddr, EndpointId, SecretKey, Signature}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; /// Version of the signed invitation format and invitation validation rules. pub const INVITATION_PROTOCOL_VERSION: u16 = 5; pub const INVITE_SCHEME: &str = "appa://"; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ManifestSummary { pub folder_id: FolderId, pub root_hash: String, } impl ManifestSummary { #[cfg(test)] pub fn from_manifest(manifest: &Manifest) -> anyhow::Result { Ok(Self { folder_id: manifest.folder_id, root_hash: manifest_root_hash(manifest)?, }) } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ManifestRequestKind { Summary, MerkleNode { prefix: String, }, AuditEvents { after_sequences: BTreeMap, }, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Invite { pub protocol_version: u16, pub folder_id: FolderId, pub folder_name: String, pub inviter_device_id: DeviceId, pub inviter_endpoint: EndpointAddr, pub capability: String, pub roster: FolderRoster, pub expires_at: OffsetDateTime, #[serde(default)] pub signature: Option, } impl Invite { pub fn sign(mut self, identity: &SecretKey) -> anyhow::Result { self.signature = Some(identity.sign(&self.signing_bytes()?)); Ok(self) } fn verify_signature(&self) -> anyhow::Result<()> { let signer = self.inviter_device_id.parse::()?; let signature = self .signature .as_ref() .ok_or_else(|| anyhow::anyhow!("invitation is missing its signature"))?; signer .verify(&self.signing_bytes()?, signature) .context("invitation signature is invalid") } fn signing_bytes(&self) -> anyhow::Result> { canonical_json_bytes(&UnsignedInvite::from(self)) } } #[derive(Serialize)] struct UnsignedInvite<'a> { protocol_version: u16, folder_id: FolderId, folder_name: &'a str, inviter_device_id: &'a str, inviter_endpoint: &'a EndpointAddr, capability: &'a str, roster: &'a FolderRoster, expires_at: OffsetDateTime, signature: Option, } impl<'a> From<&'a Invite> for UnsignedInvite<'a> { fn from(invite: &'a Invite) -> Self { Self { protocol_version: invite.protocol_version, folder_id: invite.folder_id, folder_name: &invite.folder_name, inviter_device_id: &invite.inviter_device_id, inviter_endpoint: &invite.inviter_endpoint, capability: &invite.capability, roster: &invite.roster, expires_at: invite.expires_at, signature: None, } } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ControlMessage { Request { folder_id: FolderId, capability: String, invite: Option>, requester_endpoint: EndpointAddr, request: ManifestRequestKind, }, Summary { summary: ManifestSummary, roster: FolderRoster, peer_endpoints: Vec, capability: String, }, MerkleNode { node: ManifestMerkleNode, }, AuditEvents { events: Vec, }, Announcement { folder_id: FolderId, capability: String, root_hash: String, sender_endpoint: EndpointAddr, }, AnnouncementAccepted, } pub fn encode_invite(invite: &Invite) -> anyhow::Result { let payload = serde_json::to_vec(invite)?; Ok(format!( "{INVITE_SCHEME}{}", URL_SAFE_NO_PAD.encode(payload) )) } pub fn decode_invite(ticket: &str) -> anyhow::Result { decode_invite_at(ticket, OffsetDateTime::now_utc()) } pub fn decode_invite_at(ticket: &str, now: OffsetDateTime) -> anyhow::Result { let encoded_payload = ticket .strip_prefix(INVITE_SCHEME) .ok_or_else(|| anyhow::anyhow!("Appa invitation must start with appa://"))?; let payload = URL_SAFE_NO_PAD.decode(encoded_payload)?; let invite: Invite = serde_json::from_slice(&payload)?; validate_invite(&invite, now)?; Ok(invite) } pub fn validate_invite(invite: &Invite, now: OffsetDateTime) -> anyhow::Result<()> { if invite.protocol_version != INVITATION_PROTOCOL_VERSION { anyhow::bail!( "invitation uses unsupported Appa protocol version {} (expected {})", invite.protocol_version, INVITATION_PROTOCOL_VERSION ); } if invite.expires_at < now { anyhow::bail!("invitation has expired"); } if invite.roster.folder_id != invite.folder_id { anyhow::bail!("invitation roster belongs to a different folder"); } if invite.roster.owner_device_id != invite.inviter_device_id { anyhow::bail!("invitation must be issued by the folder owner"); } invite.roster.validate()?; invite.verify_signature()?; Ok(()) } #[cfg(test)] mod tests { use crate::domain::{FolderRoster, MemberRole, RosterMember}; use time::Duration; use uuid::Uuid; use super::{ INVITATION_PROTOCOL_VERSION, Invite, ManifestSummary, decode_invite, decode_invite_at, encode_invite, }; #[test] fn rejects_an_invitation_changed_after_signing() -> anyhow::Result<()> { let identity = iroh::SecretKey::generate(); let endpoint = iroh::EndpointAddr::new(identity.public()); let roster = FolderRoster::create( Uuid::new_v4(), "secret".to_owned(), RosterMember { device_id: endpoint.id.to_string(), display_name: None, role: MemberRole::Owner, }, )? .sign(&identity)?; let invite = Invite { protocol_version: INVITATION_PROTOCOL_VERSION, folder_id: roster.folder_id, folder_name: "notes".to_owned(), inviter_device_id: endpoint.id.to_string(), inviter_endpoint: endpoint, capability: "secret".to_owned(), roster, expires_at: time::OffsetDateTime::now_utc() + Duration::hours(1), signature: None, }; let mut invite = invite.sign(&identity)?; let mut legacy_unsigned_invite = invite.clone(); legacy_unsigned_invite.signature = None; assert_eq!( invite.signing_bytes()?, serde_json::to_vec(&legacy_unsigned_invite)? ); assert!(decode_invite(&encode_invite(&invite)?).is_ok()); invite.folder_name = "altered".to_owned(); assert!(decode_invite(&encode_invite(&invite)?).is_err()); Ok(()) } #[test] fn validates_an_invitation_against_the_supplied_time() -> anyhow::Result<()> { let identity = iroh::SecretKey::generate(); let endpoint = iroh::EndpointAddr::new(identity.public()); let roster = FolderRoster::create( Uuid::new_v4(), "secret".to_owned(), RosterMember { device_id: endpoint.id.to_string(), display_name: None, role: MemberRole::Owner, }, )? .sign(&identity)?; let expires_at = time::OffsetDateTime::UNIX_EPOCH + Duration::hours(1); let invite = Invite { protocol_version: INVITATION_PROTOCOL_VERSION, folder_id: roster.folder_id, folder_name: "notes".to_owned(), inviter_device_id: endpoint.id.to_string(), inviter_endpoint: endpoint, capability: "secret".to_owned(), roster, expires_at, signature: None, }; let invite = invite.sign(&identity)?; let ticket = encode_invite(&invite)?; assert!(decode_invite_at(&ticket, expires_at - Duration::seconds(1)).is_ok()); assert!(decode_invite_at(&ticket, expires_at + Duration::seconds(1)).is_err()); Ok(()) } #[test] fn manifest_summaries_change_with_manifest_contents() -> anyhow::Result<()> { let folder_id = Uuid::new_v4(); let first = crate::domain::Manifest::empty(folder_id); let mut second = first.clone(); second.entries.insert( "note.txt".to_owned(), crate::domain::Entry { path: "note.txt".to_owned(), kind: crate::domain::EntryKind::File, blob_hash: Some("blob".to_owned()), size_bytes: Some(4), modified_at: time::OffsetDateTime::now_utc(), clock: Default::default(), author_device_id: "device".to_owned(), }, ); assert_ne!( ManifestSummary::from_manifest(&first)?, ManifestSummary::from_manifest(&second)? ); Ok(()) } }