diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 87d0fc3..c0e6a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules releases -.fallow/ +.fallow +.direnv diff --git a/bun.lock b/bun.lock index 0af5854..376d438 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-8ae8377/mtp-0.1.0.tgz", + "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/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-8ae8377/mtp-0.1.0.tgz", {}, "sha512-W0l7Jc+1XI8BdrmRP9rlY/di6kZ5a/srv4mQvzwyJd+Tfg7BWZ9IUYUXIPze6ayx9dLY1f9Y71OgbhjstMzYjA=="], + "mtp": ["mtp@https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/mtp-0.1.0.tgz", {}, "sha512-j/RGQY9/3NXzZptXbY44Kv7YnGr38CQQOehRAzPPFvnEVj+kxCwzVLgSImT765+vfgl2L+bkAE9aI2PWwOBTow=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], diff --git a/flake.nix b/flake.nix index 8cd7777..55990bb 100644 --- a/flake.nix +++ b/flake.nix @@ -206,7 +206,7 @@ ]; }; - commonDeps = [pkgs.wasm-pack pkgs.lld]; + commonDeps = [rustToolchain pkgs.wasm-pack pkgs.lld]; in rec { default = electron; @@ -256,7 +256,6 @@ buildInputs = with pkgs; [ jdk17 - rustToolchain gradle bun nodejs diff --git a/package.json b/package.json index c673b1a..4b90957 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-8ae8377/mtp-0.1.0.tgz" + "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.1.0-dev-c3fbeb2/mtp-0.1.0.tgz" }, "dependencies": { "@tensamin/ui": "*", diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index b6da558..bcdcbaa 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { onResume } from "tauri-plugin-app-events-api"; -import { MTPClient } from "mtp"; +import { ConnectionState, MTPClient } from "mtp"; import type { z } from "zod"; import { @@ -31,12 +31,6 @@ import { RETRY_INTERVAL, } from "./values"; -const READY_STATE = { - CLOSED: 0, - CONNECTING: 1, - OPEN: 2, -} as const; - const PUSH_TYPES = [ "message_live", "message_state", @@ -153,6 +147,7 @@ function mapDataKeys(value: unknown, mapKey: (key: string) => string): unknown { ); } +// Zod schema validation function validateResponse( type: T, message: { id?: number; type: string; data: unknown }, @@ -189,8 +184,9 @@ export function Provider(props: { }) { const { load } = useStorage(); - const [readyState, setReadyState] = useState(READY_STATE.CLOSED); - const [connected, setConnected] = useState(false); + const [readyState, setReadyState] = useState( + ConnectionState.Disconnected, + ); const [identified, setIdentified] = useState(false); const [identifying, setIdentifying] = useState(false); @@ -208,11 +204,15 @@ export function Provider(props: { null, ); + const connected = readyState === ConnectionState.Connected; + + // MTP url const [mtpUrl, setMtpUrl] = useState(null); useEffect(() => { load("mtp_url").then(setMtpUrl); }, [load]); + // Validation override functions const send: BoundSendFn = useMemo( () => async (type, data, options) => { const client = clientRef.current; @@ -259,6 +259,7 @@ export function Provider(props: { }; }, []); + // No Iota check useEffect(() => { if (!connected) return; @@ -272,6 +273,7 @@ export function Provider(props: { }); }, [connected, subscribe]); + // Custom Pings useEffect(() => { if (!connected || !identified) { return; @@ -297,11 +299,9 @@ export function Provider(props: { }; }, [connected, identified, send]); + // Reconnect stuff useEffect(() => { - if (!mtpUrl) { - return; - } - const url = mtpUrl; + if (!mtpUrl) return; let attempts = 0; let reconnectTimer: ReturnType | null = null; @@ -358,17 +358,28 @@ export function Provider(props: { } try { - setReadyState(READY_STATE.CONNECTING); - setConnected(false); setIdentified(false); setIdentifying(false); await MTPClient.init(); const client = await MTPClient.create({ - url, + url: mtpUrl ?? "", + descriptor: "client", pings: true, logger: (event) => { - log(2, "mtp", event.type === "state" ? "cyan" : "blue", event.type === "state" ? event.data : event.type, event); + if (event.type === "state") { + setReadyState( + clientRef.current?.state ?? ConnectionState.Disconnected, + ); + } + + log( + 2, + "mtp", + event.type === "state" ? "cyan" : "blue", + event.type === "state" ? event.data : event.type, + event, + ); }, }); @@ -378,6 +389,7 @@ export function Provider(props: { } clientRef.current = client; + setReadyState(client.state); await client.connect(); if (disposed) { @@ -401,8 +413,7 @@ export function Provider(props: { clearReconnectTimer(); scheduleReconnectReset(); - setReadyState(READY_STATE.OPEN); - setConnected(true); + setReadyState(client.state); setIdentifying(true); setError(""); setErrorDescription(""); @@ -425,8 +436,7 @@ export function Provider(props: { clientRef.current?.disconnect(); clientRef.current = null; clearReconnectResetTimer(); - setReadyState(READY_STATE.CLOSED); - setConnected(false); + setReadyState(ConnectionState.Disconnected); setIdentified(false); setIdentifying(false); log( @@ -475,16 +485,16 @@ export function Provider(props: { clientRef.current?.disconnect(); clientRef.current = null; - setReadyState(READY_STATE.CLOSED); - setConnected(false); + setReadyState(ConnectionState.Disconnected); setIdentified(false); setIdentifying(false); }; }, [mtpUrl, props.blockConnection]); + // Loading bar const progress = useMemo(() => { if (!mtpUrl) return 10; - if (readyState === READY_STATE.CONNECTING) return 30; + if (readyState === ConnectionState.Connecting) return 30; if (!connected) return 45; if (identifying) return 75; if (!identified) return 90; @@ -493,7 +503,7 @@ export function Provider(props: { const loadingTitle = useMemo(() => { if (!mtpUrl) return "Looking up configuration"; - if (readyState === READY_STATE.CONNECTING || !connected) + if (readyState === ConnectionState.Connecting || !connected) return "Connecting to Tensamin"; if (identifying || !identified) return "Identifying secure session"; return "Loading"; @@ -501,13 +511,14 @@ export function Provider(props: { const loadingDescription = useMemo(() => { if (!mtpUrl) return "Loading connection details"; - if (readyState === READY_STATE.CONNECTING || !connected) { + if (readyState === ConnectionState.Connecting || !connected) { return "Establishing transport channel"; } if (identifying || !identified) return "Waiting for authenticated session"; return undefined; }, [connected, identified, identifying, readyState, mtpUrl]); + // Return if (error !== "" && errorDescription !== "") { return ; } diff --git a/packages/tauth/src/context.tsx b/packages/tauth/src/context.tsx index 641a278..31ca22a 100644 --- a/packages/tauth/src/context.tsx +++ b/packages/tauth/src/context.tsx @@ -11,7 +11,7 @@ import { DialogHeader, DialogTitle, } from "@tensamin/ui"; -import { useLocation } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; @@ -25,28 +25,40 @@ export default function Wrapper({ children }: { children: ReactNode }) { const { send } = useMTP(); const { getSharedSecret } = useCrypto(); const { searchStr } = useLocation(); + const navigate = useNavigate(); const [dialogOpen, setDialogOpen] = useState(false); const [loading, setLoading] = useState(false); const [identifier, setIdentifier] = useState(null); const [redirect, setRedirect] = useState(null); const [challenge, setChallenge] = useState(null); + const [appPublicKey, setAppPublicKey] = useState(null); const [allowChildern, setAllowChildern] = useState(false); const { deeplinks } = useDeeplinks(); const authorizeApp = async () => { - if (!identifier || !redirect || !challenge) return; + if (!identifier || !redirect || !challenge || !appPublicKey) return; try { // Get Data const user = await get(await load("user_id")); const { - data: { content: appPublicKey }, + data: { content: appPublicKeyHash }, } = await send("load_txt_record", { path: "tauth." + identifier, }); + const verifiedAppPublicKeyHash = await sha256Hex(appPublicKey); + if (normalizeHash(appPublicKeyHash) !== verifiedAppPublicKeyHash) { + log(1, "tauth", "red", "App public key hash mismatch", undefined, { + appPublicKey, + appPublicKeyHash, + verifiedAppPublicKeyHash, + }); + throw new Error("App public key hash mismatch"); + } + // Get Shared Secret const sharedSecret = await getSharedSecret( await load("private_key"), @@ -75,7 +87,7 @@ export default function Wrapper({ children }: { children: ReactNode }) { finalUrl.searchParams.set("challenge", solvedChallenge); finalUrl.searchParams.set("originalChallenge", challenge); - const session = Date.now(); + const session = new Date().getTime(); finalUrl.searchParams.set("sessionId", String(session)); // Save Session @@ -92,11 +104,14 @@ export default function Wrapper({ children }: { children: ReactNode }) { setIdentifier(null); setRedirect(null); setChallenge(null); + setAppPublicKey(null); toast("success", "App authorized successfully"); return; } else { - window.location.href = finalUrl.toString(); + navigate({ + to: finalUrl.toString(), + }); return; } } catch (err) { @@ -108,9 +123,27 @@ export default function Wrapper({ children }: { children: ReactNode }) { setIdentifier(null); setRedirect(null); setChallenge(null); + setAppPublicKey(null); } }; + async function sha256Hex(value: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return [...new Uint8Array(hash)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + + function normalizeHash(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^sha256[:=]/, ""); + } + function hexToBase64(hex: string): string { const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? []; const binaryString = String.fromCharCode(...bytes); @@ -122,6 +155,7 @@ export default function Wrapper({ children }: { children: ReactNode }) { const identifier = params.get("identifier"); const redirect = params.get("redirect"); const challenge = params.get("challenge"); + const appPublicKey = params.get("public_key"); if (!identifier || !redirect) { setAllowChildern(true); return; @@ -135,10 +169,18 @@ export default function Wrapper({ children }: { children: ReactNode }) { return; } + if (!appPublicKey) { + toast("error", "Missing app public key"); + log(1, "tauth", "red", "Missing app public key"); + setAllowChildern(true); + return; + } + setAllowChildern(true); setIdentifier(identifier); setRedirect(redirect); setChallenge(hexToBase64(challenge || "")); + setAppPublicKey(appPublicKey); setDialogOpen(true); }); }, [searchStr, load]); @@ -149,11 +191,13 @@ export default function Wrapper({ children }: { children: ReactNode }) { const url = new URL(link); const identifier = url.searchParams.get("identifier"); const redirect = url.searchParams.get("redirect"); + const appPublicKey = url.searchParams.get("public_key"); - if (identifier && redirect) { + if (identifier && redirect && appPublicKey) { setIdentifier(identifier); setRedirect(redirect); setChallenge(hexToBase64(url.searchParams.get("challenge") || "")); + setAppPublicKey(appPublicKey); setDialogOpen(true); } } @@ -170,6 +214,7 @@ export default function Wrapper({ children }: { children: ReactNode }) { setIdentifier(null); setRedirect(null); setChallenge(null); + setAppPublicKey(null); } }} > @@ -205,6 +250,7 @@ export default function Wrapper({ children }: { children: ReactNode }) { setIdentifier(null); setRedirect(null); setChallenge(null); + setAppPublicKey(null); }} > Deny diff --git a/todo.md b/todo.md index 837b847..d3665c2 100644 --- a/todo.md +++ b/todo.md @@ -1 +1,2 @@ - Move legal to extra onboarding package +- Add a bunch of tests