diff --git a/BACKEND-CHANGES.md b/BACKEND-CHANGES.md deleted file mode 100644 index 8c5f543..0000000 --- a/BACKEND-CHANGES.md +++ /dev/null @@ -1,325 +0,0 @@ -# Call PQ Secret Envelope Migration - -## What Changed - -The backend no longer treats `CallSecret` as a plain ECDH/base64 string. It now parses, stores, forwards, and returns a typed call secret envelope containing `SecretId`, `VersionNumber`, `EncryptedSecret`, `KemCiphertext`, and `WrappingScheme`. - -Call secret storage is now recipient-specific. `CallGroup.secrets` is keyed by recipient user ID and stores a `CallSecretEnvelope`, so reconnect/session payloads return only the envelope intended for the current user. - -Both authenticated and anonymous `call_invite` handlers now reject a plain-string `CallSecret` with `BadRequest`. The forwarded `CallInvite` push includes the envelope object, and anonymous invite forwarding now includes `CallSecret` as well. - -## Why - -The call client is migrating from an old ECDH shared-secret protocol to a recipient-specific PQ KEM wrapping protocol. The backend must not decrypt, decapsulate, derive keys, or inspect the encrypted secret material. It only validates that the envelope shape is present and forwards/stores the opaque bytes unchanged. - -The previous pairwise string storage mirrored one string for both `(inviter, invitee)` and `(invitee, inviter)`, which is incompatible with recipient-specific KEM ciphertexts. Each recipient needs their own `EncryptedSecret` and `KemCiphertext` envelope. - -## Raw Diff - -```diff -diff --git a/src/anonymous_clients/anonymous_client_connection.rs b/src/anonymous_clients/anonymous_client_connection.rs -index b2c90e7..ddf69b8 100644 ---- a/src/anonymous_clients/anonymous_client_connection.rs -+++ b/src/anonymous_clients/anonymous_client_connection.rs -@@ -7,7 +7,7 @@ use tokio::sync::RwLock; - use uuid::Uuid; - - use crate::anonymous_clients::anonymous_manager::{self, generate_username}; --use crate::calls::call_manager; -+use crate::calls::{call_group::CallSecretEnvelope, call_manager}; - use crate::data::user::UserStatus; - use crate::omega::omega_connection::{OmegaConnection, get_omega_connection}; - use crate::rho::connection::GeneralConnection; -@@ -367,12 +367,21 @@ impl AnonymousClientConnection { - } - }; - -- let secret = cv -- .get_data(DataType::CallSecret) -- .as_str() -- .map(|s| s.to_string()); -- let invited = -- call_manager::add_invite(call_id, self.user_id, receiver_id as u64, secret).await; -+ let secret = match CallSecretEnvelope::from_data_value(cv.get_data(DataType::CallSecret)) { -+ Some(secret) => secret, -+ None => { -+ self.send_error_response(&cv.get_id(), CommunicationType::BadRequest) -+ .await; -+ return; -+ } -+ }; -+ let invited = call_manager::add_invite( -+ call_id, -+ self.user_id, -+ receiver_id as u64, -+ Some(secret.clone()), -+ ) -+ .await; - if !invited { - self.send_error_response(&cv.get_id(), CommunicationType::ErrorInvalidCallId) - .await; -@@ -423,6 +432,7 @@ impl AnonymousClientConnection { - let forward = CommunicationValue::new(CommunicationType::CallInvite) - .with_receiver(receiver_id as u64) - .with_sender(sender_id) -+ .add_typed_default(DataType::CallSecret, secret.to_data_value()) - .add_typed_default(DataType::CallId, DataValue::Str(call_id.to_string())) - .add_typed_default( - DataType::ReceiverId, -diff --git a/src/calls/call_group.rs b/src/calls/call_group.rs -index d751802..a5c76ea 100755 ---- a/src/calls/call_group.rs -+++ b/src/calls/call_group.rs -@@ -1,4 +1,4 @@ --use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -+use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; - use serde_json::Map; - - use std::{collections::BTreeMap, env, sync::Arc, time::Duration}; -@@ -16,7 +16,80 @@ pub struct CallGroup { - pub show: RwLock, - pub anonymous_joining: RwLock, - pub short_link: RwLock>, -- pub secrets: RwLock>, -+ pub secrets: RwLock>, -+} -+ -+#[derive(Clone, Debug)] -+pub struct CallSecretEnvelope { -+ pub secret_id: String, -+ pub version_number: i64, -+ pub encrypted_secret: Vec, -+ pub kem_ciphertext: Vec, -+ pub wrapping_scheme: String, -+} -+ -+impl CallSecretEnvelope { -+ pub fn from_data_value(value: &DataValue) -> Option { -+ let tm = TypeMap::latest(); -+ let secret_id = value -+ .get_field(DataType::SecretId.to_id(&tm))? -+ .as_str()? -+ .to_string(); -+ let version_number = value -+ .get_field(DataType::VersionNumber.to_id(&tm))? -+ .as_signed_number() -+ .and_then(|n| i64::try_from(n).ok()) -+ .or_else(|| { -+ value -+ .get_field(DataType::VersionNumber.to_id(&tm))? -+ .as_number() -+ .and_then(|n| i64::try_from(n).ok()) -+ })?; -+ let encrypted_secret = value -+ .get_field(DataType::EncryptedSecret.to_id(&tm))? -+ .as_bytes()?; -+ let kem_ciphertext = value -+ .get_field(DataType::KemCiphertext.to_id(&tm))? -+ .as_bytes()?; -+ let wrapping_scheme = value -+ .get_field(DataType::WrappingScheme.to_id(&tm))? -+ .as_str()? -+ .to_string(); -+ -+ Some(Self { -+ secret_id, -+ version_number, -+ encrypted_secret, -+ kem_ciphertext, -+ wrapping_scheme, -+ }) -+ } -+ -+ pub fn to_data_value(&self) -> DataValue { -+ let tm = TypeMap::latest(); -+ let mut map: BTreeMap = BTreeMap::new(); -+ map.insert( -+ DataType::SecretId.to_id(&tm), -+ DataValue::Str(self.secret_id.clone()), -+ ); -+ map.insert( -+ DataType::VersionNumber.to_id(&tm), -+ DataValue::SignedNumber(self.version_number.into()), -+ ); -+ map.insert( -+ DataType::EncryptedSecret.to_id(&tm), -+ DataValue::Bytes(self.encrypted_secret.clone()), -+ ); -+ map.insert( -+ DataType::KemCiphertext.to_id(&tm), -+ DataValue::Bytes(self.kem_ciphertext.clone()), -+ ); -+ map.insert( -+ DataType::WrappingScheme.to_id(&tm), -+ DataValue::Str(self.wrapping_scheme.clone()), -+ ); -+ DataValue::container_from_map(&map) -+ } - } - - impl CallGroup { -diff --git a/src/calls/call_manager.rs b/src/calls/call_manager.rs -index 6c33fd6..718f5f1 100644 ---- a/src/calls/call_manager.rs -+++ b/src/calls/call_manager.rs -@@ -3,7 +3,11 @@ use once_cell::sync::Lazy; - use std::sync::Arc; - use uuid::Uuid; - --use crate::calls::{call_group::CallGroup, call_util, caller::Caller}; -+use crate::calls::{ -+ call_group::{CallGroup, CallSecretEnvelope}, -+ call_util, -+ caller::Caller, -+}; - - pub static CALL_GROUPS: Lazy>> = Lazy::new(|| DashMap::new()); - #[allow(dead_code)] -@@ -74,7 +78,7 @@ pub async fn add_invite( - call_id: Uuid, - inviter_id: u64, - invitee_id: u64, -- secret: Option, -+ secret: Option, - ) -> bool { - if let Some(cg) = CALL_GROUPS.get(&call_id) { - let mut members = cg.members.write().await; -@@ -88,8 +92,7 @@ pub async fn add_invite( - - if let Some(secret) = secret { - let mut secrets = cg.secrets.write().await; -- secrets.insert((inviter_id, invitee_id), secret.clone()); -- secrets.insert((invitee_id, inviter_id), secret); -+ secrets.insert(invitee_id, secret); - } - - return true; -diff --git a/src/rho/client_connection.rs b/src/rho/client_connection.rs -index 2f0ca96..56a6cba 100644 ---- a/src/rho/client_connection.rs -+++ b/src/rho/client_connection.rs -@@ -1,5 +1,5 @@ - use crate::anonymous_clients::anonymous_manager; --use crate::calls::{call_manager, call_util}; -+use crate::calls::{call_group::CallSecretEnvelope, call_manager, call_util}; - use crate::omega::omega_connection::get_omega_connection; - use crate::rho::connection::GeneralConnection; - use crate::rho::{rho_connection::RhoConnection, rho_manager}; -@@ -354,12 +354,21 @@ impl ClientConnection { - } - }; - -- let secret = cv -- .get_data(DataType::CallSecret) -- .as_str() -- .map(|s| s.to_string()); -- let invited = -- call_manager::add_invite(call_id, self.user_id, receiver_id as u64, secret).await; -+ let secret = match CallSecretEnvelope::from_data_value(cv.get_data(DataType::CallSecret)) { -+ Some(secret) => secret, -+ None => { -+ self.send_error_response(cv.get_id(), CommunicationType::BadRequest) -+ .await; -+ return; -+ } -+ }; -+ let invited = call_manager::add_invite( -+ call_id, -+ self.user_id, -+ receiver_id as u64, -+ Some(secret.clone()), -+ ) -+ .await; - if !invited { - self.send_error_response(cv.get_id(), CommunicationType::ErrorInvalidCallId) - .await; -@@ -410,10 +419,7 @@ impl ClientConnection { - let forward = CommunicationValue::new(CommunicationType::CallInvite) - .with_receiver(receiver_id as u64) - .with_sender(sender_id as u64) -- .add_typed_default( -- DataType::CallSecret, -- cv.get_data(DataType::CallSecret).clone(), -- ) -+ .add_typed_default(DataType::CallSecret, secret.to_data_value()) - .add_typed_default(DataType::CallId, DataValue::Str(call_id.to_string())) - .add_typed_default( - DataType::ReceiverId, -diff --git a/src/rho/connection.rs b/src/rho/connection.rs -index 90abac0..2e97711 100755 ---- a/src/rho/connection.rs -+++ b/src/rho/connection.rs -@@ -207,7 +207,16 @@ impl GeneralConnection { - ); - } - -- // Add to global calls (without contact-specific secret) -+ if let Some(secret) = -+ call.secrets.read().await.get(&(user_id as u64)) -+ { -+ base_call_map.insert( -+ DataType::CallSecret.to_id(&tm), -+ secret.to_data_value(), -+ ); -+ } -+ -+ // Add to global calls with only this user's recipient-specific secret. - global_calls.push(DataValue::container_from_map( - &base_call_map, - )); -@@ -218,19 +227,7 @@ impl GeneralConnection { - continue; - } - -- let mut contact_call_map = base_call_map.clone(); -- -- if let Some(secret) = call -- .secrets -- .read() -- .await -- .get(&(member_id, user_id as u64)) -- { -- contact_call_map.insert( -- DataType::CallSecret.to_id(&tm), -- DataValue::Str(secret.clone()), -- ); -- } -+ let contact_call_map = base_call_map.clone(); - - invites - .entry(member_id as i64) -diff --git a/src/rho/iota_connection.rs b/src/rho/iota_connection.rs -index 95956a4..b499875 100755 ---- a/src/rho/iota_connection.rs -+++ b/src/rho/iota_connection.rs -@@ -433,7 +433,11 @@ impl IotaConnection { - base_call_map.insert(DataType::HasAdmin.to_id(&tm), DataValue::Bool(true)); - } - -- // Add to global calls (without contact-specific secret) -+ if let Some(secret) = call.secrets.read().await.get(&user_id) { -+ base_call_map.insert(DataType::CallSecret.to_id(&tm), secret.to_data_value()); -+ } -+ -+ // Add to global calls with only this user's recipient-specific secret. - global_calls.push(DataValue::container_from_map(&base_call_map)); - - // Attach this call to EVERY member of the call (other than ourselves) -@@ -443,15 +447,7 @@ impl IotaConnection { - continue; - } - -- let mut contact_call_map = base_call_map.clone(); -- -- // Add secret if it exists for this pairing -- if let Some(secret) = call.secrets.read().await.get(&(member_id, user_id)) { -- contact_call_map.insert( -- DataType::CallSecret.to_id(&tm), -- DataValue::Str(secret.clone()), -- ); -- } -+ let contact_call_map = base_call_map.clone(); - - invites - .entry(member_id as i64) -``` diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index a25886d..7cca709 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -109,6 +109,7 @@ type CallStore = { pendingWatchedParticipantIds: number[]; activeScreenShareParticipantIds: number[]; isEncrypted: boolean; + ownCallSecretInvitePending: boolean; callIsFullscreen: boolean; callIsPopout: boolean; layoutVersion: number; @@ -897,6 +898,7 @@ export async function disconnect() { watchedStreamParticipantIds: [], pendingWatchedParticipantIds: [], activeScreenShareParticipantIds: [], + ownCallSecretInvitePending: false, callIsFullscreen: false, lastFocusedParticipantId: null, }); @@ -930,9 +932,11 @@ export async function joinCall( } log(2, "call", "purple", "Call creation initialised"); + const isNewCall = !callSecret && !existingCallId; useCall.setState({ state: "encrypting", invitedUserId: sendInvite && !existingCallId ? userId : null, + ownCallSecretInvitePending: isNewCall, }); if (callSecret) { @@ -1119,6 +1123,7 @@ export const useCall = create(() => ({ pendingWatchedParticipantIds: [], activeScreenShareParticipantIds: [], isEncrypted: false, + ownCallSecretInvitePending: false, callIsFullscreen: false, callIsPopout: false, layoutVersion: 0, @@ -1226,13 +1231,9 @@ export function useInitializeCall() { SenderId: number; }; - showCallingScreen( - CallId, - normalizeWrappedCallSecret(CallSecret), - SenderId, - ); + showCallingScreen(CallId, CallSecret, SenderId); }); - }, [subscribePush, showCallingScreen]); + }, [load, subscribePush, showCallingScreen]); // get callId from url useEffect(() => { @@ -1333,6 +1334,23 @@ export function useInitializeCall() { } const invitedUserId = useCall.getState().invitedUserId; + const ownCallSecretInvitePending = + useCall.getState().ownCallSecretInvitePending; + + if (ownCallSecretInvitePending) { + useCall.setState({ ownCallSecretInvitePending: false }); + void load("user_id") + .then((ownUserId) => sendCallInvite(Number(ownUserId))) + .catch((error) => { + log( + 1, + "call", + "red", + "Failed to send own call secret invite", + error, + ); + }); + } if (invitedUserId != null) { setTimeout(async () => {