From 0ddd25a1cf44c38d50f3a507bce23a0a5d6fac99 Mon Sep 17 00:00:00 2001 From: Claas Date: Sun, 7 Dec 2025 17:19:16 +0100 Subject: [PATCH] Make client operations more ergonomic --- Cargo.lock | 30 ++- app/src/service-worker/serviceWorker.ts | 46 ++--- core/Cargo.toml | 4 +- core/src/lib.rs | 3 +- core/src/v2/serializable.rs | 235 +++++++++++++----------- 5 files changed, 182 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3edfe0..4397cd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2563,12 +2563,12 @@ dependencies = [ "postcard", "serde", "serde-wasm-bindgen", + "serde_bytes", "thiserror 2.0.9", "time", "tls_codec", "tsify", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", ] @@ -3696,10 +3696,11 @@ checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -3714,11 +3715,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/app/src/service-worker/serviceWorker.ts b/app/src/service-worker/serviceWorker.ts index 10a8749..d08ab9e 100644 --- a/app/src/service-worker/serviceWorker.ts +++ b/app/src/service-worker/serviceWorker.ts @@ -1,11 +1,6 @@ import { precacheAndRoute } from "workbox-precaching"; import { openDB } from "idb"; -import init, { - create_client, - create_invite, - decode_key_package, - Friend, -} from "meal-core"; +import init, { Client, Friend } from "meal-core"; import { expose } from "../crackle"; import { Schema } from "./schema"; @@ -69,7 +64,7 @@ const fileSizeFormatter = new Intl.NumberFormat(undefined, { unit: "megabyte", }); -async function persistClient(client: Uint8Array) { +async function persistClient(client: Client) { // Persist client state const directory = await navigator.storage.getDirectory(); @@ -78,25 +73,29 @@ async function persistClient(client: Uint8Array) { }); const writeStream = await fileHandle.createWritable(); - if (client.buffer instanceof SharedArrayBuffer) - throw new Error("Did not expect a SharedArrayBuffer"); + + const serializedClient = client.serialize(); + if (serializedClient.buffer instanceof SharedArrayBuffer) + throw new Error( + "Did not expect a SharedArrayBuffer. Cannot write shared buffer to file" + ); try { - await writeStream.write(client.buffer); + await writeStream.write(serializedClient.buffer); } finally { writeStream.close(); } console.debug( `[Service worker]: Stored client with length ${fileSizeFormatter.format( - client.length + serializedClient.length )}` ); return client; } -async function initializeClient(): Promise { +async function initializeClient(): Promise { // Have to use wasm-pack --target web to build the wasm package to get the init function because with the bundler target // it is included as a top level await which is not supported by service workers according to the web spec // Has to be initialized before we do anything with the Rust code. It also needs to be initialized if a client exists @@ -108,18 +107,19 @@ async function initializeClient(): Promise { if (fileHandle !== undefined) { const file = await fileHandle.getFile(); - return new Uint8Array(await file.arrayBuffer()); + const buffer = await file.arrayBuffer(); + return Client.from_serialized(new Uint8Array(buffer)); } // Create new client - const client = create_client(); + const client = new Client(); return await persistClient(client); } let getClient = initializeClient(); -async function updateClient(client: Uint8Array) { +async function updateClient(client: Client) { getClient = persistClient(client); await getClient; } @@ -162,13 +162,10 @@ const handler = { ]); //TODO fix invite storage/memory leak from creating and adding new key packages without removing them - const result = create_invite(client, configuration.user?.name); - // The getter should clone the data so we can free the memory - const { invite_payload, client: newClient } = result; - result.free(); + const invite_payload = client.create_invite(configuration.user?.name); // Persisting the client does not block us from responding - void updateClient(newClient); + void updateClient(client); const inviteUrl = new URL(`/join/${invite_payload}`, location.origin); return inviteUrl.href; @@ -176,8 +173,8 @@ const handler = { async decodeKeyPackage(encodedInvite: string) { const client = await getClient; - - return decode_key_package(client, encodedInvite); + //TODO make it more ergonomic to not needing to know when the client is mutated and needs to be persisted + return client.decode_key_package(encodedInvite); }, /** @@ -185,7 +182,10 @@ const handler = { * @param name The name the user wants to appear as in the group */ async createGroup(friend: Friend, name: string) { - //TODO create group with core + const client = await getClient; + // const { client: newClient, group_id } = create_group(client); + const group_id = client.create_group(); + await updateClient(client); //TODO persist group with id from core //TODO return group }, diff --git a/core/Cargo.toml b/core/Cargo.toml index 2f46372..115781a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -17,10 +17,10 @@ openmls_traits = "0.3.0" postcard = { version = "1.0.10", features = ["alloc"] } serde = { workspace = true, features = ["derive", "rc"] } serde-wasm-bindgen = "0.6.5" +serde_bytes = "0.11.19" thiserror = { workspace = true } time = { version = "0.3.41", features = ["formatting", "parsing", "serde"] } tls_codec = { workspace = true } tsify = { version = "0.5.6", features = ["js"] } -wasm-bindgen = "0.2.92" -wasm-bindgen-futures = "0.4.42" +wasm-bindgen = "0.2.106" web-sys = { version = "0.3.70", features = ["Storage", "Window"] } diff --git a/core/src/lib.rs b/core/src/lib.rs index 23c5f75..44d9e60 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -28,7 +28,8 @@ struct User { signature_key: SignatureKeyPair, } -#[wasm_bindgen(getter_with_clone)] +// Disable wasm_bindgen as it screws with our v2 implementation +// #[wasm_bindgen(getter_with_clone)] pub struct Client { pub id: String, user: User, diff --git a/core/src/v2/serializable.rs b/core/src/v2/serializable.rs index a384eeb..43a0ef6 100644 --- a/core/src/v2/serializable.rs +++ b/core/src/v2/serializable.rs @@ -24,8 +24,16 @@ struct User { signature_key: SignatureKeyPair, } +#[wasm_bindgen] +#[derive(Debug, thiserror::Error)] +pub enum CreateGroupError { + #[error("Group id already exists. This should not happen if the group id is created randomly")] + IdCollision, +} + #[derive(Serialize, Deserialize)] -struct Client { +#[wasm_bindgen] +pub struct Client { id: Rc, user: User, /// We only store the group ids because the groups themselves are not serializable. @@ -38,108 +46,125 @@ struct Client { } #[wasm_bindgen] -pub fn create_client() -> Result, JsError> { - console_error_panic_hook::set_once(); - - let provider = Provider::default(); - let client_id = nanoid!(ID_LENGTH); - - //TODO Basic credentials only for tests and demo - let credential: Credential = BasicCredential::new(client_id.clone().into_bytes()).into(); - let signature_keys = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm())?; - signature_keys.store(provider.storage())?; - - let credential = CredentialWithKey { - credential, - signature_key: signature_keys.public().into(), - }; - - let user = User { - credential, - signature_key: signature_keys, - }; - - let client = Client { - id: client_id.into(), - user, - groups: HashSet::new(), - key_packages: Vec::new(), - provider, - }; - - Ok(postcard::to_allocvec(&client)?) -} - -#[wasm_bindgen(getter_with_clone)] -pub struct InviteResult { - pub client: Box<[u8]>, - pub invite_payload: String, -} - -#[wasm_bindgen] -pub fn create_invite(client: &[u8], user_name: Option) -> Result { - let mut client: Client = postcard::from_bytes(client)?; - - //TODO think about ways to reduce size of key package to generate smaller invite links - //TODO like using a non self describing serialization format and remove - //TODO and remove things that do not change or where we use a default - //TODO adding postcard as dependency yields 9-10% smaller serialized + base64 encoded key packages - - let extensions = encode_application_id(&client.id, &user_name); - - // Add identifier to help users identify the origin of the key package / invitation - // Details: https://www.rfc-editor.org/rfc/rfc9420.html#section-5.3.3 - - let bundle = KeyPackage::builder() - .key_package_extensions(extensions) - .build( - CIPHERSUITE, - &client.provider, - &client.user.signature_key, - client.user.credential.clone(), - )?; - - client.key_packages.push(bundle.key_package().clone()); - let client = postcard::to_allocvec(&client)?.into(); - // Using postcard reduces the size by around 40 bytes or 9-10% - // This might not be worth the dependency but we are using it for application messages anyways - let data = postcard::to_allocvec(bundle.key_package())?; - Ok(InviteResult { - client, - invite_payload: BASE64_URL_SAFE_NO_PAD.encode(data), - }) -} - -#[wasm_bindgen] -pub fn decode_key_package(client: &[u8], encoded_invite: &str) -> Result { - let data = BASE64_URL_SAFE_NO_PAD.decode(encoded_invite)?; - // let package = KeyPackageIn::tls_deserialize_exact_bytes(&data).unwrap(); - let package: KeyPackageIn = postcard::from_bytes(&data)?; - - let client: Client = postcard::from_bytes(client)?; - - let validated = package.validate(client.provider.crypto(), ProtocolVersion::Mls10)?; - let id = validated - .extensions() - .application_id() - .map(|id| str::from_utf8(id.as_slice())) - .transpose()? - .ok_or_else(|| { - JsError::new("Invite did not contain an id to contact the other client with") - })?; - - let (id, friend_name) = if id.len() > ID_LENGTH { - let (id, friend_name) = id.split_at(ID_LENGTH); - (id, Some(friend_name)) - } else { - (id, None) - }; - - Ok(DecodedPackage { - friend: Friend { - id: id.to_owned(), - name: friend_name.map(str::to_owned), - }, - key_package: validated, - }) +impl Client { + #[wasm_bindgen(constructor)] + pub fn new() -> Result { + console_error_panic_hook::set_once(); + + let provider = Provider::default(); + let client_id = nanoid!(ID_LENGTH); + + //TODO Basic credentials only for tests and demo + let credential: Credential = BasicCredential::new(client_id.clone().into_bytes()).into(); + let signature_keys = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm())?; + signature_keys.store(provider.storage())?; + + let credential = CredentialWithKey { + credential, + signature_key: signature_keys.public().into(), + }; + + let user = User { + credential, + signature_key: signature_keys, + }; + + let client = Client { + id: client_id.into(), + user, + groups: HashSet::new(), + key_packages: Vec::new(), + provider, + }; + + Ok(client) + } + + pub fn serialize(&self) -> Result, JsError> { + Ok(postcard::to_allocvec(&self)?) + } + + pub fn from_serialized(bytes: &[u8]) -> Result { + Ok(postcard::from_bytes(bytes)?) + } + + pub fn create_invite(&mut self, user_name: Option) -> Result { + //TODO think about ways to reduce size of key package to generate smaller invite links + //TODO like using a non self describing serialization format and remove + //TODO and remove things that do not change or where we use a default + //TODO adding postcard as dependency yields 9-10% smaller serialized + base64 encoded key packages + + let extensions = encode_application_id(&self.id, &user_name); + + // Add identifier to help users identify the origin of the key package / invitation + // Details: https://www.rfc-editor.org/rfc/rfc9420.html#section-5.3.3 + + let bundle = KeyPackage::builder() + .key_package_extensions(extensions) + .build( + CIPHERSUITE, + &self.provider, + &self.user.signature_key, + self.user.credential.clone(), + )?; + + self.key_packages.push(bundle.key_package().clone()); + // Using postcard reduces the size by around 40 bytes or 9-10% + // This might not be worth the dependency but we are using it for application messages anyways + let data = postcard::to_allocvec(bundle.key_package())?; + Ok(BASE64_URL_SAFE_NO_PAD.encode(data)) + } + + pub fn decode_key_package(&self, encoded_invite: &str) -> Result { + let data = BASE64_URL_SAFE_NO_PAD.decode(encoded_invite)?; + // let package = KeyPackageIn::tls_deserialize_exact_bytes(&data).unwrap(); + let package: KeyPackageIn = postcard::from_bytes(&data)?; + + let validated = package.validate(self.provider.crypto(), ProtocolVersion::Mls10)?; + let id = validated + .extensions() + .application_id() + .map(|id| str::from_utf8(id.as_slice())) + .transpose()? + .ok_or_else(|| { + JsError::new("Invite did not contain an id to contact the other client with") + })?; + + let (id, friend_name) = if id.len() > ID_LENGTH { + let (id, friend_name) = id.split_at(ID_LENGTH); + (id, Some(friend_name)) + } else { + (id, None) + }; + + Ok(DecodedPackage { + friend: Friend { + id: id.to_owned(), + name: friend_name.map(str::to_owned), + }, + key_package: validated, + }) + } + + pub fn create_group(&mut self) -> Result { + let group = MlsGroup::builder() + .use_ratchet_tree_extension(true) + // //TODO should we enforce usage of application id in the capabilities? + // .with_leaf_node_extensions(encode_application_id(self.id.clone(), &self.user.name)) + // .unwrap() + .build( + &self.provider, + &self.user.signature_key, + self.user.credential.clone(), + )?; + + let group_id = group.group_id(); + if self.groups.contains(&group_id) { + return Err(CreateGroupError::IdCollision.into()); + } + let js_group_id = BASE64_URL_SAFE_NO_PAD.encode(group_id.as_slice()); + + self.groups.insert(group_id.clone()); + Ok(js_group_id) + } } -- 2.51.2