TTP -> MTP, A lot of other stuff #21

Merged
alois merged 81 commits from dev into main 2026-07-21 11:57:33 +03:00
5 changed files with 562 additions and 52 deletions
Showing only changes of commit b4fe5edfbb - Show all commits

(feat): more crypto migration
Some checks failed
/ build-web (push) Failing after 5m13s
/ build-desktop (linux) (push) Failing after 5m16s
/ build-mobile (push) Failing after 7m3s
/ release (push) Has been skipped

Alois 2026-07-06 19:39:23 +02:00

325
BACKEND-CHANGES.md Normal file
View file

@ -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<bool>,
pub anonymous_joining: RwLock<bool>,
pub short_link: RwLock<Option<String>>,
- pub secrets: RwLock<BTreeMap<(u64, u64), String>>,
+ pub secrets: RwLock<BTreeMap<u64, CallSecretEnvelope>>,
+}
+
+#[derive(Clone, Debug)]
+pub struct CallSecretEnvelope {
+ pub secret_id: String,
+ pub version_number: i64,
+ pub encrypted_secret: Vec<u8>,
+ pub kem_ciphertext: Vec<u8>,
+ pub wrapping_scheme: String,
+}
+
+impl CallSecretEnvelope {
+ pub fn from_data_value(value: &DataValue) -> Option<Self> {
+ 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<DataTypeId, DataValue> = 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<DashMap<Uuid, Arc<CallGroup>>> = 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<String>,
+ secret: Option<CallSecretEnvelope>,
) -> 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)
```

View file

@ -4,7 +4,13 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { mtp } from "@tensamin/shared/data"; 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 { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context"; import { useUser } from "@tensamin/user/context";
@ -46,9 +52,19 @@ setLogExtension(
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
type CallView = "preview" | "focused" | "grid"; type CallView = "preview" | "focused" | "grid";
type ProtocolCallSecret = NonNullable<
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
>;
type WrappedCallSecret = {
secretId: string;
versionNumber: number;
encryptedSecret: Uint8Array;
kemCiphertext: Uint8Array;
wrappingScheme: string;
};
type IncomingCallInvite = { type IncomingCallInvite = {
callId: string; callId: string;
callSecret: string; callSecret: WrappedCallSecret;
senderId: number; senderId: number;
}; };
type CurrentCallData = type CurrentCallData =
@ -62,13 +78,6 @@ type SendFn = (
type: string, type: string,
data: Record<string, unknown>, data: Record<string, unknown>,
) => Promise<{ data: unknown }>; ) => Promise<{ data: unknown }>;
type GetSharedSecretFn = (
privateKey: unknown,
ownPublicKey: string,
remotePublicKey: string,
) => Promise<string>;
type DecryptTextFn = (sharedSecret: string, text: string) => Promise<string>;
type EncryptTextFn = (sharedSecret: string, text: string) => Promise<string>;
type LoadFn = (key: string) => Promise<unknown>; type LoadFn = (key: string) => Promise<unknown>;
type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>; type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
type RemoteVideoTrackSelector = Track.Kind | Track.Source; type RemoteVideoTrackSelector = Track.Kind | Track.Source;
@ -76,9 +85,6 @@ type RemoteVideoTrackSelector = Track.Kind | Track.Source;
type Runtime = { type Runtime = {
navigate: NavigateFn; navigate: NavigateFn;
send: SendFn; send: SendFn;
getSharedSecret: GetSharedSecretFn;
decryptText: DecryptTextFn;
encryptText: EncryptTextFn;
load: LoadFn; load: LoadFn;
getUser: GetUserFn; getUser: GetUserFn;
}; };
@ -148,11 +154,46 @@ export function getRoom(): Room {
const remoteAudioElements = new Map<string, HTMLMediaElement>(); const remoteAudioElements = new Map<string, HTMLMediaElement>();
const CALL_SECRET_VERSION = 1;
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320; const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180; const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7; const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000; const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
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 // audio helpers
function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) { function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) {
const existingElement = remoteAudioElements.get(trackSid); 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."); 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 const remotePublicKey = await runtime
.getUser(userId) .getUser(userId)
.then((data) => data.PublicKey); .then((data) => data.PublicKey);
const sharedSecret = await runtime.getSharedSecret( const secretId = deriveCallSecretId(callId);
privateKey, const wrapped = await wrapCallSecret({
ownPublicKey,
remotePublicKey,
);
const encryptedCallSecret = await runtime.encryptText(
sharedSecret,
callSecret, callSecret,
); recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(remotePublicKey),
callId,
secretId,
version: CALL_SECRET_VERSION,
});
await runtime.send("call_invite", { await runtime.send("call_invite", {
ReceiverId: userId, ReceiverId: userId,
CallId: callId, 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. // Prepare encryption and join or create a call with another user.
export async function joinCall( export async function joinCall(
userId: number, userId: number,
callSecret?: string, callSecret?: WrappedCallSecret | ProtocolCallSecret,
existingCallId?: string, existingCallId?: string,
sendInvite = true, sendInvite = true,
) { ) {
@ -896,17 +937,20 @@ export async function joinCall(
if (callSecret) { if (callSecret) {
try { try {
const sharedSecret = await runtime.getSharedSecret( if (!existingCallId) {
await runtime.load("mtp_keyring"), throw new Error("Cannot unwrap call secret without a call id");
await runtime }
.getUser((await runtime.load("user_id")) as number)
.then((res) => res.PublicKey), const wrappedCallSecret = normalizeWrappedCallSecret(callSecret);
await runtime.getUser(userId).then((res) => res.PublicKey), const decryptedSecret = await unwrapCallSecret({
); encryptedSecret: wrappedCallSecret.encryptedSecret,
const decryptedSecret = await runtime.decryptText( kemCiphertext: wrappedCallSecret.kemCiphertext,
sharedSecret, keyring: String(await runtime.load("mtp_keyring")),
callSecret, callId: existingCallId,
); secretId: wrappedCallSecret.secretId,
version: wrappedCallSecret.versionNumber,
wrappingScheme: wrappedCallSecret.wrappingScheme,
});
await getKeyProvider().setKey(decryptedSecret); await getKeyProvider().setKey(decryptedSecret);
await getRoom().setE2EEEnabled(true); await getRoom().setE2EEEnabled(true);
@ -917,7 +961,7 @@ export async function joinCall(
return; return;
} }
} else { } else {
const random = crypto.randomUUID(); const random = randomCallSecret();
await getKeyProvider().setKey(random); await getKeyProvider().setKey(random);
await getRoom().setE2EEEnabled(true); await getRoom().setE2EEEnabled(true);
@ -1088,7 +1132,6 @@ export function useInitializeCall() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { send, subscribePush } = useMTP(); const { send, subscribePush } = useMTP();
const { getSharedSecret, decryptText, encryptText } = useCrypto();
const { load } = useStorage(); const { load } = useStorage();
const { insertCall } = useSession(); const { insertCall } = useSession();
const { get } = useUser(); const { get } = useUser();
@ -1116,16 +1159,13 @@ export function useInitializeCall() {
setCallRuntime({ setCallRuntime({
navigate, navigate,
send: send as SendFn, send: send as SendFn,
getSharedSecret: getSharedSecret as GetSharedSecretFn,
decryptText: decryptText as DecryptTextFn,
encryptText: encryptText as EncryptTextFn,
load: load as LoadFn, load: load as LoadFn,
getUser: get as GetUserFn, getUser: get as GetUserFn,
}); });
}, [decryptText, encryptText, get, getSharedSecret, load, navigate, send]); }, [get, load, navigate, send]);
const showCallingScreen = useCallback( const showCallingScreen = useCallback(
(callId: string, callSecret: string, senderId: number) => { (callId: string, callSecret: WrappedCallSecret, senderId: number) => {
useCall.setState({ useCall.setState({
incomingCallInvite: { callId, callSecret, senderId }, incomingCallInvite: { callId, callSecret, senderId },
}); });
@ -1151,7 +1191,7 @@ export function useInitializeCall() {
insertCall({ insertCall({
CallId: invite.callId, CallId: invite.callId,
CallSecret: invite.callSecret, CallSecret: protocolCallSecret(invite.callSecret),
CallMembers: [invite.senderId], CallMembers: [invite.senderId],
}); });
@ -1182,11 +1222,15 @@ export function useInitializeCall() {
const { CallId, CallSecret, SenderId } = message.data as { const { CallId, CallSecret, SenderId } = message.data as {
CallId: string; CallId: string;
CallSecret: string; CallSecret: ProtocolCallSecret;
SenderId: number; SenderId: number;
}; };
showCallingScreen(CallId, CallSecret, SenderId); showCallingScreen(
CallId,
normalizeWrappedCallSecret(CallSecret),
SenderId,
);
}); });
}, [subscribePush, showCallingScreen]); }, [subscribePush, showCallingScreen]);

View file

@ -5,7 +5,8 @@
"type": "module", "type": "module",
"exports": { "exports": {
"./context": "./src/context.tsx", "./context": "./src/context.tsx",
"./chatSecret": "./src/chatSecret.ts" "./chatSecret": "./src/chatSecret.ts",
"./callSecret": "./src/callSecret.ts"
}, },
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",

View file

@ -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<string> {
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}`),
);
}

View file

@ -20,6 +20,18 @@ const bytesLike = z.union([
const protocolBytes = z.instanceof(Uint8Array); const protocolBytes = z.instanceof(Uint8Array);
function bytesFromProtocol(value: z.infer<typeof bytesLike>): 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({ const chatSecretResponse = z.object({
UserId: z.string(), UserId: z.string(),
ChatId: z.string(), ChatId: z.string(),
@ -38,6 +50,22 @@ const chatSecretRecipient = z.object({
KemCiphertext: protocolBytes, 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({ export const Message = z.object({
NotEncrypted: z.boolean().optional(), NotEncrypted: z.boolean().optional(),
SentBySelf: z.boolean().optional(), SentBySelf: z.boolean().optional(),
@ -85,7 +113,7 @@ const authPayload = z.object({
.array( .array(
z.object({ z.object({
CallId: z.string(), CallId: z.string(),
CallSecret: z.base64().optional(), CallSecret: callSecretEnvelopeResponse.optional(),
CallMembers: z.array(z.number()), CallMembers: z.array(z.number()),
}), }),
) )
@ -262,12 +290,12 @@ export const mtp = {
CallInvite: { CallInvite: {
request: z.object({ request: z.object({
CallId: z.string(), CallId: z.string(),
CallSecret: z.base64(), CallSecret: callSecretEnvelopeRequest,
ReceiverId: z.number(), ReceiverId: z.number(),
}), }),
response: z.object({ response: z.object({
CallId: z.string().optional(), CallId: z.string().optional(),
CallSecret: z.base64().optional(), CallSecret: callSecretEnvelopeResponse.optional(),
SenderId: z.number().optional(), SenderId: z.number().optional(),
}), }),
}, },