diff --git a/BACKEND-CHANGES.md b/BACKEND-CHANGES.md new file mode 100644 index 0000000..8c5f543 --- /dev/null +++ b/BACKEND-CHANGES.md @@ -0,0 +1,325 @@ +# 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 e14aea6..a25886d 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -4,7 +4,13 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; import { mtp } from "@tensamin/shared/data"; -import { useCrypto } from "@tensamin/crypto/context"; +import { bytesToBase64 } from "mtp"; +import { + deriveCallSecretId, + kemPublicKeyFromPublicKeyBundle, + unwrapCallSecret, + wrapCallSecret, +} from "@tensamin/crypto/callSecret"; import { useStorage } from "@tensamin/storage/context"; import { useSession } from "@tensamin/storage/session"; import { useUser } from "@tensamin/user/context"; @@ -46,9 +52,19 @@ setLogExtension( type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; +type ProtocolCallSecret = NonNullable< + z.infer["CallSecret"] +>; +type WrappedCallSecret = { + secretId: string; + versionNumber: number; + encryptedSecret: Uint8Array; + kemCiphertext: Uint8Array; + wrappingScheme: string; +}; type IncomingCallInvite = { callId: string; - callSecret: string; + callSecret: WrappedCallSecret; senderId: number; }; type CurrentCallData = @@ -62,13 +78,6 @@ type SendFn = ( type: string, data: Record, ) => Promise<{ data: unknown }>; -type GetSharedSecretFn = ( - privateKey: unknown, - ownPublicKey: string, - remotePublicKey: string, -) => Promise; -type DecryptTextFn = (sharedSecret: string, text: string) => Promise; -type EncryptTextFn = (sharedSecret: string, text: string) => Promise; type LoadFn = (key: string) => Promise; type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>; type RemoteVideoTrackSelector = Track.Kind | Track.Source; @@ -76,9 +85,6 @@ type RemoteVideoTrackSelector = Track.Kind | Track.Source; type Runtime = { navigate: NavigateFn; send: SendFn; - getSharedSecret: GetSharedSecretFn; - decryptText: DecryptTextFn; - encryptText: EncryptTextFn; load: LoadFn; getUser: GetUserFn; }; @@ -148,11 +154,46 @@ export function getRoom(): Room { const remoteAudioElements = new Map(); +const CALL_SECRET_VERSION = 1; const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320; const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180; const SCREEN_SHARE_PREVIEW_QUALITY = 0.7; const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000; +function protocolBytes(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes); +} + +function normalizeWrappedCallSecret( + callSecret: WrappedCallSecret | ProtocolCallSecret, +): WrappedCallSecret { + if ("secretId" in callSecret) { + return callSecret; + } + + return { + secretId: callSecret.SecretId, + versionNumber: callSecret.VersionNumber, + encryptedSecret: callSecret.EncryptedSecret, + kemCiphertext: callSecret.KemCiphertext, + wrappingScheme: callSecret.WrappingScheme, + }; +} + +function protocolCallSecret(callSecret: WrappedCallSecret): ProtocolCallSecret { + return { + SecretId: callSecret.secretId, + VersionNumber: callSecret.versionNumber, + EncryptedSecret: protocolBytes(callSecret.encryptedSecret), + KemCiphertext: protocolBytes(callSecret.kemCiphertext), + WrappingScheme: callSecret.wrappingScheme, + }; +} + +function randomCallSecret(): string { + return bytesToBase64(globalThis.crypto.getRandomValues(new Uint8Array(32))); +} + // audio helpers function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) { const existingElement = remoteAudioElements.get(trackSid); @@ -625,28 +666,28 @@ export async function sendCallInvite(userId: number) { throw new Error("Cannot send call invite without an active call."); } - const ownUserId = (await runtime.load("user_id")) as number; - const privateKey = await runtime.load("mtp_keyring"); - const ownPublicKey = await runtime - .getUser(ownUserId) - .then((data) => data.PublicKey); const remotePublicKey = await runtime .getUser(userId) .then((data) => data.PublicKey); - const sharedSecret = await runtime.getSharedSecret( - privateKey, - ownPublicKey, - remotePublicKey, - ); - const encryptedCallSecret = await runtime.encryptText( - sharedSecret, + const secretId = deriveCallSecretId(callId); + const wrapped = await wrapCallSecret({ callSecret, - ); + recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(remotePublicKey), + callId, + secretId, + version: CALL_SECRET_VERSION, + }); await runtime.send("call_invite", { ReceiverId: userId, CallId: callId, - CallSecret: encryptedCallSecret, + CallSecret: { + SecretId: secretId, + VersionNumber: CALL_SECRET_VERSION, + EncryptedSecret: protocolBytes(wrapped.encryptedSecret), + KemCiphertext: protocolBytes(wrapped.kemCiphertext), + WrappingScheme: wrapped.wrappingScheme, + }, }); } @@ -877,7 +918,7 @@ export async function disconnect() { // Prepare encryption and join or create a call with another user. export async function joinCall( userId: number, - callSecret?: string, + callSecret?: WrappedCallSecret | ProtocolCallSecret, existingCallId?: string, sendInvite = true, ) { @@ -896,17 +937,20 @@ export async function joinCall( if (callSecret) { try { - const sharedSecret = await runtime.getSharedSecret( - await runtime.load("mtp_keyring"), - await runtime - .getUser((await runtime.load("user_id")) as number) - .then((res) => res.PublicKey), - await runtime.getUser(userId).then((res) => res.PublicKey), - ); - const decryptedSecret = await runtime.decryptText( - sharedSecret, - callSecret, - ); + if (!existingCallId) { + throw new Error("Cannot unwrap call secret without a call id"); + } + + const wrappedCallSecret = normalizeWrappedCallSecret(callSecret); + const decryptedSecret = await unwrapCallSecret({ + encryptedSecret: wrappedCallSecret.encryptedSecret, + kemCiphertext: wrappedCallSecret.kemCiphertext, + keyring: String(await runtime.load("mtp_keyring")), + callId: existingCallId, + secretId: wrappedCallSecret.secretId, + version: wrappedCallSecret.versionNumber, + wrappingScheme: wrappedCallSecret.wrappingScheme, + }); await getKeyProvider().setKey(decryptedSecret); await getRoom().setE2EEEnabled(true); @@ -917,7 +961,7 @@ export async function joinCall( return; } } else { - const random = crypto.randomUUID(); + const random = randomCallSecret(); await getKeyProvider().setKey(random); await getRoom().setE2EEEnabled(true); @@ -1088,7 +1132,6 @@ export function useInitializeCall() { const navigate = useNavigate(); const location = useLocation(); const { send, subscribePush } = useMTP(); - const { getSharedSecret, decryptText, encryptText } = useCrypto(); const { load } = useStorage(); const { insertCall } = useSession(); const { get } = useUser(); @@ -1116,16 +1159,13 @@ export function useInitializeCall() { setCallRuntime({ navigate, send: send as SendFn, - getSharedSecret: getSharedSecret as GetSharedSecretFn, - decryptText: decryptText as DecryptTextFn, - encryptText: encryptText as EncryptTextFn, load: load as LoadFn, getUser: get as GetUserFn, }); - }, [decryptText, encryptText, get, getSharedSecret, load, navigate, send]); + }, [get, load, navigate, send]); const showCallingScreen = useCallback( - (callId: string, callSecret: string, senderId: number) => { + (callId: string, callSecret: WrappedCallSecret, senderId: number) => { useCall.setState({ incomingCallInvite: { callId, callSecret, senderId }, }); @@ -1151,7 +1191,7 @@ export function useInitializeCall() { insertCall({ CallId: invite.callId, - CallSecret: invite.callSecret, + CallSecret: protocolCallSecret(invite.callSecret), CallMembers: [invite.senderId], }); @@ -1182,11 +1222,15 @@ export function useInitializeCall() { const { CallId, CallSecret, SenderId } = message.data as { CallId: string; - CallSecret: string; + CallSecret: ProtocolCallSecret; SenderId: number; }; - showCallingScreen(CallId, CallSecret, SenderId); + showCallingScreen( + CallId, + normalizeWrappedCallSecret(CallSecret), + SenderId, + ); }); }, [subscribePush, showCallingScreen]); diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 636f300..d5ce7d2 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { "./context": "./src/context.tsx", - "./chatSecret": "./src/chatSecret.ts" + "./chatSecret": "./src/chatSecret.ts", + "./callSecret": "./src/callSecret.ts" }, "scripts": { "format": "pnpm exec prettier --write .", diff --git a/packages/crypto/src/callSecret.ts b/packages/crypto/src/callSecret.ts new file mode 100644 index 0000000..97ef318 --- /dev/null +++ b/packages/crypto/src/callSecret.ts @@ -0,0 +1,112 @@ +import { crypto } from "mtp"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const CALL_SECRET_WRAPPING_SCHEME = + "mtp-call-secret-kem-chacha20poly1305-hkdf-sha256-v1"; + +const CALL_SECRET_SALT = textEncoder.encode("tensamin-call-secret-v1"); + +export function deriveCallSecretId(callId: string): string { + return `call:${callId}:main`; +} + +export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { + return crypto.keyringToKeys(keyring).kemPublicKey; +} + +export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { + return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; +} + +export async function wrapCallSecret(args: { + callSecret: string; + recipientKemPublicKey: Uint8Array; + callId: string; + secretId: string; + version: number; +}): Promise<{ + encryptedSecret: Uint8Array; + kemCiphertext: Uint8Array; + wrappingScheme: string; +}> { + const enc = crypto.encapsulate(args.recipientKemPublicKey); + try { + const wrappingKey = deriveWrappingKey({ + sharedSecret: enc.shared_secret, + callId: args.callId, + secretId: args.secretId, + version: args.version, + }); + + try { + return { + encryptedSecret: await crypto.encrypt( + wrappingKey, + textEncoder.encode(args.callSecret), + ), + kemCiphertext: enc.ciphertext, + wrappingScheme: CALL_SECRET_WRAPPING_SCHEME, + }; + } finally { + wrappingKey.fill(0); + } + } finally { + enc.shared_secret.fill(0); + } +} + +export async function unwrapCallSecret(args: { + encryptedSecret: Uint8Array; + kemCiphertext: Uint8Array; + keyring: string; + callId: string; + secretId: string; + version: number; + wrappingScheme: string; +}): Promise { + if (args.wrappingScheme !== CALL_SECRET_WRAPPING_SCHEME) { + throw new Error( + `Unsupported call secret wrapping scheme: ${args.wrappingScheme}`, + ); + } + + const ownKeys = crypto.keyringToKeys(args.keyring); + const sharedSecret = crypto.decapsulate( + ownKeys.kemSecretKey, + args.kemCiphertext, + ); + + try { + const wrappingKey = deriveWrappingKey({ + sharedSecret, + callId: args.callId, + secretId: args.secretId, + version: args.version, + }); + + try { + return textDecoder.decode( + await crypto.decrypt(wrappingKey, args.encryptedSecret), + ); + } finally { + wrappingKey.fill(0); + } + } finally { + sharedSecret.fill(0); + } +} + +function deriveWrappingKey(args: { + sharedSecret: Uint8Array; + callId: string; + secretId: string; + version: number; +}): Uint8Array { + return crypto.deriveEncryptionKey( + args.sharedSecret, + CALL_SECRET_SALT, + textEncoder.encode(`${args.callId}:${args.secretId}:${args.version}`), + ); +} diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 11a6ef5..0fccbd6 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -20,6 +20,18 @@ const bytesLike = z.union([ const protocolBytes = z.instanceof(Uint8Array); +function bytesFromProtocol(value: z.infer): Uint8Array { + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value); + + const bin = atob(value); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +const protocolBytesResponse = bytesLike.transform(bytesFromProtocol); + const chatSecretResponse = z.object({ UserId: z.string(), ChatId: z.string(), @@ -38,6 +50,22 @@ const chatSecretRecipient = z.object({ KemCiphertext: protocolBytes, }); +const callSecretEnvelopeResponse = z.object({ + SecretId: z.string(), + VersionNumber: z.number(), + EncryptedSecret: protocolBytesResponse, + KemCiphertext: protocolBytesResponse, + WrappingScheme: z.string(), +}); + +const callSecretEnvelopeRequest = z.object({ + SecretId: z.string(), + VersionNumber: z.number(), + EncryptedSecret: protocolBytes, + KemCiphertext: protocolBytes, + WrappingScheme: z.string(), +}); + export const Message = z.object({ NotEncrypted: z.boolean().optional(), SentBySelf: z.boolean().optional(), @@ -85,7 +113,7 @@ const authPayload = z.object({ .array( z.object({ CallId: z.string(), - CallSecret: z.base64().optional(), + CallSecret: callSecretEnvelopeResponse.optional(), CallMembers: z.array(z.number()), }), ) @@ -262,12 +290,12 @@ export const mtp = { CallInvite: { request: z.object({ CallId: z.string(), - CallSecret: z.base64(), + CallSecret: callSecretEnvelopeRequest, ReceiverId: z.number(), }), response: z.object({ CallId: z.string().optional(), - CallSecret: z.base64().optional(), + CallSecret: callSecretEnvelopeResponse.optional(), SenderId: z.number().optional(), }), },