diff --git a/src/domain.rs b/src/domain.rs index fd5b743..aaebdeb 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -22,6 +22,15 @@ pub type VersionClock = BTreeMap; /// Version of the signed folder roster and manifest model. pub const FOLDER_PROTOCOL_VERSION: u16 = 2; +/// Encode data used in signed payloads and Merkle hashes. +/// +/// Appa's protocol types use declaration-ordered structs and `BTreeMap` for +/// every map in these payloads. Changing either representation changes the +/// bytes, so it requires a matching protocol version bump. +pub(crate) fn canonical_json_bytes(value: &T) -> anyhow::Result> { + Ok(serde_json::to_vec(value)?) +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FolderMode { diff --git a/src/domain/merkle.rs b/src/domain/merkle.rs index dd9b1c4..594553f 100644 --- a/src/domain/merkle.rs +++ b/src/domain/merkle.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use super::{Entry, Manifest, RelativePath}; +use super::{Entry, Manifest, RelativePath, canonical_json_bytes}; const MAX_LEAF_ENTRIES: usize = 64; const BLAKE3_HEX_LENGTH: usize = 64; @@ -93,7 +93,7 @@ fn build_index_node( let node = if entries.len() <= MAX_LEAF_ENTRIES || prefix.len() == BLAKE3_HEX_LENGTH { ManifestMerkleNode::Leaf { prefix: prefix.to_owned(), - hash: blake3::hash(&serde_json::to_vec(&entries)?) + hash: blake3::hash(&canonical_json_bytes(&entries)?) .to_hex() .to_string(), entries, @@ -116,7 +116,7 @@ fn build_index_node( } ManifestMerkleNode::Branch { prefix: prefix.to_owned(), - hash: blake3::hash(&serde_json::to_vec(&children)?) + hash: blake3::hash(&canonical_json_bytes(&children)?) .to_hex() .to_string(), children, diff --git a/src/domain/roster.rs b/src/domain/roster.rs index 2468cfd..76f2e36 100644 --- a/src/domain/roster.rs +++ b/src/domain/roster.rs @@ -4,7 +4,7 @@ use anyhow::Context; use iroh::{EndpointId, SecretKey, Signature}; use serde::{Deserialize, Serialize}; -use super::{DeviceId, FOLDER_PROTOCOL_VERSION, FolderId}; +use super::{DeviceId, FOLDER_PROTOCOL_VERSION, FolderId, canonical_json_bytes}; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum MemberRole { @@ -127,7 +127,7 @@ impl FolderRoster { } fn signing_bytes(&self) -> anyhow::Result> { - Ok(serde_json::to_vec(&UnsignedFolderRoster::from(self))?) + canonical_json_bytes(&UnsignedFolderRoster::from(self)) } fn invalidate_signature(&mut self) { diff --git a/src/protocol.rs b/src/protocol.rs index 132a853..8c792b0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,6 +1,6 @@ use anyhow::Context; -use crate::domain::{DeviceId, FolderId, FolderRoster, ManifestMerkleNode}; +use crate::domain::{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}; @@ -67,9 +67,36 @@ impl Invite { } fn signing_bytes(&self) -> anyhow::Result> { - let mut unsigned = self.clone(); - unsigned.signature = None; - Ok(serde_json::to_vec(&unsigned)?) + 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, + } } } @@ -175,6 +202,15 @@ mod tests { signature: None, }; 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());