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
4 changed files with 264 additions and 53 deletions
Showing only changes of commit 27f08e92e5 - Show all commits

(wip): call migration stuff

Alois 2026-07-06 20:02:21 +02:00

View file

@ -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<typeof mtp.CallInvite.response>["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<string, 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 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;
};
@ -103,6 +109,7 @@ type CallStore = {
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
ownCallSecretInvitePending: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
@ -148,11 +155,46 @@ export function getRoom(): Room {
const remoteAudioElements = new Map<string, HTMLMediaElement>();
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<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
function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) {
const existingElement = remoteAudioElements.get(trackSid);
@ -625,28 +667,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,
},
});
}
@ -856,6 +898,7 @@ export async function disconnect() {
watchedStreamParticipantIds: [],
pendingWatchedParticipantIds: [],
activeScreenShareParticipantIds: [],
ownCallSecretInvitePending: false,
callIsFullscreen: false,
lastFocusedParticipantId: null,
});
@ -877,7 +920,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,
) {
@ -889,24 +932,29 @@ 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) {
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 +965,7 @@ export async function joinCall(
return;
}
} else {
const random = crypto.randomUUID();
const random = randomCallSecret();
await getKeyProvider().setKey(random);
await getRoom().setE2EEEnabled(true);
@ -1075,6 +1123,7 @@ export const useCall = create<CallStore>(() => ({
pendingWatchedParticipantIds: [],
activeScreenShareParticipantIds: [],
isEncrypted: false,
ownCallSecretInvitePending: false,
callIsFullscreen: false,
callIsPopout: false,
layoutVersion: 0,
@ -1088,7 +1137,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 +1164,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 +1196,7 @@ export function useInitializeCall() {
insertCall({
CallId: invite.callId,
CallSecret: invite.callSecret,
CallSecret: protocolCallSecret(invite.callSecret),
CallMembers: [invite.senderId],
});
@ -1182,13 +1227,21 @@ export function useInitializeCall() {
const { CallId, CallSecret, SenderId } = message.data as {
CallId: string;
CallSecret: string;
CallSecret: ProtocolCallSecret;
SenderId: number;
};
showCallingScreen(CallId, CallSecret, SenderId);
if (SenderId === Number(await load("user_id"))) {
return;
}
showCallingScreen(
CallId,
normalizeWrappedCallSecret(CallSecret),
SenderId,
);
});
}, [subscribePush, showCallingScreen]);
}, [load, subscribePush, showCallingScreen]);
// get callId from url
useEffect(() => {
@ -1289,6 +1342,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 () => {

View file

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

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);
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({
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(),
}),
},