(feat): add tauth

(fix): formatted
This commit is contained in:
Alois 2026-04-14 01:12:34 +02:00
commit fb742bd068
15 changed files with 141 additions and 74 deletions

View file

@ -6,11 +6,7 @@ import { log } from "@tensamin/shared/log";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Clock } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { useIsMobile } from "@tensamin/ui";
import {
ContextMenu,
@ -36,7 +32,11 @@ function getSafeMessageHeight(height: number | undefined) {
* @param props Parameter props.
* @returns unknown.
*/
function MessageComponent({ message, notEncrypted, measureRef }: {
function MessageComponent({
message,
notEncrypted,
measureRef,
}: {
message: RawMessage & {
failed?: boolean;
};
@ -113,19 +113,17 @@ function MessageComponent({ message, notEncrypted, measureRef }: {
: "bg-muted"
} ${!isReady && "animate-pulse"} ${isMobile && "select-none"}`}
>
{message.failed &&
message.message_state === "awaiting" && (
<Tooltip>
<TooltipContent>
<p>Failed to send message</p>
</TooltipContent>
<TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip>
)}
{!message.failed &&
message.message_state === "awaiting" && (
<Clock size={17} />
)}
{message.failed && message.message_state === "awaiting" && (
<Tooltip>
<TooltipContent>
<p>Failed to send message</p>
</TooltipContent>
<TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip>
)}
{!message.failed && message.message_state === "awaiting" && (
<Clock size={17} />
)}
{isReady ? (
<Text value={decodedContent} />
) : (

View file

@ -25,10 +25,7 @@ function bytesToB64u(value: Uint8Array): string {
output += index + 2 < value.length ? alphabet[chunk & 63] : "=";
}
return output
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
return output.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
/**

View file

@ -1,7 +1,10 @@
import { z } from "zod";
import { legalDocsSchema } from "./features/legal/schema";
import { settingsStorageDefaults, type SettingsStorageDefaults } from "./settings";
import {
settingsStorageDefaults,
type SettingsStorageDefaults,
} from "./settings";
const fileFromMessage = z.object({
name: z.string(),
@ -155,6 +158,14 @@ export const ttp = {
}),
},
load_txt_record: {
request: z.object({
path: z.string(),
}),
response: z.object({
content: z.string(),
}),
},
authenticate_app: {
request: z.object({
app_identifier: z.string(),
@ -163,13 +174,12 @@ export const ttp = {
challenge: z.base64(),
}),
},
get_app: {
create_app: {
request: z.object({
app_public_key: z.base64(),
app_identifier: z.string(),
}),
response: z.object({
app_public_key: z.base64(),
}),
response: z.object({}),
},
// Calls

View file

@ -49,10 +49,11 @@ type BooleanSettingNames<T extends SettingsSchema> = {
export type SettingsStorageKey<T extends SettingsSchema = typeof settings> =
`settings.${BooleanSettingNames<T>}`;
export type SettingsStorageDefaults<T extends SettingsSchema = typeof settings> =
{
[K in SettingsStorageKey<T>]: boolean;
};
export type SettingsStorageDefaults<
T extends SettingsSchema = typeof settings,
> = {
[K in SettingsStorageKey<T>]: boolean;
};
function buildSettingsStorageDefaults<T extends SettingsSchema>(
schema: T,
@ -62,7 +63,10 @@ function buildSettingsStorageDefaults<T extends SettingsSchema>(
for (const category of Object.values(schema)) {
for (const page of Object.values(category)) {
for (const [settingName, setting] of Object.entries(page)) {
if (setting.type === "boolean" && typeof setting.default === "boolean") {
if (
setting.type === "boolean" &&
typeof setting.default === "boolean"
) {
const key = `settings.${settingName}` as SettingsStorageKey<T>;
defaults[key] = setting.default;
}
@ -73,4 +77,4 @@ function buildSettingsStorageDefaults<T extends SettingsSchema>(
return defaults as SettingsStorageDefaults<T>;
}
export const settingsStorageDefaults = buildSettingsStorageDefaults(settings);
export const settingsStorageDefaults = buildSettingsStorageDefaults(settings);

View file

@ -13,6 +13,7 @@
},
"dependencies": {
"@tanstack/react-router": "^1.0.0",
"@tauri-apps/api": "^2.10.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -16,77 +16,128 @@ import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import { useTTP } from "@tensamin/ttp";
import { log, toast } from "@tensamin/shared/log";
import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
export default function Wrapper({ children }: { children: ReactNode }) {
const { getSharedSecret } = useCrypto();
const { get } = useUser();
const { load } = useStorage();
const { send } = useTTP();
const { decrypt } = useCrypto();
const { decrypt, getSharedSecret } = useCrypto();
const { searchStr } = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [identifier, setIdentifier] = useState<string | null>(null);
const [redirect, setRedirect] = useState<string | null>(null);
const [challenge, setChallenge] = useState<string | null>(null);
const [allowChildern, setAllowChildern] = useState(false);
const { deeplinks } = useDeeplinks();
const authorizeApp = async () => {
if (!identifier || !redirect) return;
if (!identifier || !redirect || !challenge) return;
try {
// Get Data
const user = await get(await load("user_id"));
const app = await send("get_app", {
app_identifier: identifier,
const {
data: { content: appPublicKey },
} = await send("load_txt_record", {
path: "tauth." + identifier,
});
// Get Shared Secret
const sharedSecret = await getSharedSecret(
await load("private_key"),
user.public_key,
app.data.app_public_key,
);
// Get Challenge
const res = await send("authenticate_app", {
app_identifier: identifier,
appPublicKey,
).catch((err) => {
log(1, "tauth", "red", "Failed to get shared secret", err, {
appPublicKey,
});
throw new Error("Failed to get shared secret");
});
const challenge = res.data.challenge;
// Solve Challenge
const solvedChallenge = await decrypt(sharedSecret, challenge);
const solvedChallenge = await decrypt(sharedSecret, challenge).catch(
(err) => {
log(1, "tauth", "red", "Failed to solve challenge", err, {
challenge,
});
throw new Error("Failed to solve challenge");
},
);
// Craft Redirect URL
const finalUrl = new URL(redirect);
finalUrl.searchParams.set("userId", String(await load("user_id")));
finalUrl.searchParams.set("challenge", solvedChallenge);
finalUrl.searchParams.set("originalChallenge", challenge);
const session = crypto.randomUUID();
finalUrl.searchParams.set("session", session);
const session = Date.now();
finalUrl.searchParams.set("sessionId", String(session));
// Save Session
await send("create_app", {
app_public_key: appPublicKey,
app_identifier: identifier,
});
// Open Redirect URL
window.open(finalUrl.toString(), "_blank");
if (isTauri()) {
window.open(finalUrl.toString(), "_blank");
setLoading(false);
setDialogOpen(false);
setIdentifier(null);
setRedirect(null);
setChallenge(null);
toast("success", "App authorized successfully");
return;
} else {
window.location.href = finalUrl.toString();
return;
}
} catch (err) {
toast("error", "Failed to authorize app");
log(1, "tauth", "red", "Failed to authorize app", err);
} finally {
setDialogOpen(false);
setLoading(false);
setDialogOpen(false);
setIdentifier(null);
setRedirect(null);
setChallenge(null);
}
};
function hexToBase64(hex: string): string {
const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? [];
const binaryString = String.fromCharCode(...bytes);
return btoa(binaryString);
}
useEffect(() => {
const params = new URLSearchParams(searchStr);
const identifier = params.get("identifier");
const redirect = params.get("redirect");
const challenge = params.get("challenge");
if (!identifier || !redirect) return;
// @eslint-disable-next-line react-hooks/exhaustive-deps
setIdentifier(identifier);
setRedirect(redirect);
setDialogOpen(true);
}, [searchStr]);
load("user_id").then((userId) => {
if (!challenge) {
const newUrl = new URL(redirect);
newUrl.searchParams.set("userId", String(userId));
window.location.href = newUrl.toString();
return;
}
setAllowChildern(true);
setIdentifier(identifier);
setRedirect(redirect);
setChallenge(hexToBase64(challenge || ""));
setDialogOpen(true);
});
}, [searchStr, load]);
useEffect(() => {
deeplinks.forEach((link) => {
@ -98,6 +149,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
if (identifier && redirect) {
setIdentifier(identifier);
setRedirect(redirect);
setChallenge(hexToBase64(url.searchParams.get("challenge") || ""));
setDialogOpen(true);
}
}
@ -113,6 +165,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
if (!value) {
setIdentifier(null);
setRedirect(null);
setChallenge(null);
}
}}
>
@ -147,6 +200,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
setDialogOpen(false);
setIdentifier(null);
setRedirect(null);
setChallenge(null);
}}
>
Deny
@ -154,7 +208,7 @@ export default function Wrapper({ children }: { children: ReactNode }) {
</DialogFooter>
</DialogContent>
</Dialog>
{children}
{allowChildern && children}
</>
);
}

View file

@ -11,7 +11,11 @@ import {
import { useCrypto } from "@tensamin/crypto/context";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import { createTransportClient, READY_STATE, type BoundSendFn } from "@tensamin/ttp-core";
import {
createTransportClient,
READY_STATE,
type BoundSendFn,
} from "@tensamin/ttp-core";
import type { PushHandler } from "@tensamin/ttp-core";
import {
PING_INTERVAL,

View file

@ -1,3 +1,3 @@
export * from "@tensamin/ttp-core";
export * from "./context";
export * from "./values";
export * from "./values";