diff --git a/Cargo.lock b/Cargo.lock index c46f205..b3dacc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,11 +259,12 @@ dependencies = [ "ed25519-dalek", "hex", "html-escape", + "rand", "rand_core", "rusqlite", "serde", + "serde_jcs", "serde_json", - "syn", "uuid", ] @@ -1392,6 +1393,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "schannel" version = "0.1.23" @@ -1478,6 +1485,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_jcs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.127" diff --git a/Cargo.toml b/Cargo.toml index 7695945..64ed488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,10 @@ bitflags_serde_shim = "0.2" ed25519-dalek = "2.1" hex = { version = "0.4", features = ["serde"] } html-escape = "0.2" +rand = "0.8.5" rand_core = "0.6" rusqlite = { version = "0.32", optional = true } serde = { version = "1", features = ["derive"] } +serde_jcs = "0.1" serde_json = "1" uuid = { version = "1", features = ["serde"] } - -[dev-dependencies] -syn = { version = "2.0.76", features = ["full", "visit"] } diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index 3ae7957..4b72678 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -5,8 +5,8 @@ use std::{fs, io}; use anyhow::{Context, Result}; use blah::bitflags; use blah::types::{ - ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, RoomMember, - RoomMemberList, ServerPermission, UserKey, WithSig, + get_timestamp, ChatPayload, CreateRoomPayload, MemberPermission, RichText, RoomAttrs, + RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig, }; use blah::uuid::Uuid; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; @@ -220,7 +220,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { user: UserKey(key.verifying_key().to_bytes()), }]), }; - let payload = WithSig::sign(&key, &mut OsRng, payload)?; + let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?; let ret = client .post(api_url.join("/room/create")?) @@ -242,7 +242,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { room, rich_text: RichText::from(text), }; - let payload = WithSig::sign(&key, &mut OsRng, payload)?; + let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?; let ret = client .post(api_url.join(&format!("/room/{room}/item"))?) diff --git a/pages/main.js b/pages/main.js index d0524a5..5389974 100644 --- a/pages/main.js +++ b/pages/main.js @@ -96,7 +96,12 @@ async function showChatMsg(chat) { let verifyRet = null; crypto.subtle.exportKey('raw', keypair.publicKey) try { - const signeeBytes = (new TextEncoder()).encode(JSON.stringify(chat.signee)); + const sortKeys = (obj) => + Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0])); + const canonicalJson = chat.signee + // Just for simplicity. Only this struct is unsorted due to serde implementation. + canonicalJson.payload = sortKeys(canonicalJson.payload) + const signeeBytes = (new TextEncoder()).encode(JSON.stringify(canonicalJson)); const rawkey = hexToBuf(chat.signee.user); const senderKey = await crypto.subtle.importKey('raw', rawkey, { name: 'Ed25519' }, true, ['verify']); const success = await crypto.subtle.verify('Ed25519', senderKey, hexToBuf(chat.sig), signeeBytes); @@ -244,9 +249,10 @@ async function postChat(text) { richText = [text]; } const signedPayload = await signData({ - typ: 'chat', + // sorted fields. rich_text: richText, room: roomUuid, + typ: 'chat', }); const resp = await fetch(`${roomUrl}/item`, { method: 'POST', diff --git a/src/types.rs b/src/types.rs index 1b1dfb6..183338c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,7 +1,3 @@ -//! NB. All structs here that are part of signee must be lexically sorted, as RFC8785. -//! This is tested by `canonical_fields_sorted`. -//! See: https://www.rfc-editor.org/rfc/rfc8785 -//! FIXME: `typ` is still always the first field because of `serde`'s implementation. use std::fmt; use std::time::SystemTime; @@ -44,7 +40,7 @@ pub struct Signee { pub user: UserKey, } -fn get_timestamp() -> u64 { +pub fn get_timestamp() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("after UNIX epoch") @@ -55,16 +51,17 @@ impl WithSig { /// Sign the payload with the given `key`. pub fn sign( key: &SigningKey, + timestamp: u64, rng: &mut impl RngCore, payload: T, ) -> Result { let signee = Signee { nonce: rng.next_u32(), payload, - timestamp: get_timestamp(), + timestamp, user: UserKey(key.verifying_key().to_bytes()), }; - let canonical_signee = serde_json::to_vec(&signee).expect("serialization cannot fail"); + let canonical_signee = serde_jcs::to_vec(&signee).expect("serialization cannot fail"); let sig = key.try_sign(&canonical_signee)?.to_bytes(); Ok(Self { sig, signee }) } @@ -73,7 +70,7 @@ impl WithSig { /// /// Note that this does nott check validity of timestamp and other data. pub fn verify(&self) -> Result<(), SignatureError> { - let canonical_signee = serde_json::to_vec(&self.signee).expect("serialization cannot fail"); + let canonical_signee = serde_jcs::to_vec(&self.signee).expect("serialization cannot fail"); let sig = Signature::from_bytes(&self.sig); VerifyingKey::from_bytes(&self.signee.user.0)?.verify_strict(&canonical_signee, &sig)?; Ok(()) @@ -431,49 +428,32 @@ mod sql_impl { #[cfg(test)] mod tests { - use std::fmt::Write; - - use syn::visit; - use super::*; - #[derive(Default)] - struct Visitor { - errors: String, - } - - const SKIP_CHECK_STRUCTS: &[&str] = &["RichTextPiece", "RichTextPieceRaw"]; - - impl<'ast> syn::visit::Visit<'ast> for Visitor { - fn visit_fields_named(&mut self, i: &'ast syn::FieldsNamed) { - let fields = i - .named - .iter() - .flat_map(|f| f.ident.clone()) - .map(|i| i.to_string()) - .collect::>(); - if !fields.windows(2).all(|w| w[0] < w[1]) { - writeln!(self.errors, "unsorted fields: {fields:?}").unwrap(); - } - } - - fn visit_item_struct(&mut self, i: &'ast syn::ItemStruct) { - if !SKIP_CHECK_STRUCTS.contains(&&*i.ident.to_string()) { - visit::visit_item_struct(self, i); - } - } - } - #[test] - fn canonical_fields_sorted() { - let src = std::fs::read_to_string(file!()).unwrap(); - let file = syn::parse_file(&src).unwrap(); + fn canonical_chat() { + let mut fake_rng = rand::rngs::mock::StepRng::new(0x42, 1); + let signing_key = SigningKey::from_bytes(&[0x42; 32]); + let timestamp = 0xDEAD_BEEF; + let item = WithSig::sign( + &signing_key, + timestamp, + &mut fake_rng, + ChatPayload { + rich_text: RichText::from("hello"), + room: Uuid::nil(), + }, + ) + .unwrap(); - let mut v = Visitor::default(); - syn::visit::visit_file(&mut v, &file); - if !v.errors.is_empty() { - panic!("{}", v.errors); - } + let json = serde_jcs::to_string(&item).unwrap(); + assert_eq!( + json, + r#"{"sig":"5e52985dc9e43a77267f0b383a8223af96f36e83c180a36da627dfac6504b2bb4c6b80c9903a6c3a0bbc742718466d72af4407a8e74d41af5cb0137cf3798d08","signee":{"nonce":66,"payload":{"rich_text":["hello"],"room":"00000000-0000-0000-0000-000000000000","typ":"chat"},"timestamp":3735928559,"user":"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12"}}"# + ); + let roundtrip_item = serde_json::from_str::>(&json).unwrap(); + // assert_eq!(roundtrip_item, item); + roundtrip_item.verify().unwrap(); } #[test]