Use serde_jcs for RFC 8785 compliancy

This fixes serialization and reduces maintainance cost, but does more
data copying on serialization.
This commit is contained in:
oxalica 2024-08-31 15:08:19 -04:00
parent 6e7229e4ac
commit a63d0df443
5 changed files with 60 additions and 57 deletions

20
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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"))?)

View file

@ -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',

View file

@ -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<T> {
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<T: Serialize> WithSig<T> {
/// Sign the payload with the given `key`.
pub fn sign(
key: &SigningKey,
timestamp: u64,
rng: &mut impl RngCore,
payload: T,
) -> Result<Self, SignatureError> {
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<T: Serialize> WithSig<T> {
///
/// 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::<Vec<_>>();
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::<WithSig<ChatPayload>>(&json).unwrap();
// assert_eq!(roundtrip_item, item);
roundtrip_item.verify().unwrap();
}
#[test]