From cd2c2f81678aa0d958b75325376e7d9aefba4ac5 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 5 Jul 2026 21:45:44 +0200 Subject: [PATCH] (feat): crypto migrations --- apps/web/vite.config.ts | 1 - packages/chat/src/components/input.tsx | 67 ++- packages/chat/src/context.tsx | 164 +++---- packages/chat/src/screen.tsx | 6 +- packages/crypto/package.json | 5 +- packages/crypto/src/context.test.ts | 7 +- packages/crypto/src/context.tsx | 53 +- packages/crypto/src/encryptedDeviceSecret.ts | 134 ++++++ packages/crypto/src/worker.test.ts | 123 ----- packages/crypto/src/worker.ts | 482 ------------------- packages/mtp/src/context.tsx | 408 +++++++++++++++- packages/mtp/src/index.ts | 7 +- packages/shared/src/data.ts | 144 +++++- packages/tauth/src/context.tsx | 3 +- pnpm-lock.yaml | 22 - type-maps.yaml | 29 ++ vitest.config.ts | 7 + 17 files changed, 838 insertions(+), 824 deletions(-) create mode 100644 packages/crypto/src/encryptedDeviceSecret.ts delete mode 100644 packages/crypto/src/worker.test.ts delete mode 100644 packages/crypto/src/worker.ts create mode 100644 vitest.config.ts diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4ea6a18..b3d396e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -162,7 +162,6 @@ export default defineConfig({ "@tensamin/chat", "@tensamin/crypto", "@tensamin/crypto/context", - "@tensamin/crypto/worker", "@tensamin/markdown", "@tensamin/mtp", "@tensamin/notifications", diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index df83c18..892f911 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -15,7 +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 { encryptText } from "@tensamin/crypto/worker"; + import { useSession } from "@tensamin/storage/session"; import GifPicker from "./gifPicker"; @@ -28,8 +28,8 @@ export default function InputComponent({ }) { const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); - const { send } = useMTP(); - const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat(); + const { sendEncrypted } = useMTP(); + const { addLiveMessage, userId, inputBoxRef } = useChat(); const { load, save } = useStorage(); const { moveUserIdToTop } = useSession(); const gifPopoverRef = React.useRef(null); @@ -64,11 +64,6 @@ export default function InputComponent({ return; } - if (!sharedSecret) { - toast("error", "Still getting shared secret..."); - return; - } - log(3, "chat", "purple", "Message send init, adding live message ..."); const reference = addLiveMessage({ @@ -79,36 +74,38 @@ export default function InputComponent({ MessageState: "awaiting", }); - log(3, "chat", "purple", "Live message added, encrypting..."); + log(3, "chat", "purple", "Live message added, sending encrypted frame..."); - const encryptedContext = await encryptText( - sharedSecret, - currentValue, - ).catch((err) => { - toast("error", "Failed to encrypt message", String(err)); - reference.setFailed(true); - }); - - if (!encryptedContext) return; - - log(3, "chat", "purple", "Content encrypted, sending message..."); - - send("MessageSend", { - Content: encryptedContext, - ReceiverId: userId, - SendTime: time, - }).catch((e) => { - log(0, "Chat", "red", "Failed to send message", e, { - content: currentValue, - encryptedContext, - ReceiverId: userId, - SendTime: time, + 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, + }); + 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", + ); }); - reference.setFailed(true); - toast("error", "Failed to send message"); - }); - log(3, "chat", "purple", "Message sent"); + log(3, "chat", "purple", "Encrypted message send queued"); moveUserIdToTop(userId); diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index b9d580d..c12d768 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -12,8 +12,6 @@ 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 { useCrypto } from "@tensamin/crypto/context"; -import { useUser } from "@tensamin/user/context"; import { useStorage } from "@tensamin/storage/context"; import { useMTP } from "@tensamin/mtp"; import { log } from "@tensamin/shared/log"; @@ -51,23 +49,15 @@ function updateMessageStateBySendTime< } export default function Provider({ children }: { children: ReactNode }) { - const { getSharedSecret, decryptText } = useCrypto(); - const { get } = useUser(); const { load } = useStorage(); - const { send, subscribePush } = useMTP(); + const { send, subscribePush, subscribeEncrypted, decryptEncryptedRecord } = + useMTP(); const { moveUserIdToTop } = useSession(); - const [error, setError] = useState(""); - const [errorDescription, setErrorDescription] = useState(""); + const [error] = useState(""); + const [errorDescription] = useState(""); const [liveMessagesState, setLiveMessagesState] = useState([]); - const [currentSharedSecretState, setCurrentSharedSecretState] = useState<{ - userId: number; - value: string; - }>({ - userId: 0, - value: "", - }); const inputBoxRef = useRef(null); @@ -81,86 +71,40 @@ export default function Provider({ children }: { children: ReactNode }) { return Number(rawId ?? 0); }, [locationSearch]); - const currentSharedSecret = useMemo(() => { - if (currentSharedSecretState.userId !== userIdValue) { - return ""; - } - - return currentSharedSecretState.value; - }, [currentSharedSecretState, userIdValue]); - - // Load shared secret - useEffect(() => { - if (!userIdValue) return; - - let active = true; - - void (async () => { - try { - const recipientData = await get(userIdValue); - const ownId = await load("user_id"); - const privateKey = await load("mtp_keyring"); - const ownData = await get(ownId); - - log(3, "chat", "purple", "Getting shared secret...", { - recipientData, - ownData, - }); - - const sharedSecret = await getSharedSecret( - privateKey, - ownData.PublicKey, - recipientData.PublicKey, - ); - - log(2, "chat", "purple", "Got shared secret", { - sharedSecret, - }); - - if (active) { - setCurrentSharedSecretState({ - userId: userIdValue, - value: sharedSecret, - }); - } - } catch (err) { - log( - 1, - "chat", - "red", - "An unknown error occured while getting a shared secret", - err, - ); - setError(err instanceof Error ? err.name : "Unknown Error"); - setErrorDescription(err instanceof Error ? err.message : String(err)); - if (active) { - setCurrentSharedSecretState({ - userId: userIdValue, - value: "", - }); - } - } - })(); - - return () => { - active = false; - }; - }, [get, getSharedSecret, load, userIdValue]); - const getMessages = useCallback( async (amount: number, offset: number) => { - const messages = await send("MessagesGet", { - Amount: amount, - Offset: offset, - UserId: userIdValue, + const response = await send("EncryptedMessagesGet", { + Limit: amount, + SenderUserId: String(userIdValue), }); - if (messages.type.startsWith("error")) { - throw new Error(messages.type); + if (response.type.startsWith("error")) { + throw new Error(response.type); } - const rawMessages = messages.data.Messages; - const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime); + 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 sorted = [...decrypted].sort((a, b) => a.SendTime - b.SendTime); if (sorted.length > 0) { const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime)); @@ -174,20 +118,9 @@ export default function Provider({ children }: { children: ReactNode }) { }); } - return await Promise.all( - sorted.map(async (message) => { - try { - return { - ...message, - content: await decryptText(currentSharedSecret, message.Content), - }; - } catch { - return message; - } - }), - ); + return sorted; }, - [send, userIdValue, currentSharedSecret, decryptText], + [send, userIdValue, decryptEncryptedRecord, load], ); const addLiveMessage = useCallback( @@ -228,6 +161,30 @@ 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) => { @@ -286,7 +243,6 @@ export default function Provider({ children }: { children: ReactNode }) { const queryKey = [ "chat-messages", String(userIdValue), - currentSharedSecret.length > 0, ] as const; queryClient.setQueryData>( queryKey, @@ -322,7 +278,7 @@ export default function Provider({ children }: { children: ReactNode }) { }, ); }); - }, [currentSharedSecret, subscribePush, userIdValue]); + }, [subscribePush, userIdValue]); return ( @@ -332,7 +288,7 @@ export default function Provider({ children }: { children: ReactNode }) { liveMessages: () => liveMessagesState, addLiveMessage, clearLiveMessages, - sharedSecret: currentSharedSecret, + sharedSecret: "", userId: userIdValue, inputBoxRef, error, diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index b5cf724..663ea99 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -79,7 +79,6 @@ export default function Screen() { liveMessages, clearLiveMessages, userId, - sharedSecret, error, errorDescription, } = useChat(); @@ -106,13 +105,12 @@ export default function Screen() { const [value, setValue] = React.useState(""); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; - const hasSharedSecret = sharedSecret.length > 0; const messagesQuery = useInfiniteQuery({ - queryKey: ["chat-messages", String(userId), hasSharedSecret], + queryKey: ["chat-messages", String(userId)], initialPageParam: 0, queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)), - enabled: hasValidChatUser && hasSharedSecret, + enabled: hasValidChatUser, getNextPageParam: (lastPage, allPages) => { if (lastPage.length < PAGE_SIZE) { return undefined; diff --git a/packages/crypto/package.json b/packages/crypto/package.json index cc14db5..b4a2f30 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": { "./context": "./src/context.tsx", - "./worker": "./src/worker.ts" + "./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts" }, "scripts": { "format": "pnpm exec prettier --write .", @@ -14,9 +14,6 @@ "build": "pnpm run test && tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "comlink": "^4.4.2", "react": "^19.2.0", "react-dom": "^19.2.0" } diff --git a/packages/crypto/src/context.test.ts b/packages/crypto/src/context.test.ts index 5dc9b69..8db92a3 100644 --- a/packages/crypto/src/context.test.ts +++ b/packages/crypto/src/context.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("mtp", () => ({ + crypto: {}, +})); + import { createCryptoActions } from "./context"; /** diff --git a/packages/crypto/src/context.tsx b/packages/crypto/src/context.tsx index c871348..26120b7 100644 --- a/packages/crypto/src/context.tsx +++ b/packages/crypto/src/context.tsx @@ -21,20 +21,47 @@ type CryptoContextType = { export const context = createContext(undefined); +export function createCryptoActions( + getApi: () => CryptoContextType | null | undefined, +): CryptoContextType { + const requireApi = () => { + const api = getApi(); + if (!api) { + throw new Error("Crypto API not initialized"); + } + return api; + }; + + return { + decrypt: (secret, input) => requireApi().decrypt(secret, input), + decryptText: (secret, ciphertext) => + requireApi().decryptText(secret, ciphertext), + encrypt: (secret, input) => requireApi().encrypt(secret, input), + encryptText: (secret, plaintext) => + requireApi().encryptText(secret, plaintext), + getSharedSecret: (ownPrivateKey, ownPublicKey, otherPublicKey) => + requireApi().getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey), + }; +} + +function ownedBytes(bytes: Uint8Array): Uint8Array { + const out = new Uint8Array(bytes.byteLength); + out.set(bytes); + return out; +} + export default function Provider(props: { children: React.ReactNode }) { - return ( - - {props.children} - - ); + const actions = createCryptoActions(() => ({ + decrypt: async (secret, input) => + ownedBytes(await crypto.decrypt(secret, input)), + decryptText: crypto.decryptText, + encrypt: async (secret, input) => + ownedBytes(await crypto.encrypt(secret, input)), + encryptText: crypto.encryptText, + getSharedSecret: crypto.getSharedSecret, + })); + + return {props.children}; } export function useCrypto(): CryptoContextType { diff --git a/packages/crypto/src/encryptedDeviceSecret.ts b/packages/crypto/src/encryptedDeviceSecret.ts new file mode 100644 index 0000000..26dd5a0 --- /dev/null +++ b/packages/crypto/src/encryptedDeviceSecret.ts @@ -0,0 +1,134 @@ +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/crypto/src/worker.test.ts b/packages/crypto/src/worker.test.ts deleted file mode 100644 index eae3ee1..0000000 --- a/packages/crypto/src/worker.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { x448 } from "@noble/curves/ed448.js"; -import { - decrypt, - decryptText, - encrypt, - encryptText, - getSharedSecret, -} from "./worker"; - -/** - * Encodes bytes to URL-safe base64 without padding. - * @param value Input bytes. - * @returns Base64url string. - */ -function bytesToB64u(value: Uint8Array): string { - const alphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let output = ""; - - for (let index = 0; index < value.length; index += 3) { - const first = value[index] ?? 0; - const second = value[index + 1] ?? 0; - const third = value[index + 2] ?? 0; - const chunk = (first << 16) | (second << 8) | third; - - output += alphabet[(chunk >> 18) & 63]; - output += alphabet[(chunk >> 12) & 63]; - output += index + 1 < value.length ? alphabet[(chunk >> 6) & 63] : "="; - output += index + 2 < value.length ? alphabet[chunk & 63] : "="; - } - - return output.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -/** - * Converts bytes to lowercase hex. - * @param value Input bytes. - * @returns Hex string. - */ -function bytesToHex(value: Uint8Array): string { - return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -} - -/** - * Creates deterministic 56-byte private key material for tests. - * @param seed Offset seed used to vary generated bytes. - * @returns Deterministic private key bytes. - */ -function createPrivateKey(seed: number): Uint8Array { - const output = new Uint8Array(56); - - for (let index = 0; index < output.length; index += 1) { - output[index] = (seed + index) % 255; - } - - return output; -} - -describe("crypto worker", () => { - const textEncoder = new TextEncoder(); - const textDecoder = new TextDecoder(); - - test("encrypt/decrypt byte round-trip returns original plaintext", async () => { - const secret = "0f".repeat(56); - const input = "hello encrypted world"; - - const encryptedContent = await encrypt(secret, textEncoder.encode(input)); - const decryptedContent = await decrypt(secret, encryptedContent); - - expect(textDecoder.decode(decryptedContent)).toBe(input); - }); - - test("encryptText/decryptText round-trip returns original plaintext", async () => { - const secret = "0f".repeat(56); - const input = "hello encrypted world"; - - const ciphertext = await encryptText(secret, input); - const plaintext = await decryptText(secret, ciphertext); - - expect(plaintext).toBe(input); - }); - - test("decrypt fails with wrong shared secret", async () => { - const secret = "0f".repeat(56); - const wrongSecret = "f0".repeat(56); - const input = "sensitive"; - - const ciphertext = await encrypt(secret, textEncoder.encode(input)); - - let failed = false; - try { - await decrypt(wrongSecret, ciphertext); - } catch { - failed = true; - } - - expect(failed).toBe(true); - }); - - test("getSharedSecret matches noble x448 derivation", async () => { - const ownPrivateBytes = createPrivateKey(7); - const peerPrivateBytes = createPrivateKey(23); - - const ownPublicBytes = x448.getPublicKey(ownPrivateBytes); - const peerPublicBytes = x448.getPublicKey(peerPrivateBytes); - - const expected = bytesToHex( - new Uint8Array(x448.getSharedSecret(ownPrivateBytes, peerPublicBytes)), - ); - - const actual = await getSharedSecret( - bytesToB64u(ownPrivateBytes), - bytesToB64u(ownPublicBytes), - bytesToB64u(peerPublicBytes), - ); - - expect(actual).toBe(expected); - }); -}); diff --git a/packages/crypto/src/worker.ts b/packages/crypto/src/worker.ts deleted file mode 100644 index f22fe42..0000000 --- a/packages/crypto/src/worker.ts +++ /dev/null @@ -1,482 +0,0 @@ -import * as Comlink from "comlink"; - -type Base64URLString = string; - -type JWK = { - kty: string; - crv: string; - x?: string; - d?: string; -}; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); -const crypto = globalThis.crypto; - -/** - * Encodes bytes as standard base64 text. - * @param bytes Bytes to encode. - * @returns Base64 string. - */ -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -/** - * Decodes standard base64 text into bytes. - * @param base64 Base64 string. - * @returns Decoded bytes. - */ -function base64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -/** - * Encrypts bytes with a symmetric key derived from a hex shared secret. - * @param secret Hex-encoded shared secret. - * @param input Plaintext bytes to encrypt. - * @returns Ciphertext bytes. - */ -export async function encrypt( - secret: string, - input: Uint8Array, -): Promise> { - const sharedSecret = new Uint8Array( - secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), - ); - - const hkdfKey = await crypto.subtle.importKey( - "raw", - sharedSecret, - "HKDF", - false, - ["deriveBits"], - ); - - const okm = await crypto.subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array([]), - info: textEncoder.encode("x448-aes-gcm-no-overhead"), - }, - hkdfKey, - 44 * 8, - ); - - const okmBytes = new Uint8Array(okm); - const keyBytes = okmBytes.slice(0, 32); - const nonce = okmBytes.slice(32, 44); - - const aesKey = await crypto.subtle.importKey( - "raw", - keyBytes, - { name: "AES-GCM" }, - false, - ["encrypt"], - ); - - const encryptedBuffer = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: nonce }, - aesKey, - input, - ); - - return new Uint8Array(encryptedBuffer); -} - -/** - * Decrypts bytes with a symmetric key derived from a hex shared secret. - * @param secret Hex-encoded shared secret. - * @param input Ciphertext bytes to decrypt. - * @returns Plaintext bytes. - */ -export async function decrypt( - secret: string, - input: Uint8Array, -): Promise> { - const sharedSecret = new Uint8Array( - secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), - ); - - const hkdfKey = await crypto.subtle.importKey( - "raw", - sharedSecret, - "HKDF", - false, - ["deriveBits"], - ); - - const okm = await crypto.subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array([]), - info: textEncoder.encode("x448-aes-gcm-no-overhead"), - }, - hkdfKey, - 44 * 8, - ); - - const okmBytes = new Uint8Array(okm); - const keyBytes = okmBytes.slice(0, 32); - const nonce = okmBytes.slice(32, 44); - - const aesKey = await crypto.subtle.importKey( - "raw", - keyBytes, - { name: "AES-GCM" }, - false, - ["decrypt"], - ); - - const decryptedBuffer = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: nonce, - }, - aesKey, - input, - ); - - return new Uint8Array(decryptedBuffer); -} - -/** - * Encrypts UTF-8 text and returns base64 ciphertext for easy transport/storage. - * @param secret Hex-encoded shared secret. - * @param plaintext Text to encrypt. - * @returns Base64 ciphertext. - */ -export async function encryptText( - secret: string, - plaintext: string, -): Promise { - const encrypted = await encrypt(secret, textEncoder.encode(plaintext)); - return bytesToBase64(encrypted); -} - -/** - * Decrypts base64 ciphertext into UTF-8 text. - * @param secret Hex-encoded shared secret. - * @param ciphertext Base64 ciphertext. - * @returns Decrypted text. - */ -export async function decryptText( - secret: string, - ciphertext: string, -): Promise { - const decrypted = await decrypt(secret, base64ToBytes(ciphertext)); - return textDecoder.decode(decrypted); -} - -/** - * Computes an X448 shared secret from local and peer key material. - * @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form. - * @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form. - * @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form. - * @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid. - */ -export async function getSharedSecret( - ownPrivateKey: string, - ownPublicKey: string, - otherPublicKey: string, -): Promise { - const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey }; - const ownJwk: JWK = { - kty: "OKP", - crv: "X448", - x: ownPublicKey, - d: ownPrivateKey, - }; - - /** - * Converts bytes to a lowercase hex string. - * @param u8 Byte array. - * @returns Hex string. - */ - const bytesToHex = (u8: Uint8Array): string => - Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); - - /** - * Decodes standard base64 text into bytes. - * @param s Base64 string. - * @returns Decoded bytes. - */ - const b64ToBytes = (s: Base64URLString): Uint8Array => { - const bin = atob(s); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - }; - - /** - * Decodes URL-safe base64 text into bytes. - * @param s Base64url string. - * @returns Decoded bytes. - */ - const b64uToBytes = (s: Base64URLString): Uint8Array => { - const b64 = - s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); - return b64ToBytes(b64); - }; - - /** - * Encodes bytes as URL-safe base64 without padding. - * @param u8 Byte array. - * @returns Base64url string. - */ - const bytesToB64u = (u8: Uint8Array): string => { - const b64 = btoa(String.fromCharCode(...u8)); - return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); - }; - - /** - * Decodes either base64 or base64url text into bytes. - * @param s Base64/base64url string. - * @returns Decoded bytes. - */ - const decodeBase64Auto = (s: string): Uint8Array => - /[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s); - - /** - * Reads a DER TLV item from the provided offset. - * @param view DER-encoded bytes. - * @param off Start offset. - * @returns Parsed TLV metadata with tag, length, and boundaries. - */ - const readTLV = (view: Uint8Array, off: number) => { - const tag = view[off++]; - if (off >= view.length) throw new Error("DER: truncated"); - let len = view[off++]; - if (len & 0x80) { - const n = len & 0x7f; - if (n === 0) throw new Error("DER: indefinite length not supported"); - if (off + n > view.length) throw new Error("DER: truncated length"); - len = 0; - for (let i = 0; i < n; i++) len = (len << 8) | view[off++]; - } - const start = off; - const end = off + len; - if (end > view.length) throw new Error("DER: content truncated"); - return { tag, len, start, end }; - }; - - /** - * Validates that a DER OID matches X448. - * @param view DER-encoded bytes. - * @param start Offset of the OID TLV. - * @returns True when the OID is X448. - */ - const ensureOidX448 = (view: Uint8Array, start: number): boolean => { - const oid = readTLV(view, start); - if (oid.tag !== 0x06) return false; - const len = oid.end - oid.start; - if (len !== 3) return false; - return ( - view[oid.start] === 0x2b && - view[oid.start + 1] === 0x65 && - view[oid.start + 2] === 0x6f - ); - }; - - /** - * Extracts raw 56-byte X448 public key material from SPKI bytes. - * @param spkiBytes DER-encoded SPKI bytes. - * @returns Raw X448 public key bytes. - */ - const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => { - const view = spkiBytes; - const outer = readTLV(view, 0); - if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE"); - const alg = readTLV(view, outer.start); - if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier"); - if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448"); - const bitstr = readTLV(view, alg.end); - if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING"); - const unusedBits = view[bitstr.start]; - if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits"); - const raw = view.subarray(bitstr.start + 1, bitstr.end); - if (raw.length !== 56) - throw new Error("SPKI: X448 public key must be 56 bytes"); - return raw; - }; - - /** - * Extracts raw 56-byte X448 private key material from PKCS#8 bytes. - * @param pkcs8Bytes DER-encoded PKCS#8 bytes. - * @returns Raw X448 private key bytes. - */ - const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => { - const view = pkcs8Bytes; - const outer = readTLV(view, 0); - if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE"); - let off = outer.start; - - const version = readTLV(view, off); - if (version.tag !== 0x02) - throw new Error("PKCS8: expected version INTEGER"); - off = version.end; - - const alg = readTLV(view, off); - if (alg.tag !== 0x30) - throw new Error("PKCS8: expected AlgorithmIdentifier"); - if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448"); - off = alg.end; - - const priv = readTLV(view, off); - if (priv.tag !== 0x04) - throw new Error("PKCS8: expected privateKey OCTET STRING"); - let raw = view.subarray(priv.start, priv.end); - - // Some encoders nest another OCTET STRING inside - if (raw[0] === 0x04) { - const inner = readTLV(raw, 0); - if (inner.tag === 0x04) { - raw = raw.subarray(inner.start, inner.end); - } - } - if (raw.length !== 56) - throw new Error("PKCS8: X448 private key must be 56 bytes"); - return raw; - }; - - /** - * Normalizes X448 JWK fields into raw base64url key material. - * @param jwk Candidate JWK. - * @param label Error label for diagnostics. - * @returns Normalized JWK suitable for WebCrypto import. - */ - const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => { - if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") { - throw new Error(`${label}: expected OKP JWK with crv "X448"`); - } - const out = { ...jwk }; - - if (out.x) { - const xBytes = decodeBase64Auto(out.x); - let rawX: Uint8Array; - try { - rawX = extractRawX448FromSPKI(xBytes); - } catch { - if (xBytes.length !== 56) { - throw new Error( - `${label}: "x" is not a valid X448 SPKI or raw 56-byte key`, - ); - } - rawX = xBytes; - } - out.x = bytesToB64u(rawX); - } - - if (out.d) { - const dBytes = decodeBase64Auto(out.d); - let rawD: Uint8Array; - try { - rawD = extractRawX448FromPKCS8(dBytes); - } catch { - if (dBytes.length !== 56) { - throw new Error( - `${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`, - ); - } - rawD = dBytes; - } - out.d = bytesToB64u(rawD); - } - - return out; - }; - - const getSubtle = () => globalThis.crypto?.subtle; - - const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk"); - const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk"); - - const subtle = getSubtle(); - - if (subtle) { - const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }]; - - for (const algorithm of algorithms) { - try { - const [myPriv, peerPub] = await Promise.all([ - subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]), - subtle.importKey("jwk", peerJwk, algorithm, false, []), - ]); - - const sharedBits = await subtle.deriveBits( - { name: algorithm.name, public: peerPub }, - myPriv, - 448, - ); - - const sharedSecret = new Uint8Array(sharedBits); - //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); - - return bytesToHex(sharedSecret); - } catch { - // Browser doesn't support this algorithm, try next or fall through to software fallback - } - } - } - - const { d: dMyB64u } = myJwk; - //const { x: xMyB64u, d: dMyB64u } = myJwk; - const { x: xPeerB64u } = peerJwk; - - if (!dMyB64u || !xPeerB64u) { - return "Failed to get shared secret due to missing keys"; - } - - const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)]; - if (dRaw.length !== 56 || xRawPeer.length !== 56) { - return "Failed to get shared secret due to invalid key lengths"; - } - - const { x448 } = await import("@noble/curves/ed448.js"); - const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer)); - //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); - - return bytesToHex(sharedSecret); -} - -/** - * @deprecated Use getSharedSecret instead. - * @param ownPrivateKey Local private key. - * @param ownPublicKey Local public key. - * @param otherPublicKey Peer public key. - * @returns Shared secret derived by getSharedSecret. - */ -export async function get_shared_secret( - ownPrivateKey: string, - ownPublicKey: string, - otherPublicKey: string, -): Promise { - return await getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey); -} - -/** - * Checks whether the current runtime context is a worker global scope. - * @returns True when executed inside a worker-like runtime. - */ -function isWorkerRuntime(): boolean { - return "postMessage" in globalThis && "importScripts" in globalThis; -} - -if (isWorkerRuntime()) { - Comlink.expose({ - encrypt, - decrypt, - encryptText, - decryptText, - getSharedSecret, - }); -} diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index ddd1b15..5ca378c 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -45,6 +45,237 @@ 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 { + constructor( + private readonly load: ( + key: K, + ) => Promise | undefined>, + private readonly save: ( + key: K, + value: Record, + ) => Promise, + ) {} + + 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, > = { @@ -59,15 +290,38 @@ 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; @@ -134,7 +388,7 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean; }) { - const { load } = useStorage(); + const { load, save } = useStorage(); const [readyState, setReadyState] = useState( ConnectionState.Disconnected, @@ -149,9 +403,7 @@ export function Provider(props: { const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); - const clientRef = useRef> | null>( - null, - ); + const clientRef = useRef(null); const connected = readyState === ConnectionState.Connected; @@ -180,6 +432,57 @@ 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) { @@ -191,6 +494,34 @@ 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) { @@ -299,6 +630,7 @@ export function Provider(props: { await MTPClient.init(); + const userId = await load("user_id"); const forcedOmikronUrl = await load("forced_omikron_url"); const forcedOmikronPublicKey = await load("forced_omikron_public_key"); @@ -309,9 +641,7 @@ export function Provider(props: { omikronPublicKey = forcedOmikronPublicKey; } else { log(2, "mtp", "purple", "Fetching Omikron data."); - const data = await fetch( - `${mtpUrl}api/get/omikron/${await load("user_id")}`, - ); + const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`); if (data.status === 404) { sonnerToast.error("We couldn't reach your Iota", { @@ -350,16 +680,31 @@ export function Provider(props: { log(2, "mtp", "green", "Connecting to: " + url); - const client = await MTPClient.create({ + const encryptedDeviceSecretProvider = + new NetworkEncryptedDeviceSecretProvider(); + + const createMTPClient = MTPClient.create as unknown as ( + options: Record, + ) => Promise; + const client = await createMTPClient({ url, credentials: { - clientId: await load("user_id"), + clientId: BigInt(userId), keyring: base64ToUint8Array(await load("mtp_keyring")), }, hostPublicKey: omikronPublicKey, descriptor: "client", pings: true, - logger: (event) => { + encryptedDeviceSecretProvider, + sessionStorage: new LocalMTPSessionStorage( + load as never, + save as never, + ), + logger: (event: { + type: string; + data?: unknown; + direction?: "send" | "recv"; + }) => { if (event.type === "state") { setReadyState( clientRef.current?.state ?? ConnectionState.Disconnected, @@ -395,6 +740,8 @@ export function Provider(props: { return; } + encryptedDeviceSecretProvider.attach(client); + clientRef.current = client; setReadyState(client.state); await client.connect(); @@ -527,7 +874,7 @@ export function Provider(props: { setIdentifying(false); sonnerToast.dismiss("mtp-connection-toast"); }; - }, [mtpUrl, props.blockConnection, load]); + }, [mtpUrl, props.blockConnection, load, save]); // No Iota check useEffect(() => { @@ -561,8 +908,11 @@ export function Provider(props: { () => createAsyncQueue<{ send: typeof send; + sendEncrypted: typeof sendEncrypted; subscribe: typeof subscribe; subscribePush: typeof subscribePush; + subscribeEncrypted: typeof subscribeEncrypted; + decryptEncryptedRecord: typeof decryptEncryptedRecordForQueue; }>(), [], ); @@ -570,11 +920,25 @@ export function Provider(props: { if (connected && identified && mtpUrl) { mtpRef.set({ send, + sendEncrypted, subscribe, subscribePush, + subscribeEncrypted, + decryptEncryptedRecord: decryptEncryptedRecordForQueue, }); } - }, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]); + }, [ + connected, + identified, + mtpUrl, + send, + sendEncrypted, + subscribe, + subscribePush, + subscribeEncrypted, + decryptEncryptedRecordForQueue, + mtpRef, + ]); const sendQueued: BoundSendFn = useMemo( () => async (type, data, options) => { @@ -584,12 +948,32 @@ 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 ( ; @@ -241,6 +252,95 @@ export const mtp = { SenderId: z.number().optional(), }), }, + SetEncryptedDeviceSecret: { + request: z.object({ + UserId: z.string(), + DeviceId: z.string(), + SecretId: z.string(), + VersionNumber: z.number(), + EncryptedSecret: bytesLike, + WrappingPublicKeyId: z.string().optional(), + WrappingScheme: z.string(), + CreatedAt: z.number(), + }), + response: z.object({}), + }, + GetEncryptedDeviceSecret: { + request: z.object({ + UserId: z.string(), + DeviceId: z.string().optional(), + SecretId: z.string().optional(), + }), + response: z.object({}), + }, + EncryptedDeviceSecretResponse: { + request: z.object({}).optional(), + response: z.object({ + UserId: z.string(), + DeviceId: z.string(), + SecretId: z.string(), + VersionNumber: z.number(), + EncryptedSecret: bytesLike, + WrappingPublicKeyId: z.string().optional(), + WrappingScheme: z.string(), + CreatedAt: z.number(), + UpdatedAt: z.number(), + }), + }, + EncryptedMessage: { + request: z.object({ + MessageId: z.string(), + ConversationId: z.string(), + SenderClientId: clientIdLike, + RecipientClientId: clientIdLike, + SenderUserId: z.string().optional(), + RecipientUserId: z.string().optional(), + CreatedAt: z.number(), + EncryptionVersion: z.literal(1), + EncryptedPayload: bytesLike, + }), + response: z.object({}), + }, + EncryptedMessageAck: { + request: z.object({ + MessageId: z.string(), + ConversationId: z.string(), + RecipientClientId: clientIdLike, + SendTime: z.number().optional(), + GetTime: z.number().optional(), + }), + response: z.object({}), + }, + EncryptedMessagesGet: { + request: z.object({ + ConversationId: z.string().optional(), + PeerClientId: clientIdLike.optional(), + SenderUserId: z.string().optional(), + Since: z.number().optional(), + Limit: z.number().optional(), + }), + response: z.object({}), + }, + EncryptedMessagesResponse: { + request: z.object({}).optional(), + response: z.object({ + Messages: z.array( + z.object({ + MessageId: z.string(), + ConversationId: z.string(), + SenderClientId: clientIdLike, + RecipientClientId: clientIdLike, + SenderUserId: z.string().optional(), + RecipientUserId: z.string().optional(), + CreatedAt: z.number(), + EncryptionVersion: z.literal(1), + EncryptedPayload: bytesLike, + }), + ), + HasMore: z.boolean().optional(), + NextCursor: z.string().optional(), + }), + }, ErrorNoIota: { request: z.object({}).optional(), response: z.object({}), @@ -254,6 +354,8 @@ export interface Storage extends SettingsStorageDefaults { session_id: number; user_id: number; mtp_keyring: string; + e2ee_device_id: string; + e2ee_sessions: Record; ppandtos_done: boolean; accepted_terms_of_service: boolean; accepted_privacy_policy: boolean; @@ -288,6 +390,8 @@ 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/packages/tauth/src/context.tsx b/packages/tauth/src/context.tsx index e2ca153..9c1abcb 100644 --- a/packages/tauth/src/context.tsx +++ b/packages/tauth/src/context.tsx @@ -17,13 +17,12 @@ import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; import { Loader2 } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; -import { decryptText } from "@tensamin/crypto/worker"; export default function Wrapper({ children }: { children: ReactNode }) { const { get } = useUser(); const { load } = useStorage(); const { send } = useMTP(); - const { getSharedSecret } = useCrypto(); + const { decryptText, getSharedSecret } = useCrypto(); const { searchStr } = useLocation(); const navigate = useNavigate(); const [dialogOpen, setDialogOpen] = useState(false); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a0c4b..3687b75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,15 +603,6 @@ importers: packages/crypto: dependencies: - '@noble/curves': - specifier: ^2.0.1 - version: 2.2.0 - '@noble/hashes': - specifier: ^2.0.1 - version: 2.2.0 - comlink: - specifier: ^4.4.2 - version: 4.4.2 react: specifier: ^19.2.0 version: 19.2.7 @@ -1451,10 +1442,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@noble/curves@2.2.0': - resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} - engines: {node: '>= 20.19.0'} - '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -2534,9 +2521,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - comlink@4.4.2: - resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} - commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -5625,10 +5609,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@noble/curves@2.2.0': - dependencies: - '@noble/hashes': 2.2.0 - '@noble/hashes@1.4.0': {} '@noble/hashes@2.2.0': {} @@ -6675,8 +6655,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - comlink@4.4.2: {} - commander@11.1.0: {} commander@14.0.3: {} diff --git a/type-maps.yaml b/type-maps.yaml index 501a034..38b4d21 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -137,6 +137,13 @@ type_maps: AppChallengeResponse: 133 AppIdentificationResponse: 134 LoadTxtRecord: 135 + SetEncryptedDeviceSecret: 139 + GetEncryptedDeviceSecret: 140 + EncryptedDeviceSecretResponse: 141 + EncryptedMessage: 142 + EncryptedMessageAck: 143 + EncryptedMessagesGet: 144 + EncryptedMessagesResponse: 145 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -237,3 +244,25 @@ type_maps: AppData: 131 TauriToken: 132 Challenge: 133 + EncryptedPayload: 134 + SecurePayload: 135 + DeviceId: 136 + ClientId: 137 + SecretId: 142 + VersionNumber: 143 + EncryptedSecret: 144 + WrappingPublicKeyId: 145 + WrappingScheme: 146 + UpdatedAt: 147 + MessageId: 148 + ConversationId: 149 + SenderClientId: 150 + RecipientClientId: 151 + SenderUserId: 152 + RecipientUserId: 153 + EncryptionVersion: 154 + HasMore: 155 + NextCursor: 156 + Since: 157 + Limit: 158 + PeerClientId: 159 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..30e49b8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "**/.direnv/**", "**/dist/**"], + }, +});