diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 892f911..24f8add 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -15,6 +15,7 @@ import { useChat } from "../context"; import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; import { cn, useIsMobile } from "@tensamin/ui"; +import { encryptChatText } from "@tensamin/crypto/chatSecret"; import { useSession } from "@tensamin/storage/session"; import GifPicker from "./gifPicker"; @@ -28,8 +29,8 @@ export default function InputComponent({ }) { const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); - const { sendEncrypted } = useMTP(); - const { addLiveMessage, userId, inputBoxRef } = useChat(); + const { send } = useMTP(); + const { addLiveMessage, chatSecret, userId, inputBoxRef } = useChat(); const { load, save } = useStorage(); const { moveUserIdToTop } = useSession(); const gifPopoverRef = React.useRef(null); @@ -64,6 +65,11 @@ export default function InputComponent({ return; } + if (!chatSecret) { + toast("error", "Still getting chat secret..."); + return; + } + log(3, "chat", "purple", "Message send init, adding live message ..."); const reference = addLiveMessage({ @@ -74,38 +80,33 @@ export default function InputComponent({ MessageState: "awaiting", }); - log(3, "chat", "purple", "Live message added, sending encrypted frame..."); + log(3, "chat", "purple", "Live message added, encrypting..."); - void load("user_id") - .then((ownUserId) => - sendEncrypted( - "MessageSend", - { - Content: currentValue, - ReceiverId: userId, - SendTime: time, - }, - { - senderUserId: String(ownUserId), - recipientUserId: String(userId), - }, - ), - ) - .catch((e) => { - log(0, "Chat", "red", "Failed to send encrypted message", e, { - ReceiverId: userId, - SendTime: time, - }); + const encryptedContent = await encryptChatText(chatSecret, currentValue).catch( + (err) => { + toast("error", "Failed to encrypt message", String(err)); reference.setFailed(true); - toast( - "error", - e instanceof Error && e.message.includes("public key") - ? "Recipient has no encryption public key available" - : "Failed to send encrypted message", - ); - }); + }, + ); - log(3, "chat", "purple", "Encrypted message send queued"); + if (!encryptedContent) return; + + log(3, "chat", "purple", "Content encrypted, sending message..."); + + send("MessageSend", { + Content: encryptedContent, + ReceiverId: userId, + SendTime: time, + }).catch((e) => { + log(0, "Chat", "red", "Failed to send message", e, { + ReceiverId: userId, + SendTime: time, + }); + reference.setFailed(true); + toast("error", "Failed to send message"); + }); + + log(3, "chat", "purple", "Message sent"); moveUserIdToTop(userId); diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index c12d768..94df598 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -12,14 +12,39 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRouterState } from "@tanstack/react-router"; import type { InfiniteData } from "@tanstack/react-query"; import type { LiveMessage, RawMessage, RawMessages } from "./values"; +import { + deriveChatId, + deriveChatSecretId, + decryptChatText, + encryptChatText, + kemPublicKeyFromPublicKeyBundle, + ownKemPublicKeyFromKeyring, + randomChatSecret, + unwrapChatSecret, + wrapChatSecret, +} from "@tensamin/crypto/chatSecret"; import { useStorage } from "@tensamin/storage/context"; import { useMTP } from "@tensamin/mtp"; import { log } from "@tensamin/shared/log"; import { useSession } from "@tensamin/storage/session"; +import { useUser } from "@tensamin/user/context"; export const context = createContext(undefined); const queryClient = new QueryClient(); +const CHAT_SECRET_VERSION = 1; + +function bytesFromProtocol(value: unknown): Uint8Array { + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value); + if (typeof value === "string") { + 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; + } + throw new Error("expected protocol bytes"); +} function updateMessageStateBySendTime< T extends { SendTime: number; MessageState: RawMessage["MessageState"] }, @@ -50,14 +75,21 @@ function updateMessageStateBySendTime< export default function Provider({ children }: { children: ReactNode }) { const { load } = useStorage(); - const { send, subscribePush, subscribeEncrypted, decryptEncryptedRecord } = - useMTP(); + const { send, subscribePush } = useMTP(); + const { get: getUser } = useUser(); const { moveUserIdToTop } = useSession(); - const [error] = useState(""); - const [errorDescription] = useState(""); + const [error, setError] = useState(""); + const [errorDescription, setErrorDescription] = useState(""); const [liveMessagesState, setLiveMessagesState] = useState([]); + const [currentChatSecretState, setCurrentChatSecretState] = useState<{ + userId: number; + value: Uint8Array | null; + }>({ + userId: 0, + value: null, + }); const inputBoxRef = useRef(null); @@ -71,40 +103,132 @@ export default function Provider({ children }: { children: ReactNode }) { return Number(rawId ?? 0); }, [locationSearch]); + const currentChatSecret = useMemo(() => { + if (currentChatSecretState.userId !== userIdValue) { + return null; + } + + return currentChatSecretState.value; + }, [currentChatSecretState, userIdValue]); + + useEffect(() => { + if (!userIdValue) return; + + let active = true; + + void (async () => { + try { + setError(""); + setErrorDescription(""); + + const ownUserId = Number(await load("user_id")); + const keyring = await load("mtp_keyring"); + const chatId = deriveChatId(ownUserId, userIdValue); + const secretId = deriveChatSecretId(chatId); + + const existing = await send("GetChatSecret", { + UserId: String(ownUserId), + ChatId: chatId, + SecretId: secretId, + }); + + if (!existing.type.startsWith("Error")) { + const data = existing.data as Record; + const secret = await unwrapChatSecret({ + encryptedSecret: bytesFromProtocol(data.EncryptedSecret), + kemCiphertext: bytesFromProtocol(data.KemCiphertext), + keyring, + chatId: String(data.ChatId), + secretId: String(data.SecretId), + version: Number(data.VersionNumber), + wrappingScheme: String(data.WrappingScheme), + }); + + if (active) { + setCurrentChatSecretState({ userId: userIdValue, value: secret }); + } + return; + } + + const rawSecret = randomChatSecret(); + const ownWrapped = await wrapChatSecret({ + chatSecret: rawSecret, + recipientKemPublicKey: ownKemPublicKeyFromKeyring(keyring), + chatId, + secretId, + version: CHAT_SECRET_VERSION, + }); + + await send("SetChatSecret", { + UserId: String(ownUserId), + ChatId: chatId, + SecretId: secretId, + VersionNumber: CHAT_SECRET_VERSION, + EncryptedSecret: Array.from(ownWrapped.encryptedSecret), + KemCiphertext: Array.from(ownWrapped.kemCiphertext), + WrappingScheme: ownWrapped.wrappingScheme, + CreatedAt: Date.now(), + }); + + const peerUser = await getUser(userIdValue); + const peerWrapped = await wrapChatSecret({ + chatSecret: rawSecret, + recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle( + peerUser.PublicKey, + ), + chatId, + secretId, + version: CHAT_SECRET_VERSION, + }); + + await send("ChatSecretForward", { + ChatId: chatId, + SenderUserId: String(ownUserId), + RecipientUserId: String(userIdValue), + SecretId: secretId, + VersionNumber: CHAT_SECRET_VERSION, + EncryptedSecret: Array.from(peerWrapped.encryptedSecret), + KemCiphertext: Array.from(peerWrapped.kemCiphertext), + WrappingScheme: peerWrapped.wrappingScheme, + CreatedAt: Date.now(), + }); + + if (active) { + setCurrentChatSecretState({ userId: userIdValue, value: rawSecret }); + } + } catch (err) { + log(1, "chat", "red", "Failed to initialize chat secret", err); + if (active) { + setError(err instanceof Error ? err.name : "Unknown Error"); + setErrorDescription(err instanceof Error ? err.message : String(err)); + setCurrentChatSecretState({ userId: userIdValue, value: null }); + } + } + })(); + + return () => { + active = false; + }; + }, [getUser, load, send, userIdValue]); + const getMessages = useCallback( async (amount: number, offset: number) => { - const response = await send("EncryptedMessagesGet", { - Limit: amount, - SenderUserId: String(userIdValue), - }); - - if (response.type.startsWith("error")) { - throw new Error(response.type); + if (!currentChatSecret) { + return []; } - const encryptedMessages = (response.data.Messages ?? []) as Record< - string, - unknown - >[]; - const decrypted = await Promise.all( - encryptedMessages.slice(offset, offset + amount).map(async (record) => { - const inner = await decryptEncryptedRecord(record); - const data = inner.data as Record; - return { - NotEncrypted: false, - SendTime: Number( - data.SendTime ?? record.CreatedAt ?? record.createdAt, - ), - Content: String(data.Content ?? ""), - SentBySelf: - String(record.SenderUserId ?? record.senderUserId ?? "") === - String(await load("user_id")), - MessageState: "received" as RawMessage["MessageState"], - }; - }), - ); + const messages = await send("MessagesGet", { + Amount: amount, + Offset: offset, + UserId: userIdValue, + }); - const sorted = [...decrypted].sort((a, b) => a.SendTime - b.SendTime); + if (messages.type.startsWith("error")) { + throw new Error(messages.type); + } + + const rawMessages = messages.data.Messages; + const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime); if (sorted.length > 0) { const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime)); @@ -118,9 +242,20 @@ export default function Provider({ children }: { children: ReactNode }) { }); } - return sorted; + return await Promise.all( + sorted.map(async (message) => { + try { + return { + ...message, + Content: await decryptChatText(currentChatSecret, message.Content), + }; + } catch { + return message; + } + }), + ); }, - [send, userIdValue, decryptEncryptedRecord, load], + [currentChatSecret, send, userIdValue], ); const addLiveMessage = useCallback( @@ -161,33 +296,75 @@ export default function Provider({ children }: { children: ReactNode }) { setLiveMessagesState([]); }, []); - useEffect(() => { - return subscribeEncrypted("MessageSend", (data) => { - const rawData = data as unknown as { - Content?: unknown; - ReceiverId?: unknown; - SendTime?: unknown; - }; - const sendTime = Number(rawData.SendTime); - - if (!Number.isFinite(sendTime) || typeof rawData.Content !== "string") { - log(3, "chat", "yellow", "Ignoring invalid encrypted live message"); - return; - } - - addLiveMessage({ - NotEncrypted: false, - SendTime: sendTime, - Content: rawData.Content, - SentBySelf: false, - MessageState: "received", - }); - }); - }, [addLiveMessage, subscribeEncrypted]); - // Get live updates for message states useEffect(() => { return subscribePush((message) => { + if (message.type === "ChatSecretForward") { + const data = message.data as Record; + void (async () => { + const ownUserId = Number(await load("user_id")); + if (String(data.RecipientUserId) !== String(ownUserId)) return; + + const keyring = await load("mtp_keyring"); + const chatId = String(data.ChatId); + const secretId = String(data.SecretId); + const secret = await unwrapChatSecret({ + encryptedSecret: bytesFromProtocol(data.EncryptedSecret), + kemCiphertext: bytesFromProtocol(data.KemCiphertext), + keyring, + chatId, + secretId, + version: Number(data.VersionNumber), + wrappingScheme: String(data.WrappingScheme), + }); + const ownWrapped = await wrapChatSecret({ + chatSecret: secret, + recipientKemPublicKey: ownKemPublicKeyFromKeyring(keyring), + chatId, + secretId, + version: Number(data.VersionNumber), + }); + + await send("SetChatSecret", { + UserId: String(ownUserId), + ChatId: chatId, + SecretId: secretId, + VersionNumber: Number(data.VersionNumber), + EncryptedSecret: Array.from(ownWrapped.encryptedSecret), + KemCiphertext: Array.from(ownWrapped.kemCiphertext), + WrappingScheme: ownWrapped.wrappingScheme, + CreatedAt: Date.now(), + }); + + if (deriveChatId(ownUserId, userIdValue) === chatId) { + setCurrentChatSecretState({ userId: userIdValue, value: secret }); + } + })().catch((err) => { + log(1, "chat", "red", "Failed to accept chat secret", err); + }); + return; + } + + if (message.type === "MessageLive") { + const data = message.data as { + Message?: RawMessage; + SenderId?: number; + }; + if (!data.Message || !currentChatSecret) return; + + void decryptChatText(currentChatSecret, data.Message.Content) + .catch(() => data.Message?.Content ?? "") + .then((content) => { + if (!data.Message) return; + addLiveMessage({ + ...data.Message, + Content: content, + SentBySelf: false, + }); + }); + return; + } + if (message.type !== "MessageState") { return; } @@ -243,6 +420,7 @@ export default function Provider({ children }: { children: ReactNode }) { const queryKey = [ "chat-messages", String(userIdValue), + currentChatSecret !== null, ] as const; queryClient.setQueryData>( queryKey, @@ -278,7 +456,7 @@ export default function Provider({ children }: { children: ReactNode }) { }, ); }); - }, [subscribePush, userIdValue]); + }, [addLiveMessage, currentChatSecret, load, send, subscribePush, userIdValue]); return ( @@ -288,7 +466,7 @@ export default function Provider({ children }: { children: ReactNode }) { liveMessages: () => liveMessagesState, addLiveMessage, clearLiveMessages, - sharedSecret: "", + chatSecret: currentChatSecret, userId: userIdValue, inputBoxRef, error, @@ -308,7 +486,7 @@ type contextType = { setFailed: (failed: boolean) => void; }; clearLiveMessages: () => void; - sharedSecret: string; + chatSecret: Uint8Array | null; userId: number; inputBoxRef: React.RefObject; error: string; diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 663ea99..bef76da 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -79,6 +79,7 @@ export default function Screen() { liveMessages, clearLiveMessages, userId, + chatSecret, error, errorDescription, } = useChat(); @@ -105,12 +106,13 @@ export default function Screen() { const [value, setValue] = React.useState(""); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; + const hasChatSecret = chatSecret !== null; const messagesQuery = useInfiniteQuery({ - queryKey: ["chat-messages", String(userId)], + queryKey: ["chat-messages", String(userId), hasChatSecret], initialPageParam: 0, queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)), - enabled: hasValidChatUser, + enabled: hasValidChatUser && hasChatSecret, getNextPageParam: (lastPage, allPages) => { if (lastPage.length < PAGE_SIZE) { return undefined; diff --git a/packages/crypto/package.json b/packages/crypto/package.json index b4a2f30..636f300 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": { "./context": "./src/context.tsx", - "./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts" + "./chatSecret": "./src/chatSecret.ts" }, "scripts": { "format": "pnpm exec prettier --write .", diff --git a/packages/crypto/src/chatSecret.ts b/packages/crypto/src/chatSecret.ts new file mode 100644 index 0000000..3e15300 --- /dev/null +++ b/packages/crypto/src/chatSecret.ts @@ -0,0 +1,143 @@ +import { crypto } from "mtp"; + +const textEncoder = new TextEncoder(); + +export const CHAT_SECRET_WRAPPING_SCHEME = + "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"; + +const CHAT_SECRET_SALT = textEncoder.encode("tensamin-chat-secret-v1"); +const CHAT_MESSAGE_SALT = textEncoder.encode("tensamin-chat-message-v1"); + +export function deriveChatId(ownUserId: number, peerUserId: number): string { + const ids = [ownUserId, peerUserId].sort((a, b) => a - b); + return `${ids[0]}:${ids[1]}`; +} + +export function deriveChatSecretId(chatId: string): string { + return `chat:${chatId}:main`; +} + +export function randomChatSecret(): Uint8Array { + return globalThis.crypto.getRandomValues(new Uint8Array(32)); +} + +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 wrapChatSecret(args: { + chatSecret: Uint8Array; + recipientKemPublicKey: Uint8Array; + chatId: 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, + chatId: args.chatId, + secretId: args.secretId, + version: args.version, + }); + + try { + return { + encryptedSecret: await crypto.encrypt(wrappingKey, args.chatSecret), + kemCiphertext: enc.ciphertext, + wrappingScheme: CHAT_SECRET_WRAPPING_SCHEME, + }; + } finally { + wrappingKey.fill(0); + } + } finally { + enc.shared_secret.fill(0); + } +} + +export async function unwrapChatSecret(args: { + encryptedSecret: Uint8Array; + kemCiphertext: Uint8Array; + keyring: string; + chatId: string; + secretId: string; + version: number; + wrappingScheme: string; +}): Promise { + if (args.wrappingScheme !== CHAT_SECRET_WRAPPING_SCHEME) { + throw new Error(`Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`); + } + + const ownKeys = crypto.keyringToKeys(args.keyring); + const sharedSecret = crypto.decapsulate(ownKeys.kemSecretKey, args.kemCiphertext); + + try { + const wrappingKey = deriveWrappingKey({ + sharedSecret, + chatId: args.chatId, + secretId: args.secretId, + version: args.version, + }); + + try { + return await crypto.decrypt(wrappingKey, args.encryptedSecret); + } finally { + wrappingKey.fill(0); + } + } finally { + sharedSecret.fill(0); + } +} + +export async function encryptChatText( + chatSecret: Uint8Array, + plaintext: string, +): Promise { + const key = deriveMessageKey(chatSecret); + try { + return await crypto.encryptText(key, plaintext); + } finally { + key.fill(0); + } +} + +export async function decryptChatText( + chatSecret: Uint8Array, + ciphertext: string, +): Promise { + const key = deriveMessageKey(chatSecret); + try { + return await crypto.decryptText(key, ciphertext); + } finally { + key.fill(0); + } +} + +function deriveWrappingKey(args: { + sharedSecret: Uint8Array; + chatId: string; + secretId: string; + version: number; +}): Uint8Array { + return crypto.deriveEncryptionKey( + args.sharedSecret, + CHAT_SECRET_SALT, + textEncoder.encode(`${args.chatId}:${args.secretId}:${args.version}`), + ); +} + +function deriveMessageKey(chatSecret: Uint8Array): Uint8Array { + return crypto.deriveEncryptionKey( + chatSecret, + CHAT_MESSAGE_SALT, + textEncoder.encode("message-content"), + ); +} diff --git a/packages/crypto/src/context.tsx b/packages/crypto/src/context.tsx index 26120b7..0fef2b3 100644 --- a/packages/crypto/src/context.tsx +++ b/packages/crypto/src/context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import { crypto } from "mtp"; +import { base64ToBytes, bytesToBase64, crypto } from "mtp"; type CryptoContextType = { decrypt: ( @@ -50,15 +50,64 @@ function ownedBytes(bytes: Uint8Array): Uint8Array { return out; } +function secretKeyFromString(secret: string): Uint8Array { + return crypto.deriveEncryptionKey( + base64ToBytes(secret), + new Uint8Array(0), + new TextEncoder().encode("tensamin:shared-secret-text"), + ); +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + const len = Math.min(a.byteLength, b.byteLength); + + for (let i = 0; i < len; i++) { + const diff = a[i] - b[i]; + if (diff !== 0) return diff; + } + + return a.byteLength - b.byteLength; +} + +async function getSharedSecret( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, +): Promise { + crypto.keyringToKeys(ownPrivateKey); + + const ownKeys = crypto.publicKeyBundleToKeys(ownPublicKey); + const otherKeys = crypto.publicKeyBundleToKeys(otherPublicKey); + const publicKeys = [ownKeys.kemPublicKey, otherKeys.kemPublicKey].sort( + compareBytes, + ); + const input = new Uint8Array( + publicKeys[0].byteLength + publicKeys[1].byteLength, + ); + + input.set(publicKeys[0]); + input.set(publicKeys[1], publicKeys[0].byteLength); + + return bytesToBase64( + crypto.deriveEncryptionKey( + input, + new Uint8Array(0), + new TextEncoder().encode("tensamin:legacy-shared-secret"), + ), + ); +} + export default function Provider(props: { children: React.ReactNode }) { const actions = createCryptoActions(() => ({ decrypt: async (secret, input) => - ownedBytes(await crypto.decrypt(secret, input)), - decryptText: crypto.decryptText, + ownedBytes(await crypto.decrypt(secretKeyFromString(secret), input)), + decryptText: (secret, ciphertext) => + crypto.decryptText(secretKeyFromString(secret), ciphertext), encrypt: async (secret, input) => - ownedBytes(await crypto.encrypt(secret, input)), - encryptText: crypto.encryptText, - getSharedSecret: crypto.getSharedSecret, + ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)), + encryptText: (secret, plaintext) => + crypto.encryptText(secretKeyFromString(secret), plaintext), + getSharedSecret, })); return {props.children}; diff --git a/packages/crypto/src/encryptedDeviceSecret.ts b/packages/crypto/src/encryptedDeviceSecret.ts deleted file mode 100644 index 26dd5a0..0000000 --- a/packages/crypto/src/encryptedDeviceSecret.ts +++ /dev/null @@ -1,134 +0,0 @@ -const textEncoder = new TextEncoder(); - -export const ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME = - "webcrypto-aes-gcm-hkdf-sha256-v1"; - -export async function wrapDeviceSecret(args: { - rawSecret: Uint8Array; - wrappingSecret: Uint8Array | string; - userId: string; - deviceId: string; - secretId: string; - version: number; -}): Promise<{ - encryptedSecret: Uint8Array; - wrappingScheme: string; - wrappingPublicKeyId?: string; -}> { - if (!args.rawSecret.length) { - throw new Error("rawSecret must not be empty"); - } - - const key = await deriveWrappingKey(args); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const aad = metadataAad(args); - - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - { name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) }, - key, - ownedBytes(args.rawSecret), - ), - ); - - const encryptedSecret = new Uint8Array(iv.length + ciphertext.length); - encryptedSecret.set(iv, 0); - encryptedSecret.set(ciphertext, iv.length); - - return { - encryptedSecret, - wrappingScheme: ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME, - }; -} - -export async function unwrapDeviceSecret(args: { - encryptedSecret: Uint8Array; - wrappingSecret: Uint8Array | string; - userId: string; - deviceId: string; - secretId: string; - version: number; - wrappingScheme: string; -}): Promise { - if (args.wrappingScheme !== ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME) { - throw new Error( - `Unsupported encrypted device secret wrapping scheme: ${args.wrappingScheme}`, - ); - } - if (args.encryptedSecret.length <= 12) { - throw new Error("encryptedSecret is too short"); - } - - const key = await deriveWrappingKey(args); - const iv = args.encryptedSecret.slice(0, 12); - const ciphertext = args.encryptedSecret.slice(12); - const aad = metadataAad(args); - - return new Uint8Array( - await crypto.subtle.decrypt( - { name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) }, - key, - ownedBytes(ciphertext), - ), - ); -} - -async function deriveWrappingKey(args: { - wrappingSecret: Uint8Array | string; - userId: string; - deviceId: string; - secretId: string; - version: number; -}): Promise { - const wrappingSecret = - typeof args.wrappingSecret === "string" - ? textEncoder.encode(args.wrappingSecret) - : args.wrappingSecret; - - if (wrappingSecret.length < 16) { - throw new Error("No secure wrapping key available"); - } - - const baseKey = await crypto.subtle.importKey( - "raw", - ownedBytes(wrappingSecret), - "HKDF", - false, - ["deriveKey"], - ); - - return crypto.subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt: ownedBytes(textEncoder.encode("tensamin-e2ee-device-secret-v1")), - info: ownedBytes(metadataAad(args)), - }, - baseKey, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); -} - -function ownedBytes(bytes: Uint8Array): Uint8Array { - const out = new Uint8Array(bytes.byteLength); - out.set(bytes); - return out; -} - -function metadataAad(args: { - userId: string; - deviceId: string; - secretId: string; - version: number; -}): Uint8Array { - return textEncoder.encode( - JSON.stringify({ - userId: args.userId, - deviceId: args.deviceId, - secretId: args.secretId, - version: args.version, - }), - ); -} diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index e116d40..9ca1016 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -45,249 +45,6 @@ function base64ToUint8Array(b64: string) { return out; } -function bytesFromProtocol(value: unknown): Uint8Array { - if (value instanceof Uint8Array) return value; - if (Array.isArray(value)) return new Uint8Array(value); - if (typeof value === "string") return base64ToUint8Array(value); - throw new Error("expected protocol bytes"); -} - -type ParsedFrameLike = { - id?: number; - type: string; - data: unknown; -}; - -interface MTPSessionState { - version: 1; - conversationId: string; - ownClientId: bigint; - peerClientId: bigint; - peerPublicKey: Uint8Array; - sendChainKey: Uint8Array; - recvChainKey: Uint8Array; - sendCount: number; - recvCount: number; - createdAt: number; - updatedAt: number; -} - -interface MTPSessionStorage { - getSession(conversationId: string): Promise; - setSession(state: MTPSessionState): Promise; - deleteSession(conversationId: string): Promise; -} - -interface EncryptedDeviceSecretRecord { - userId: string; - deviceId: string; - secretId: string; - version: number; - encryptedSecret: Uint8Array; - wrappingPublicKeyId?: string; - wrappingScheme: string; - createdAt: number; - updatedAt: number; -} - -interface MTPEncryptedDeviceSecretProvider { - setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise; - getEncryptedDeviceSecret(query: { - userId: string; - deviceId?: string; - secretId?: string; - }): Promise; -} - -function serializeSessionState( - state: MTPSessionState, -): Record { - return { - ...state, - ownClientId: state.ownClientId.toString(), - peerClientId: state.peerClientId.toString(), - peerPublicKey: Array.from(state.peerPublicKey), - sendChainKey: Array.from(state.sendChainKey), - recvChainKey: Array.from(state.recvChainKey), - }; -} - -function deserializeSessionState(value: unknown): MTPSessionState | null { - if (!value || typeof value !== "object") return null; - const raw = value as Record; - if (typeof raw.conversationId !== "string") return null; - return { - version: 1, - conversationId: raw.conversationId, - ownClientId: BigInt(String(raw.ownClientId)), - peerClientId: BigInt(String(raw.peerClientId)), - peerPublicKey: new Uint8Array(raw.peerPublicKey as number[]), - sendChainKey: new Uint8Array(raw.sendChainKey as number[]), - recvChainKey: new Uint8Array(raw.recvChainKey as number[]), - sendCount: Number(raw.sendCount), - recvCount: Number(raw.recvCount), - createdAt: Number(raw.createdAt), - updatedAt: Number(raw.updatedAt), - }; -} - -class LocalMTPSessionStorage implements MTPSessionStorage { - private readonly load: ( - key: K, - ) => Promise | undefined>; - - private readonly save: ( - key: K, - value: Record, - ) => Promise; - - constructor( - load: ( - key: K, - ) => Promise | undefined>, - save: ( - key: K, - value: Record, - ) => Promise, - ) { - this.load = load; - this.save = save; - } - - async getSession(conversationId: string): Promise { - const sessions = (await this.load("e2ee_sessions")) ?? {}; - return deserializeSessionState(sessions[conversationId]); - } - - async setSession(state: MTPSessionState): Promise { - const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) }; - sessions[state.conversationId] = serializeSessionState(state); - await this.save("e2ee_sessions", sessions); - } - - async deleteSession(conversationId: string): Promise { - const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) }; - delete sessions[conversationId]; - await this.save("e2ee_sessions", sessions); - } -} - -function frameField( - frame: ParsedFrameLike, - pascal: string, - camel: string, -): T { - const data = frame.data as Record; - return (data[pascal] ?? data[camel]) as T; -} - -type ConnectedMTPClient = Awaited> & { - request( - type: string, - data: Record, - options?: { responseType?: string }, - ): Promise; - sendEncrypted( - type: string, - data: Record, - options: { - recipientClientId?: bigint | number | string; - recipientPublicKey?: string | Uint8Array | number[]; - senderUserId?: string; - recipientUserId?: string; - recipientDeviceId?: string; - }, - ): Promise; - subscribeEncrypted( - type: string, - handler: (data: unknown, meta: ParsedFrameLike) => void, - ): () => void; - decryptEncryptedRecord( - frameData: Record, - ): Promise; -}; - -class NetworkEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider { - #client: ConnectedMTPClient | null = null; - - attach(client: ConnectedMTPClient): void { - this.#client = client; - } - - async setEncryptedDeviceSecret( - record: EncryptedDeviceSecretRecord, - ): Promise { - if (!this.#client) { - throw new Error("encrypted device secret provider is not attached"); - } - if (!record.encryptedSecret?.length || !record.wrappingScheme) { - throw new Error( - "encrypted device secret record is missing ciphertext metadata", - ); - } - - await this.#client.request("SetEncryptedDeviceSecret", { - UserId: record.userId, - DeviceId: record.deviceId, - SecretId: record.secretId, - VersionNumber: record.version, - EncryptedSecret: record.encryptedSecret, - WrappingPublicKeyId: record.wrappingPublicKeyId, - WrappingScheme: record.wrappingScheme, - CreatedAt: record.createdAt, - }); - } - - async getEncryptedDeviceSecret(query: { - userId: string; - deviceId?: string; - secretId?: string; - }): Promise { - if (!this.#client) { - throw new Error("encrypted device secret provider is not attached"); - } - - try { - const response = await this.#client.request( - "GetEncryptedDeviceSecret", - { - UserId: query.userId, - DeviceId: query.deviceId, - SecretId: query.secretId, - }, - { responseType: "EncryptedDeviceSecretResponse" }, - ); - - if (response.type === "ErrorNotFound") return null; - - return { - userId: frameField(response, "UserId", "userId"), - deviceId: frameField(response, "DeviceId", "deviceId"), - secretId: frameField(response, "SecretId", "secretId"), - version: Number(frameField(response, "VersionNumber", "versionNumber")), - encryptedSecret: bytesFromProtocol( - frameField(response, "EncryptedSecret", "encryptedSecret"), - ), - wrappingPublicKeyId: frameField( - response, - "WrappingPublicKeyId", - "wrappingPublicKeyId", - ), - wrappingScheme: frameField( - response, - "WrappingScheme", - "wrappingScheme", - ), - createdAt: Number(frameField(response, "CreatedAt", "createdAt")), - updatedAt: Number(frameField(response, "UpdatedAt", "updatedAt")), - }; - } catch (error) { - if (String(error).includes("ErrorNotFound")) return null; - throw error; - } - } -} - export type ProtocolMessage< T extends keyof Schemas & string = keyof Schemas & string, > = { @@ -302,38 +59,15 @@ export type BoundSendFn = ( options?: { id?: number }, ) => Promise>; -export type BoundSendEncryptedFn = ( - type: T, - data: z.infer, - options: { - recipientClientId?: bigint | number | string; - recipientPublicKey?: string | Uint8Array | number[]; - senderUserId?: string; - recipientUserId?: string; - recipientDeviceId?: string; - }, -) => Promise; - export type PushHandler = (message: ProtocolMessage) => void; type ContextType = { send: BoundSendFn; - sendEncrypted: BoundSendEncryptedFn; subscribe: ( type: T, handler: (message: ProtocolMessage) => void, ) => () => void; subscribePush: (handler: PushHandler) => () => void; - subscribeEncrypted: ( - type: T, - handler: ( - data: z.infer, - meta: ProtocolMessage, - ) => void, - ) => () => void; - decryptEncryptedRecord: ( - frameData: Record, - ) => Promise; readyState: number; ownPing: number; iotaPing: number; @@ -400,7 +134,7 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean; }) { - const { load, save } = useStorage(); + const { load } = useStorage(); const [readyState, setReadyState] = useState( ConnectionState.Disconnected, @@ -415,7 +149,9 @@ export function Provider(props: { const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); - const clientRef = useRef(null); + const clientRef = useRef> | null>( + null, + ); const connected = readyState === ConnectionState.Connected; @@ -444,57 +180,6 @@ export function Provider(props: { [], ); - const sendEncrypted: BoundSendEncryptedFn = useMemo( - () => async (type, data, options) => { - const client = clientRef.current; - - if (!client) { - throw new Error("mtp is not connected"); - } - - let recipientClientId = options.recipientClientId; - let recipientPublicKey = options.recipientPublicKey; - if (recipientClientId == null) { - if (!options.recipientUserId) { - throw new Error("recipientClientId or recipientUserId is required"); - } - - const recipientUserId = Number(options.recipientUserId); - if (!Number.isSafeInteger(recipientUserId) || recipientUserId <= 0) { - throw new Error("recipientUserId must be a valid user id"); - } - - const response = await client.request( - "GetUserData", - { UserId: recipientUserId }, - { responseType: "GetUserData" }, - ); - if (response.type === "ErrorNotFound") { - throw new Error("Recipient user data not found"); - } - - recipientPublicKey = frameField( - response, - "PublicKey", - "publicKey", - ); - if (!recipientPublicKey) { - throw new Error("Recipient has no encryption public key available"); - } - recipientClientId = BigInt(recipientUserId); - } - - await client.sendEncrypted(type, data as Record, { - recipientClientId, - recipientPublicKey, - senderUserId: options.senderUserId, - recipientUserId: options.recipientUserId, - recipientDeviceId: options.recipientDeviceId, - }); - }, - [], - ); - const subscribe = useCallback((type, handler) => { const client = clientRef.current; if (!client) { @@ -506,34 +191,6 @@ export function Provider(props: { }); }, []); - const subscribeEncrypted = useCallback( - (type, handler) => { - const client = clientRef.current; - if (!client) { - return () => {}; - } - - return client.subscribeEncrypted(type, (data, meta) => { - handler(data as z.infer, { - id: meta.id, - type: meta.type, - data: data as z.infer, - }); - }); - }, - [], - ); - - const decryptEncryptedRecord = useCallback< - ContextType["decryptEncryptedRecord"] - >(async (frameData) => { - const client = clientRef.current; - if (!client) throw new Error("mtp is not connected"); - return client.decryptEncryptedRecord(frameData); - }, []); - - const decryptEncryptedRecordForQueue = decryptEncryptedRecord; - const subscribePush = useCallback((handler: PushHandler) => { const client = clientRef.current; if (!client) { @@ -543,6 +200,7 @@ export function Provider(props: { const unsubscribers = [ "MessageLive", "MessageState", + "ChatSecretForward", "CallInvite", "ErrorNoIota", ].map((type) => @@ -692,31 +350,16 @@ export function Provider(props: { log(2, "mtp", "green", "Connecting to: " + url); - const encryptedDeviceSecretProvider = - new NetworkEncryptedDeviceSecretProvider(); - - const createMTPClient = MTPClient.create as unknown as ( - options: Record, - ) => Promise; - const client = await createMTPClient({ + const client = await MTPClient.create({ url, credentials: { - clientId: BigInt(userId), + clientId: userId, keyring: base64ToUint8Array(await load("mtp_keyring")), }, hostPublicKey: omikronPublicKey, descriptor: "client", pings: true, - encryptedDeviceSecretProvider, - sessionStorage: new LocalMTPSessionStorage( - load as never, - save as never, - ), - logger: (event: { - type: string; - data?: unknown; - direction?: "send" | "recv"; - }) => { + logger: (event) => { if (event.type === "state") { setReadyState( clientRef.current?.state ?? ConnectionState.Disconnected, @@ -752,8 +395,6 @@ export function Provider(props: { return; } - encryptedDeviceSecretProvider.attach(client); - clientRef.current = client; setReadyState(client.state); await client.connect(); @@ -886,7 +527,7 @@ export function Provider(props: { setIdentifying(false); sonnerToast.dismiss("mtp-connection-toast"); }; - }, [mtpUrl, props.blockConnection, load, save]); + }, [mtpUrl, props.blockConnection, load]); // No Iota check useEffect(() => { @@ -920,11 +561,8 @@ export function Provider(props: { () => createAsyncQueue<{ send: typeof send; - sendEncrypted: typeof sendEncrypted; subscribe: typeof subscribe; subscribePush: typeof subscribePush; - subscribeEncrypted: typeof subscribeEncrypted; - decryptEncryptedRecord: typeof decryptEncryptedRecordForQueue; }>(), [], ); @@ -932,11 +570,8 @@ export function Provider(props: { if (connected && identified && mtpUrl) { mtpRef.set({ send, - sendEncrypted, subscribe, subscribePush, - subscribeEncrypted, - decryptEncryptedRecord: decryptEncryptedRecordForQueue, }); } }, [ @@ -944,11 +579,8 @@ export function Provider(props: { identified, mtpUrl, send, - sendEncrypted, subscribe, subscribePush, - subscribeEncrypted, - decryptEncryptedRecordForQueue, mtpRef, ]); @@ -960,32 +592,12 @@ export function Provider(props: { [mtpRef], ); - const sendEncryptedQueued: BoundSendEncryptedFn = useMemo( - () => async (type, data, options) => { - const mtp = await mtpRef.get(); - return mtp.sendEncrypted(type, data, options); - }, - [mtpRef], - ); - - const decryptEncryptedRecordQueued: ContextType["decryptEncryptedRecord"] = - useMemo( - () => async (frameData) => { - const mtp = await mtpRef.get(); - return mtp.decryptEncryptedRecord(frameData); - }, - [mtpRef], - ); - return ( ; ppandtos_done: boolean; accepted_terms_of_service: boolean; accepted_privacy_policy: boolean; @@ -390,8 +347,6 @@ export const storageDefaults: Storage = { session_id: 0, user_id: 0, mtp_keyring: "", - e2ee_device_id: "", - e2ee_sessions: {}, ppandtos_done: false, accepted_terms_of_service: false, accepted_privacy_policy: false, diff --git a/type-maps.yaml b/type-maps.yaml index 38b4d21..0f6ceff 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -137,13 +137,10 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 - SetEncryptedDeviceSecret: 139 - GetEncryptedDeviceSecret: 140 - EncryptedDeviceSecretResponse: 141 - EncryptedMessage: 142 - EncryptedMessageAck: 143 - EncryptedMessagesGet: 144 - EncryptedMessagesResponse: 145 + SetChatSecret: 139 + GetChatSecret: 140 + ChatSecretResponse: 141 + ChatSecretForward: 142 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -251,18 +248,9 @@ type_maps: SecretId: 142 VersionNumber: 143 EncryptedSecret: 144 - WrappingPublicKeyId: 145 WrappingScheme: 146 UpdatedAt: 147 - MessageId: 148 - ConversationId: 149 - SenderClientId: 150 - RecipientClientId: 151 + ChatId: 148 + KemCiphertext: 149 SenderUserId: 152 RecipientUserId: 153 - EncryptionVersion: 154 - HasMore: 155 - NextCursor: 156 - Since: 157 - Limit: 158 - PeerClientId: 159