From 27f08e92e56d369bfdc343df88ed5b9168b7ea6e Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 6 Jul 2026 20:02:21 +0200 Subject: [PATCH] (wip): call migration stuff --- packages/call/src/store.tsx | 168 +++++++++++++++++++++--------- packages/crypto/package.json | 3 +- packages/crypto/src/callSecret.ts | 112 ++++++++++++++++++++ packages/shared/src/data.ts | 34 +++++- 4 files changed, 264 insertions(+), 53 deletions(-) create mode 100644 packages/crypto/src/callSecret.ts diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index e14aea6..e229119 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; }; @@ -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(); +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 +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(() => ({ 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 () => { 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(), }), },