diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 27dfd61..64f2655 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -35,6 +35,10 @@ const CHAT_SECRET_VERSION = 1; function bytesFromProtocol(value: unknown): Uint8Array { if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } if (Array.isArray(value)) return new Uint8Array(value); if (typeof value === "string") { const bin = atob(value); @@ -42,6 +46,16 @@ function bytesFromProtocol(value: unknown): Uint8Array { for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value); + if ( + entries.every( + ([key, item]) => /^\d+$/.test(key) && typeof item === "number", + ) + ) { + return new Uint8Array(entries.map(([, item]) => item as number)); + } + } throw new Error("expected protocol bytes"); } @@ -72,6 +86,16 @@ function updateMessageStateBySendTime< }; } +function assertProtocolSuccess(type: string, response: { type: string }) { + if (response.type.startsWith("Error")) { + throw new Error(`${type} failed: ${response.type}`); + } +} + +function protocolBytes(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes); +} + export default function Provider({ children }: { children: ReactNode }) { const { load } = useStorage(); const { send, subscribePush } = useMTP(); @@ -125,6 +149,34 @@ 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, @@ -146,6 +198,8 @@ export default function Provider({ children }: { children: ReactNode }) { if (active) { setCurrentChatSecretState({ userId: userIdValue, value: secret }); } + + await forwardChatSecret(secret); return; } @@ -158,39 +212,21 @@ export default function Provider({ children }: { children: ReactNode }) { 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(), - }); + 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(), + }), + ); - 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(), - }); + await forwardChatSecret(rawSecret); if (active) { setCurrentChatSecretState({ userId: userIdValue, value: rawSecret }); @@ -317,6 +353,68 @@ export default function Provider({ children }: { children: ReactNode }) { 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), @@ -334,19 +432,25 @@ export default function Provider({ children }: { children: ReactNode }) { 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(), - }); + 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); @@ -474,6 +578,7 @@ export default function Provider({ children }: { children: ReactNode }) { }, [ addLiveMessage, currentChatSecret, + getUser, load, send, subscribePush, diff --git a/packages/crypto/src/chatSecret.ts b/packages/crypto/src/chatSecret.ts index 3e15300..e6e836c 100644 --- a/packages/crypto/src/chatSecret.ts +++ b/packages/crypto/src/chatSecret.ts @@ -1,6 +1,7 @@ -import { crypto } from "mtp"; +import { base64ToBytes, bytesToBase64, crypto } from "mtp"; const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); export const CHAT_SECRET_WRAPPING_SCHEME = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"; @@ -73,11 +74,16 @@ export async function unwrapChatSecret(args: { wrappingScheme: string; }): Promise { if (args.wrappingScheme !== CHAT_SECRET_WRAPPING_SCHEME) { - throw new Error(`Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`); + throw new Error( + `Unsupported chat secret wrapping scheme: ${args.wrappingScheme}`, + ); } const ownKeys = crypto.keyringToKeys(args.keyring); - const sharedSecret = crypto.decapsulate(ownKeys.kemSecretKey, args.kemCiphertext); + const sharedSecret = crypto.decapsulate( + ownKeys.kemSecretKey, + args.kemCiphertext, + ); try { const wrappingKey = deriveWrappingKey({ @@ -103,7 +109,9 @@ export async function encryptChatText( ): Promise { const key = deriveMessageKey(chatSecret); try { - return await crypto.encryptText(key, plaintext); + return bytesToBase64( + await crypto.encrypt(key, textEncoder.encode(plaintext)), + ); } finally { key.fill(0); } @@ -115,7 +123,9 @@ export async function decryptChatText( ): Promise { const key = deriveMessageKey(chatSecret); try { - return await crypto.decryptText(key, ciphertext); + return textDecoder.decode( + await crypto.decrypt(key, base64ToBytes(ciphertext)), + ); } finally { key.fill(0); } diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 9ca1016..98b5d99 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -111,7 +111,9 @@ function validateResponse( return message as ProtocolMessage; } - const schema = schemas[type]?.response; + const schema = + schemas[message.type as keyof Schemas & string]?.response ?? + schemas[type]?.response; if (!schema) { return message as ProtocolMessage; } @@ -574,15 +576,7 @@ export function Provider(props: { subscribePush, }); } - }, [ - connected, - identified, - mtpUrl, - send, - subscribe, - subscribePush, - mtpRef, - ]); + }, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]); const sendQueued: BoundSendFn = useMemo( () => async (type, data, options) => { diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 56293ec..b887168 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -18,6 +18,20 @@ const bytesLike = z.union([ z.base64(), ]); +const protocolBytes = z.instanceof(Uint8Array); + +const chatSecretResponse = z.object({ + UserId: z.string(), + ChatId: z.string(), + SecretId: z.string(), + VersionNumber: z.number(), + EncryptedSecret: bytesLike, + KemCiphertext: bytesLike, + WrappingScheme: z.string(), + CreatedAt: z.number(), + UpdatedAt: z.number(), +}); + export const Message = z.object({ NotEncrypted: z.boolean().optional(), SentBySelf: z.boolean().optional(), @@ -257,8 +271,8 @@ export const mtp = { ChatId: z.string(), SecretId: z.string(), VersionNumber: z.number(), - EncryptedSecret: bytesLike, - KemCiphertext: bytesLike, + EncryptedSecret: protocolBytes, + KemCiphertext: protocolBytes, WrappingScheme: z.string(), CreatedAt: z.number(), }), @@ -270,21 +284,11 @@ export const mtp = { ChatId: z.string(), SecretId: z.string().optional(), }), - response: z.object({}), + response: chatSecretResponse, }, ChatSecretResponse: { request: z.object({}).optional(), - response: z.object({ - UserId: z.string(), - ChatId: z.string(), - SecretId: z.string(), - VersionNumber: z.number(), - EncryptedSecret: bytesLike, - KemCiphertext: bytesLike, - WrappingScheme: z.string(), - CreatedAt: z.number(), - UpdatedAt: z.number(), - }), + response: chatSecretResponse, }, ChatSecretForward: { request: z.object({ @@ -293,8 +297,8 @@ export const mtp = { RecipientUserId: z.string(), SecretId: z.string(), VersionNumber: z.number(), - EncryptedSecret: bytesLike, - KemCiphertext: bytesLike, + EncryptedSecret: protocolBytes, + KemCiphertext: protocolBytes, WrappingScheme: z.string(), CreatedAt: z.number(), }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c210028..099ec42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: '@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz - mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz + mtp: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz importers: @@ -16,8 +16,8 @@ importers: specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3) mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -652,8 +652,8 @@ importers: specifier: ^1.14.0 version: 1.23.0(react@19.2.7) mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz react: specifier: ^19.2.0 version: 19.2.7 @@ -3736,8 +3736,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz: - resolution: {integrity: sha512-Y18aXdMfM80YLBGzk3NS5PVl08GHX2Cfkw0aQK06xazA3zZlPVHStS7enZ4s4MmDzE6NNTYBYbw49nWpUgkdvQ==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz} + mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz: + resolution: {integrity: sha512-A2SN971Er728ibaemcL1qu1PGJuOZxNGY8ssgld3ETF94tVJ3GeBHQERcbQNdj9fExPEmnjkq/o1tt8W+Vdcvw==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz} version: 0.1.0 nanoid@3.3.15: @@ -7857,7 +7857,7 @@ snapshots: ms@2.1.3: {} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz: {} + mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz: {} nanoid@3.3.15: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 019fa44..522bd1a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,4 +7,4 @@ allowBuilds: esbuild: true overrides: "@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz" - mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-515244c/mtp-0.1.0.tgz" + mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-40e9423/mtp-0.1.0.tgz"