diff --git a/packages/chat/package.json b/packages/chat/package.json index b656238..a7fa81e 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -7,7 +7,8 @@ "./context": "./src/context.tsx", "./screen": "./src/screen.tsx", "./behaviour-conversation": "./src/behaviour/conversation.ts", - "./behaviour-community": "./src/behaviour/community.ts" + "./behaviour-community": "./src/behaviour/community.ts", + "./values": "./src/values.ts" }, "scripts": { "format": "pnpm exec prettier --write .", diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 414178e..4fd1b6f 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -84,6 +84,8 @@ function MessageComponent({ } }, [load, message.SendTime, user?.UserId, send]); useEffect(() => { + if (message.SentBySelf) return; + let cancelled = false; async function run() { @@ -111,7 +113,7 @@ function MessageComponent({ return () => { cancelled = true; }; - }, [message.MessageState, load, messageStateReadUpdate]); + }, [message.MessageState, load, messageStateReadUpdate, message.SentBySelf]); // Check if message is a url const [isValidURL, setIsValidURL] = useState(false); diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 64f2655..1c8a634 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -134,6 +134,16 @@ export default function Provider({ children }: { children: ReactNode }) { return currentChatSecretState.value; }, [currentChatSecretState, userIdValue]); + // Chat secret debugging + useEffect(() => { + if (!currentChatSecret) return; + + const hex = Array.from(currentChatSecret, (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + log(3, "chat", "yellow", "Chat secret (sensitive)", hex); + }, [currentChatSecret]); + useEffect(() => { if (!userIdValue) return; @@ -149,34 +159,6 @@ export default function Provider({ children }: { children: ReactNode }) { const chatId = deriveChatId(ownUserId, userIdValue); const secretId = deriveChatSecretId(chatId); - async function forwardChatSecret(chatSecret: Uint8Array) { - const peerUser = await getUser(userIdValue); - const peerWrapped = await wrapChatSecret({ - chatSecret, - recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle( - peerUser.PublicKey, - ), - chatId, - secretId, - version: CHAT_SECRET_VERSION, - }); - - assertProtocolSuccess( - "ChatSecretForward", - await send("ChatSecretForward", { - ChatId: chatId, - SenderUserId: String(ownUserId), - RecipientUserId: String(userIdValue), - SecretId: secretId, - VersionNumber: CHAT_SECRET_VERSION, - EncryptedSecret: protocolBytes(peerWrapped.encryptedSecret), - KemCiphertext: protocolBytes(peerWrapped.kemCiphertext), - WrappingScheme: peerWrapped.wrappingScheme, - CreatedAt: Date.now(), - }), - ); - } - const existing = await send("GetChatSecret", { UserId: String(ownUserId), ChatId: chatId, @@ -198,11 +180,13 @@ export default function Provider({ children }: { children: ReactNode }) { if (active) { setCurrentChatSecretState({ userId: userIdValue, value: secret }); } - - await forwardChatSecret(secret); return; } + if (existing.type !== "ErrorNotSet") { + throw new Error(`GetChatSecret failed: ${existing.type}`); + } + const rawSecret = randomChatSecret(); const ownWrapped = await wrapChatSecret({ chatSecret: rawSecret, @@ -211,23 +195,40 @@ export default function Provider({ children }: { children: ReactNode }) { secretId, version: CHAT_SECRET_VERSION, }); + const peerUser = await getUser(userIdValue); + const peerWrapped = await wrapChatSecret({ + chatSecret: rawSecret, + recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle( + peerUser.PublicKey, + ), + chatId, + secretId, + version: CHAT_SECRET_VERSION, + }); assertProtocolSuccess( "SetChatSecret", await send("SetChatSecret", { - UserId: String(ownUserId), ChatId: chatId, SecretId: secretId, VersionNumber: CHAT_SECRET_VERSION, - EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret), - KemCiphertext: protocolBytes(ownWrapped.kemCiphertext), WrappingScheme: ownWrapped.wrappingScheme, CreatedAt: Date.now(), + Recipients: [ + { + UserId: String(ownUserId), + EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret), + KemCiphertext: protocolBytes(ownWrapped.kemCiphertext), + }, + { + UserId: String(userIdValue), + EncryptedSecret: protocolBytes(peerWrapped.encryptedSecret), + KemCiphertext: protocolBytes(peerWrapped.kemCiphertext), + }, + ], }), ); - await forwardChatSecret(rawSecret); - if (active) { setCurrentChatSecretState({ userId: userIdValue, value: rawSecret }); } @@ -246,6 +247,45 @@ export default function Provider({ children }: { children: ReactNode }) { }; }, [getUser, load, send, userIdValue]); + const getChatSecret = useCallback( + async (userId: number): Promise => { + if (!Number.isSafeInteger(userId) || userId <= 0) { + throw new Error("Invalid chat user id"); + } + + const ownUserId = Number(await load("user_id")); + const keyring = await load("mtp_keyring"); + const chatId = deriveChatId(ownUserId, userId); + const secretId = deriveChatSecretId(chatId); + + const response = await send("GetChatSecret", { + UserId: String(ownUserId), + ChatId: chatId, + SecretId: secretId, + }); + + if (response.type === "ErrorNotSet") { + return null; + } + + if (response.type.startsWith("Error")) { + throw new Error(`GetChatSecret failed: ${response.type}`); + } + + const data = response.data as Record; + return 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), + }); + }, + [load, send], + ); + const getMessages = useCallback( async (amount: number, offset: number) => { if (!currentChatSecret) { @@ -344,149 +384,7 @@ export default function Provider({ children }: { children: ReactNode }) { // 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 senderUserId = Number(data.SenderUserId); - if (!Number.isSafeInteger(senderUserId) || senderUserId <= 0) { - throw new Error("Invalid chat secret sender id"); - } - - const existing = await send("GetChatSecret", { - UserId: String(ownUserId), - ChatId: chatId, - SecretId: secretId, - }); - - if (!existing.type.startsWith("Error") && senderUserId > ownUserId) { - const existingData = existing.data as Record; - const existingSecret = await unwrapChatSecret({ - encryptedSecret: bytesFromProtocol(existingData.EncryptedSecret), - kemCiphertext: bytesFromProtocol(existingData.KemCiphertext), - keyring, - chatId: String(existingData.ChatId), - secretId: String(existingData.SecretId), - version: Number(existingData.VersionNumber), - wrappingScheme: String(existingData.WrappingScheme), - }); - const senderUser = await getUser(senderUserId); - const senderWrapped = await wrapChatSecret({ - chatSecret: existingSecret, - recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle( - senderUser.PublicKey, - ), - chatId, - secretId, - version: Number(existingData.VersionNumber), - }); - - assertProtocolSuccess( - "ChatSecretForward", - await send("ChatSecretForward", { - ChatId: chatId, - SenderUserId: String(ownUserId), - RecipientUserId: String(senderUserId), - SecretId: String(existingData.SecretId), - VersionNumber: Number(existingData.VersionNumber), - EncryptedSecret: protocolBytes(senderWrapped.encryptedSecret), - KemCiphertext: protocolBytes(senderWrapped.kemCiphertext), - WrappingScheme: senderWrapped.wrappingScheme, - CreatedAt: Date.now(), - }), - ); - - log( - 2, - "chat", - "purple", - "Ignoring forwarded chat secret because local user owns the deterministic secret", - { - chatId, - ownUserId, - senderUserId, - }, - ); - return; - } - - 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), - }); - - assertProtocolSuccess( - "SetChatSecret", - await send("SetChatSecret", { - UserId: String(ownUserId), - ChatId: chatId, - SecretId: secretId, - VersionNumber: Number(data.VersionNumber), - EncryptedSecret: protocolBytes(ownWrapped.encryptedSecret), - KemCiphertext: protocolBytes(ownWrapped.kemCiphertext), - WrappingScheme: ownWrapped.wrappingScheme, - CreatedAt: Date.now(), - }), - ); - - if (deriveChatId(ownUserId, userIdValue) === chatId) { - setCurrentChatSecretState({ userId: userIdValue, value: secret }); - void queryClient.invalidateQueries({ - queryKey: ["chat-messages", String(userIdValue)], - }); - } - })().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((err) => { - log(1, "chat", "red", "Failed to decrypt live message", err, { - SendTime: data.Message?.SendTime, - }); - return null; - }) - .then((content) => { - if (!data.Message) return; - addLiveMessage({ - ...data.Message, - Content: content ?? "Failed to decrypt message", - decryptionFailed: content === null, - SentBySelf: false, - }); - }); - return; - } - - if (message.type !== "MessageState") { - return; - } + if (message.type !== "MessageState") return; const rawData = message.data as { ChatPartnerId: unknown; @@ -575,21 +473,14 @@ export default function Provider({ children }: { children: ReactNode }) { }, ); }); - }, [ - addLiveMessage, - currentChatSecret, - getUser, - load, - send, - subscribePush, - userIdValue, - ]); + }, [addLiveMessage, currentChatSecret, send, subscribePush, userIdValue]); return ( liveMessagesState, addLiveMessage, clearLiveMessages, @@ -608,6 +499,7 @@ export default function Provider({ children }: { children: ReactNode }) { type contextType = { getMessages: (amount: number, offset: number) => Promise; + getChatSecret: (userId: number) => Promise; liveMessages: () => LiveMessage[]; addLiveMessage: (message: RawMessage) => { setFailed: (failed: boolean) => void; diff --git a/packages/chat/todo.md b/packages/chat/todo.md index c44ad8b..4bff343 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -2,3 +2,4 @@ - Add default-emoji-hotkey - Placeholder image if media fails to load - Signature verifications via ed25519 key +- Confirmation when exiting with text in the input box. diff --git a/packages/crypto/src/context.test.ts b/packages/crypto/src/context.test.ts index 8db92a3..e6c2178 100644 --- a/packages/crypto/src/context.test.ts +++ b/packages/crypto/src/context.test.ts @@ -49,12 +49,6 @@ describe("createCryptoActions", () => { secret: string, ciphertext: string, ): Promise => `${secret}|${ciphertext}`, - getSharedSecret: async ( - ownPrivateKey: string, - ownPublicKey: string, - otherPublicKey: string, - ): Promise => - `${ownPrivateKey}.${ownPublicKey}.${otherPublicKey}`, }; const actions = createCryptoActions(() => api); @@ -67,6 +61,5 @@ describe("createCryptoActions", () => { ).toBe("s|c"); expect(await actions.encryptText("s", "p")).toBe("s:p"); expect(await actions.decryptText("s", "c")).toBe("s|c"); - expect(await actions.getSharedSecret("a", "b", "c")).toBe("a.b.c"); }); }); diff --git a/packages/crypto/src/context.tsx b/packages/crypto/src/context.tsx index 0fef2b3..b9ca137 100644 --- a/packages/crypto/src/context.tsx +++ b/packages/crypto/src/context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import { base64ToBytes, bytesToBase64, crypto } from "mtp"; +import { base64ToBytes, crypto } from "mtp"; type CryptoContextType = { decrypt: ( @@ -12,11 +12,6 @@ type CryptoContextType = { input: Uint8Array, ) => Promise>; encryptText: (secret: string, plaintext: string) => Promise; - getSharedSecret: ( - ownPrivateKey: string, - ownPublicKey: string, - otherPublicKey: string, - ) => Promise; }; export const context = createContext(undefined); @@ -39,8 +34,6 @@ export function createCryptoActions( encrypt: (secret, input) => requireApi().encrypt(secret, input), encryptText: (secret, plaintext) => requireApi().encryptText(secret, plaintext), - getSharedSecret: (ownPrivateKey, ownPublicKey, otherPublicKey) => - requireApi().getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey), }; } @@ -58,45 +51,6 @@ function secretKeyFromString(secret: string): Uint8Array { ); } -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) => @@ -107,7 +61,6 @@ export default function Provider(props: { children: React.ReactNode }) { ownedBytes(await crypto.encrypt(secretKeyFromString(secret), input)), encryptText: (secret, plaintext) => crypto.encryptText(secretKeyFromString(secret), plaintext), - getSharedSecret, })); return {props.children}; diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 8d10e55..9f96555 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -201,7 +201,6 @@ export function Provider(props: { const unsubscribers = [ "MessageLive", "MessageState", - "ChatSecretForward", "CallInvite", "ErrorNoIota", ].map((type) => diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 1879852..8408213 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -1,16 +1,16 @@ -import { useCrypto } from "@tensamin/crypto/context"; import { useStorage } from "@tensamin/storage/context"; import { useUser } from "@tensamin/user/context"; import { useChat } from "@tensamin/chat/context"; import { useMTP } from "@tensamin/mtp"; import { createContext, useEffect, useContext } from "react"; -import z from "zod"; import { toast as sonnerToast } from "sonner"; -import { Message as MessageSchema } from "@tensamin/shared/data"; import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui"; import { isTauri } from "@tauri-apps/api/core"; import { useSession } from "@tensamin/storage/session"; import { useNavigate } from "@tanstack/react-router"; +import { decryptChatText } from "@tensamin/crypto/chatSecret"; +import { log } from "@tensamin/shared/log"; +import { type RawMessage } from "@tensamin/chat/values"; export const context = createContext(undefined); @@ -27,108 +27,113 @@ export default function Provider(props: { children: React.ReactNode }) { const { subscribePush, send } = useMTP(); const { load } = useStorage(); const { get } = useUser(); - const { decryptText, getSharedSecret } = useCrypto(); - const { addLiveMessage, userId } = useChat(); + const { addLiveMessage, getChatSecret, userId } = useChat(); const { moveUserIdToTop } = useSession(); const navigate = useNavigate(); useEffect(() => { - return subscribePush(async (ttpMessage) => { - if (ttpMessage.type === "MessageLive") { - const { message, SenderId } = ttpMessage.data as { - message: z.infer; - SenderId: number; + return subscribePush(async (message) => { + if (message.type === "MessageLive") { + const data = message.data as { + Message?: RawMessage; + SenderId?: number; }; - const user = await get(SenderId); + if (!data.SenderId) return; - const decryptedContent = await decryptText( - await getSharedSecret( - await load("mtp_keyring"), - await get(await load("user_id")).then((data) => data.PublicKey), - user.PublicKey, - ), - message.Content, - ); + const chatSecret = await getChatSecret(data.SenderId); - // Update message state - if (userId === SenderId) { - addLiveMessage({ - ...message, - Content: decryptedContent, - SentBySelf: false, - }); - return; - } + if (!data.Message || !chatSecret) return; + const user = await get(data.SenderId); - // todo: add notification symbol to conversation cards (incl. message start) - - moveUserIdToTop(SenderId); - - if (await load("settings.receive_confirmations")) { - void send( - "MessageState", - { - MessageState: "received", - }, - { - id: ttpMessage.id, - }, - ); - } - - if (isTauri()) { - console.log("weewoo"); - } else { - const hasPermissions = await requestNotificationPermission(); - - if (hasPermissions) { - const notification = new Notification(user.Display, { - body: decryptedContent, - icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(), - badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(), - tag: `message-${user.UserId}`, - silent: true, + void decryptChatText(chatSecret, data.Message.Content) + .catch((err) => { + log(1, "chat", "red", "Failed to decrypt live message", err, { + SendTime: data.Message?.SendTime, }); + return null; + }) + .then(async (content) => { + if (!data.Message || !content || !data.SenderId) return; - notification.onclick = () => { - window.focus(); - navigate({ - to: `/chat?id=${user.UserId}`, + if (userId === data.SenderId) { + addLiveMessage({ + ...data.Message, + Content: content ?? "Failed to decrypt message", + decryptionFailed: content === null, + SentBySelf: false, }); + return; + } - notification.close(); - }; - } else { - sonnerToast(user.Display, { - classNames: { - content: "pl-4", - }, - description: decryptedContent, - icon: ( - - - - {user.Display.slice(0, 2).toUpperCase()} - - - ), - }); - } - } + // todo: add notification symbol to conversation cards (incl. message start) + moveUserIdToTop(data.SenderId); + + if (await load("settings.receive_confirmations")) { + void send( + "MessageState", + { + MessageState: "received", + }, + { + id: data.Message.SendTime, + }, + ); + } + + if (isTauri()) { + console.log("weewoo"); + } else { + const hasPermissions = await requestNotificationPermission(); + + if (hasPermissions) { + const notification = new Notification(user.Display, { + body: content, + icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(), + badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(), + tag: `message-${user.UserId}`, + silent: true, + }); + + notification.onclick = () => { + window.focus(); + navigate({ + to: `/chat?id=${user.UserId}`, + }); + + notification.close(); + }; + } else { + sonnerToast(user.Display, { + classNames: { + content: "pl-4", + }, + description: content, + icon: ( + + + + {user.Display.slice(0, 2).toUpperCase()} + + + ), + }); + } + } + }); + return; } }); }, [ - subscribePush, - decryptText, - getSharedSecret, - load, - get, addLiveMessage, - userId, - moveUserIdToTop, - send, + get, + load, navigate, + send, + moveUserIdToTop, + subscribePush, + getChatSecret, + userId, ]); return ( diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index b887168..11a6ef5 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -32,6 +32,12 @@ const chatSecretResponse = z.object({ UpdatedAt: z.number(), }); +const chatSecretRecipient = z.object({ + UserId: z.string(), + EncryptedSecret: protocolBytes, + KemCiphertext: protocolBytes, +}); + export const Message = z.object({ NotEncrypted: z.boolean().optional(), SentBySelf: z.boolean().optional(), @@ -267,14 +273,12 @@ export const mtp = { }, SetChatSecret: { request: z.object({ - UserId: z.string(), ChatId: z.string(), SecretId: z.string(), VersionNumber: z.number(), - EncryptedSecret: protocolBytes, - KemCiphertext: protocolBytes, WrappingScheme: z.string(), CreatedAt: z.number(), + Recipients: z.array(chatSecretRecipient).min(1), }), response: z.object({}), }, @@ -308,6 +312,10 @@ export const mtp = { request: z.object({}).optional(), response: z.object({}), }, + ErrorNotSet: { + request: z.object({}).optional(), + response: z.object({}), + }, } satisfies Record; export type MTP = typeof mtp; diff --git a/type-maps.yaml b/type-maps.yaml index 0f6ceff..b6e68b1 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -137,6 +137,7 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 + ErrorNotSet: 136 SetChatSecret: 139 GetChatSecret: 140 ChatSecretResponse: 141 @@ -254,3 +255,4 @@ type_maps: KemCiphertext: 149 SenderUserId: 152 RecipientUserId: 153 + Recipients: 154