From 8e831fd993839d9e0f3ec7648b2651c7a87bbd13 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 2 Jul 2026 23:56:49 +0200 Subject: [PATCH] (feat): prepare for new ident flow (fix): some bugs --- apps/web/src/index.tsx | 40 +++---- apps/web/vite.config.ts | 59 ++++++++- bun.lock | 4 +- package.json | 2 +- packages/call/src/store.tsx | 3 +- packages/mtp/src/context.tsx | 223 +++++++++++------------------------ packages/shared/src/data.ts | 75 +++++------- type-maps.yaml | 3 +- 8 files changed, 177 insertions(+), 232 deletions(-) diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 4d1e9ae..bbc399e 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -238,19 +238,9 @@ function RootShell() { - - - - - - - - - - - - - + + + @@ -264,13 +254,23 @@ function RootShell() { function AppShell() { return ( - - - - - - - + + + + + + + + + + + + + + + + + ); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 44697ad..a224653 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { defineConfig, type Plugin } from "vite"; +import { defineConfig, normalizePath, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { mtp } from "mtp/vite"; @@ -51,6 +51,38 @@ function deepFilterAssetHeaders(rootDir: string): Plugin { }; } +function restartOnMtpSourceChange(srcDir: string): Plugin { + const watchedDir = normalizePath(realpathSync(srcDir)); + + return { + name: "restart-on-mtp-source-change", + apply: "serve", + configureServer(server) { + server.watcher.add(watchedDir); + let restartTimer: ReturnType | null = null; + + const restart = (file: string) => { + if (!normalizePath(file).startsWith(`${watchedDir}/`)) { + return; + } + + if (restartTimer) { + clearTimeout(restartTimer); + } + + restartTimer = setTimeout(() => { + restartTimer = null; + void server.restart(); + }, 50); + }; + + server.watcher.on("add", restart); + server.watcher.on("change", restart); + server.watcher.on("unlink", restart); + }, + }; +} + export default defineConfig({ base: "./", clearScreen: false, @@ -105,7 +137,6 @@ export default defineConfig({ }, envPrefix: ["VITE_", "TAURI_ENV_*"], optimizeDeps: { - // Annoying Vite 8 stuff include: [ "react-redux", "use-sync-external-store/shim/with-selector", @@ -114,7 +145,28 @@ export default defineConfig({ "eventemitter3", "react-is", ], - exclude: ["mtp", "mtp/raw", "mtp/type-map", "@tensamin/shared"], + exclude: [ + "mtp", + "mtp/raw", + "mtp/type-map", + "@tensamin/call", + "@tensamin/call/utils", + "@tensamin/chat", + "@tensamin/crypto", + "@tensamin/crypto/context", + "@tensamin/crypto/worker", + "@tensamin/markdown", + "@tensamin/mtp", + "@tensamin/notifications", + "@tensamin/shared", + "@tensamin/shared/data", + "@tensamin/shared/log", + "@tensamin/storage", + "@tensamin/storage/context", + "@tensamin/tauri", + "@tensamin/tauth", + "@tensamin/user", + ], }, build: { minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false, @@ -122,6 +174,7 @@ export default defineConfig({ }, plugins: [ deepFilterAssetHeaders(resolve(appDir, "public")), + restartOnMtpSourceChange(resolve(appDir, "../../packages/mtp/src")), mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }), react(), tailwindcss(), diff --git a/bun.lock b/bun.lock index 466251e..0af5854 100644 --- a/bun.lock +++ b/bun.lock @@ -347,7 +347,7 @@ }, "overrides": { "@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz", - "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz", + "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz", }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -1540,7 +1540,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz", {}, "sha512-KIhmkIlALo0PdMnx/QzhM87jNCuC90DaQ7uEWpMjbkI3xvGWmkiX75zqt0UZcO03JYWfBAsCO4+51egoOUq6TA=="], + "mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz", {}, "sha512-W0l7Jc+1XI8BdrmRP9rlY/di6kZ5a/srv4mQvzwyJd+Tfg7BWZ9IUYUXIPze6ayx9dLY1f9Y71OgbhjstMzYjA=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], diff --git a/package.json b/package.json index 82550b9..c673b1a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "overrides": { "@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/latest/tensamin-ui.tgz", - "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c4a52c6/mtp-0.1.0.tgz" + "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-8ae8377/mtp-0.1.0.tgz" }, "dependencies": { "@tensamin/ui": "*", diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index c931a04..251071a 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -52,8 +52,7 @@ type IncomingCallInvite = { senderId: number; }; type CurrentCallData = - | (z.infer & { exists: boolean }) - | null; + (z.infer & { exists: boolean }) | null; type NavigateFn = (options: { to: string; diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index c81aa13..b6da558 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -13,8 +13,6 @@ import { onResume } from "tauri-plugin-app-events-api"; import { MTPClient } from "mtp"; import type { z } from "zod"; -import { decryptText } from "@tensamin/crypto/worker"; -import { useCrypto } from "@tensamin/crypto/context"; import { type Calls, type Communities, @@ -33,19 +31,21 @@ import { RETRY_INTERVAL, } from "./values"; -const APP_VERSION = "0.0.10"; - const READY_STATE = { CLOSED: 0, CONNECTING: 1, OPEN: 2, } as const; -const PUSH_TYPES = ["message_live", "message_state", "call_invite", "error_no_iota"] as const; +const PUSH_TYPES = [ + "message_live", + "message_state", + "call_invite", + "error_no_iota", +] as const; const WIRE_TYPES = { - identification: "AppIdentification", - challenge_response: "AppChallengeResponse", + temp_cool_type: "TempCoolType", get_user_data: "GetUserData", change_user_data: "ChangeUserData", ping: "AppPing", @@ -67,18 +67,9 @@ const APP_TYPES = Object.fromEntries( Object.entries(WIRE_TYPES).map(([appType, wireType]) => [wireType, appType]), ) as Record; -const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([ - "error", - "error_invalid_user_id", - "error_no_user_id", - "error_invalid_challenge", - "error_invalid_secret", - "error_invalid_private_key", - "error_invalid_public_key", - "error_not_authenticated", -]); - -export type ProtocolMessage = { +export type ProtocolMessage< + T extends keyof Schemas & string = keyof Schemas & string, +> = { id?: number; type: T | string; data: z.infer; @@ -114,29 +105,16 @@ function isTauriMobile() { return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent); } -function isFatalIdentificationError(error: unknown) { - if (typeof error === "object" && error !== null && "type" in error) { - const type = (error as { type?: unknown }).type; - if (typeof type === "string" && FATAL_IDENTIFICATION_ERROR_TYPES.has(type)) { - return true; - } - } - - return error instanceof Error && ( - error.message.includes("Missing or invalid user id") || - error.message.includes("Missing private key") || - error.message.includes("Identification challenge was rejected") || - error.message.includes("timed out after") || - error.message.includes("Response validation failed") - ); -} - function getProtocolErrorDetails(error: unknown) { if (typeof error !== "object" || error === null || !("type" in error)) { return null; } - const protocolError = error as { id?: unknown; type?: unknown; data?: unknown }; + const protocolError = error as { + id?: unknown; + type?: unknown; + data?: unknown; + }; return { id: protocolError.id, type: protocolError.type, @@ -193,7 +171,9 @@ function validateResponse( const parsed = schema.safeParse(data); if (!parsed.success) { - throw new Error(`Response validation failed for ${type}: ${parsed.error.message}`); + throw new Error( + `Response validation failed for ${type}: ${parsed.error.message}`, + ); } return { @@ -203,9 +183,11 @@ function validateResponse( } as ProtocolMessage; } -export function Provider(props: { children: ReactNode; blockConnection?: boolean }) { +export function Provider(props: { + children: ReactNode; + blockConnection?: boolean; +}) { const { load } = useStorage(); - const { decrypt, getSharedSecret } = useCrypto(); const [readyState, setReadyState] = useState(READY_STATE.CLOSED); const [connected, setConnected] = useState(false); @@ -222,9 +204,9 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); - const clientRef = useRef> | null>(null); - const identificationStartedRef = useRef(false); - const identificationCancelRef = useRef(false); + const clientRef = useRef> | null>( + null, + ); const [mtpUrl, setMtpUrl] = useState(null); useEffect(() => { @@ -281,7 +263,6 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean if (!connected) return; return subscribe("error_no_iota", () => { - identificationCancelRef.current = true; setIdentified(false); setIdentifying(false); setError("We couldn't reach your Iota"); @@ -381,13 +362,14 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean setConnected(false); setIdentified(false); setIdentifying(false); - identificationStartedRef.current = false; await MTPClient.init(); const client = await MTPClient.create({ url, pings: true, - logger: (event) => log(event.hint === "error" ? 0 : 2, "mtp", "blue", event), + logger: (event) => { + log(2, "mtp", event.type === "state" ? "cyan" : "blue", event.type === "state" ? event.data : event.type, event); + }, }); if (disposed) { @@ -403,13 +385,40 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean return; } + const authPayload = new Promise>( + (resolve, reject) => { + const unsubscribe = client.subscribe("TempCoolType", (message) => { + try { + unsubscribe(); + resolve(validateResponse("temp_cool_type", message)); + } catch (authPayloadError) { + unsubscribe(); + reject(authPayloadError); + } + }); + }, + ); + clearReconnectTimer(); scheduleReconnectReset(); - identificationCancelRef.current = false; setReadyState(READY_STATE.OPEN); setConnected(true); + setIdentifying(true); setError(""); setErrorDescription(""); + + await client.auth(); + const finalResponse = await authPayload; + + if (disposed || clientRef.current !== client) { + return; + } + + setFreshContacts(finalResponse.data.contacts); + setFreshCommunities(finalResponse.data.communities ?? []); + setFreshCalls(finalResponse.data.calls); + setIdentifying(false); + setIdentified(true); } catch (connectError) { if (disposed) return; @@ -420,7 +429,13 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean setConnected(false); setIdentified(false); setIdentifying(false); - log(0, "mtp", "red", "Connection attempt failed", connectError); + log( + 0, + "mtp", + "red", + "Connection/authentication attempt failed", + getProtocolErrorDetails(connectError) ?? connectError, + ); scheduleReconnect(connectError); } } @@ -464,116 +479,9 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean setConnected(false); setIdentified(false); setIdentifying(false); - identificationStartedRef.current = false; }; }, [mtpUrl, props.blockConnection]); - useEffect(() => { - if (!connected) { - identificationStartedRef.current = false; - return; - } - - if (identificationCancelRef.current || identificationStartedRef.current) { - return; - } - - identificationStartedRef.current = true; - let cancelled = false; - setIdentifying(true); - setIdentified(false); - - const identify = async () => { - try { - const sessionId = await load("session_id"); - const userId = await load("user_id"); - const privateKey = await load("private_key"); - - if ( - !Number.isSafeInteger(userId) || - userId <= 0 || - privateKey.trim() === "" || - !Number.isSafeInteger(sessionId) || - sessionId <= 0 - ) { - throw new Error("Invalid credentials"); - } - - const challengeEnvelope = await send("identification", { - version: APP_VERSION, - session_id: sessionId, - user_id: userId, - }); - - const sharedSecret = await getSharedSecret( - privateKey, - "", - challengeEnvelope.data.public_key, - ); - - const decryptedChallenge = await decryptText( - sharedSecret, - challengeEnvelope.data.challenge, - ); - - const finalResponse = await send("challenge_response", { - challenge: decryptedChallenge, - }).catch((challengeError) => { - if (!identificationCancelRef.current) { - setError("Identification Failed"); - setErrorDescription( - "Unable to complete secure identification. Please verify your credentials and try again.", - ); - } - throw challengeError; - }); - - setFreshContacts(finalResponse.data.contacts); - setFreshCommunities(finalResponse.data.communities ?? []); - setFreshCalls(finalResponse.data.calls); - - if (cancelled || identificationCancelRef.current) { - return; - } - - setError(""); - setErrorDescription(""); - setIdentified(true); - } catch (identificationError) { - if (cancelled || identificationCancelRef.current) { - return; - } - - const isFatal = isFatalIdentificationError(identificationError); - log( - isFatal ? 0 : 1, - "mtp", - isFatal ? "red" : "yellow", - "Identification handshake failed", - getProtocolErrorDetails(identificationError) ?? identificationError, - ); - - setIdentified(false); - setError("Identification Failed"); - setErrorDescription( - isFatal - ? "Unable to complete secure identification. Please verify your credentials and try again." - : "Unable to complete secure identification because the transport request failed.", - ); - } finally { - if (!cancelled && !identificationCancelRef.current) { - setIdentifying(false); - } - } - }; - - void identify(); - - return () => { - cancelled = true; - }; - }, [connected, decrypt, getSharedSecret, load, send]); - const progress = useMemo(() => { if (!mtpUrl) return 10; if (readyState === READY_STATE.CONNECTING) return 30; @@ -585,7 +493,8 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean const loadingTitle = useMemo(() => { if (!mtpUrl) return "Looking up configuration"; - if (readyState === READY_STATE.CONNECTING || !connected) return "Connecting to Tensamin"; + if (readyState === READY_STATE.CONNECTING || !connected) + return "Connecting to Tensamin"; if (identifying || !identified) return "Identifying secure session"; return "Loading"; }, [connected, identified, identifying, readyState, mtpUrl]); @@ -595,7 +504,7 @@ export function Provider(props: { children: ReactNode; blockConnection?: boolean if (readyState === READY_STATE.CONNECTING || !connected) { return "Establishing transport channel"; } - if (identifying || !identified) return "Verifying challenge-response handshake"; + if (identifying || !identified) return "Waiting for authenticated session"; return undefined; }, [connected, identified, identifying, readyState, mtpUrl]); diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 23c8d31..b284c14 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -39,13 +39,33 @@ export const failedUser = { username: "unknown", } as z.infer; -export type Contacts = z.infer< - typeof mtp.challenge_response.response.shape.contacts ->; -export type Communities = z.infer< - typeof mtp.challenge_response.response.shape.communities ->; -export type Calls = z.infer; +const authPayload = z.object({ + communities: z.array(z.object({})).optional(), + contacts: z.array( + z.object({ + last_message_at: z.number(), + user_id: z.number(), + last_message: z + .object({ + content: z.base64(), + sender_id: z.number(), + }) + .optional(), + messages: z.array(message), + }), + ), + calls: z.array( + z.object({ + call_id: z.string(), + call_secret: z.base64().optional(), + call_members: z.array(z.number()), + }), + ), +}); + +export type Contacts = z.infer; +export type Communities = z.infer; +export type Calls = z.infer; type Base16Palette = Record< | "base00" @@ -94,44 +114,9 @@ const user = z.object({ username: z.string().min(1).max(15), }); export const mtp = { - identification: { - request: z.object({ - version: z.string(), - session_id: z.number(), - user_id: z.number(), - }), - response: z.object({ - challenge: z.string(), - public_key: z.base64(), - }), - }, - challenge_response: { - request: z.object({ - challenge: z.base64(), - }), - response: z.object({ - communities: z.array(z.object({})).optional(), - contacts: z.array( - z.object({ - last_message_at: z.number(), - user_id: z.number(), - last_message: z - .object({ - content: z.base64(), - sender_id: z.number(), - }) - .optional(), - messages: z.array(message), - }), - ), - calls: z.array( - z.object({ - call_id: z.string(), - call_secret: z.base64().optional(), - call_members: z.array(z.number()), - }), - ), - }), + temp_cool_type: { + request: z.object({}).optional(), + response: authPayload, }, get_user_data: { request: z.object({ diff --git a/type-maps.yaml b/type-maps.yaml index 15f30cc..b542504 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -3,8 +3,7 @@ protocol_version: "0.0" type_maps: "0.0": CommunicationTypes: - AppIdentification: 32 - AppChallengeResponse: 33 + TempCoolType: 32 GetUserData: 34 ChangeUserData: 35 MessageLive: 36