From b7e08b505f8d893299d43c4719720975de728e59 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:02:35 -0700 Subject: [PATCH] meow --- src/backend/accounts/crypto.ts | 209 ++++++++++++++++++ src/hooks/auth/useAuth.ts | 52 ++++- src/pages/Register.tsx | 13 ++ src/pages/parts/auth/LoginFormPart.tsx | 83 ++++++- .../parts/auth/PassphraseGeneratePart.tsx | 51 ++++- src/pages/parts/auth/VerifyPassphrasePart.tsx | 96 +++++++- 6 files changed, 494 insertions(+), 10 deletions(-) diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index ca5b72fd..fa7e045a 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -152,3 +152,212 @@ export function decryptData(data: string, secret: Uint8Array) { return decipher.output.toString(); } + +// Passkey/WebAuthn utilities + +export function isPasskeySupported(): boolean { + return ( + typeof navigator !== "undefined" && + "credentials" in navigator && + "create" in navigator.credentials && + "get" in navigator.credentials && + typeof PublicKeyCredential !== "undefined" + ); +} + +function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer { + if (typeof base64Url !== "string") { + throw new Error( + `Invalid credential ID: expected string, got ${typeof base64Url}`, + ); + } + // Convert base64url to base64 + let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + // Add padding if needed + while (base64.length % 4) { + base64 += "="; + } + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +export interface PasskeyCredential { + id: string; + rawId: ArrayBuffer; + response: AuthenticatorAttestationResponse; +} + +export interface PasskeyAssertion { + id: string; + rawId: ArrayBuffer; + response: AuthenticatorAssertionResponse; +} + +export async function createPasskey( + userId: string, + userName: string, +): Promise { + if (!isPasskeySupported()) { + throw new Error("Passkeys are not supported in this browser"); + } + + // Generate a random user ID (8 bytes) + const userIdBuffer = new Uint8Array(8); + crypto.getRandomValues(userIdBuffer); + + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = + { + challenge, + rp: { + name: "P-Stream", + id: window.location.hostname, + }, + user: { + id: userIdBuffer, + name: userName, + displayName: userName, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + { alg: -257, type: "public-key" }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: "platform", + userVerification: "preferred", + }, + timeout: 60000, + attestation: "none", + }; + + try { + const credential = (await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + })) as PublicKeyCredential | null; + + if (!credential) { + throw new Error("Failed to create passkey"); + } + + return { + id: credential.id, + rawId: credential.rawId, + response: credential.response as AuthenticatorAttestationResponse, + }; + } catch (error) { + throw new Error( + `Failed to create passkey: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function authenticatePasskey( + credentialId?: string, +): Promise { + if (!isPasskeySupported()) { + throw new Error("Passkeys are not supported in this browser"); + } + + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = + credentialId && typeof credentialId === "string" && credentialId.length > 0 + ? [ + { + id: base64UrlToArrayBuffer(credentialId), + type: "public-key", + }, + ] + : undefined; + + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { + challenge, + timeout: 60000, + userVerification: "preferred", + allowCredentials, + rpId: window.location.hostname, + }; + + try { + const assertion = (await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + })) as PublicKeyCredential | null; + + if (!assertion) { + throw new Error("Failed to authenticate with passkey"); + } + + return { + id: assertion.id, + rawId: assertion.rawId, + response: assertion.response as AuthenticatorAssertionResponse, + }; + } catch (error) { + throw new Error( + `Failed to authenticate with passkey: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function seedFromCredentialId(credentialId: string): Promise { + // Hash credential ID the same way we hash mnemonics + return pbkdf2Async(sha256, credentialId, "mnemonic", { + c: 2048, + dkLen: 32, + }); +} + +export async function keysFromCredentialId( + credentialId: string, +): Promise { + const seed = await seedFromCredentialId(credentialId); + return keysFromSeed(seed); +} + +// Storage helpers for credential mappings +const STORAGE_PREFIX = "__MW::passkey::"; + +function getStorageKey(backendUrl: string, publicKey: string): string { + return `${STORAGE_PREFIX}${backendUrl}::${publicKey}`; +} + +export function storeCredentialMapping( + backendUrl: string, + publicKey: string, + credentialId: string, +): void { + if (typeof window === "undefined" || !window.localStorage) { + throw new Error("localStorage is not available"); + } + const key = getStorageKey(backendUrl, publicKey); + localStorage.setItem(key, credentialId); +} + +export function getCredentialId( + backendUrl: string, + publicKey: string, +): string | null { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + const key = getStorageKey(backendUrl, publicKey); + return localStorage.getItem(key); +} + +export function removeCredentialMapping( + backendUrl: string, + publicKey: string, +): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + const key = getStorageKey(backendUrl, publicKey); + localStorage.removeItem(key); +} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 32150c38..029b45f9 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -6,8 +6,11 @@ import { bytesToBase64, bytesToBase64Url, encryptData, + getCredentialId, + keysFromCredentialId, keysFromMnemonic, signChallenge, + storeCredentialMapping, } from "@/backend/accounts/crypto"; import { getGroupOrder } from "@/backend/accounts/groupOrder"; import { importBookmarks, importProgress } from "@/backend/accounts/import"; @@ -33,7 +36,8 @@ import { ProgressMediaItem } from "@/stores/progress"; export interface RegistrationData { recaptchaToken?: string; - mnemonic: string; + mnemonic?: string; + credentialId?: string; userData: { device: string; profile: { @@ -45,7 +49,8 @@ export interface RegistrationData { } export interface LoginData { - mnemonic: string; + mnemonic?: string; + credentialId?: string; userData: { device: string; }; @@ -65,8 +70,23 @@ export function useAuth() { const login = useCallback( async (loginData: LoginData) => { if (!backendUrl) return; - const keys = await keysFromMnemonic(loginData.mnemonic); + if (!loginData.mnemonic && !loginData.credentialId) { + throw new Error("Either mnemonic or credentialId must be provided"); + } + + const keys = loginData.credentialId + ? await keysFromCredentialId(loginData.credentialId) + : await keysFromMnemonic(loginData.mnemonic!); const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); + + // Try to get credential ID from storage if using mnemonic + let credentialId: string | null = null; + if (loginData.mnemonic) { + credentialId = getCredentialId(backendUrl, publicKeyBase64Url); + } else { + credentialId = loginData.credentialId || null; + } + const { challenge } = await getLoginChallengeToken( backendUrl, publicKeyBase64Url, @@ -83,6 +103,12 @@ export function useAuth() { const user = await getUser(backendUrl, loginResult.token); const seedBase64 = bytesToBase64(keys.seed); + + // Store credential mapping if we have a credential ID + if (credentialId) { + storeCredentialMapping(backendUrl, publicKeyBase64Url, credentialId); + } + return userDataLogin(loginResult, user.user, user.session, seedBase64); }, [userDataLogin, backendUrl], @@ -120,22 +146,38 @@ export function useAuth() { const register = useCallback( async (registerData: RegistrationData) => { if (!backendUrl) return; + if (!registerData.mnemonic && !registerData.credentialId) { + throw new Error("Either mnemonic or credentialId must be provided"); + } + const { challenge } = await getRegisterChallengeToken( backendUrl, registerData.recaptchaToken, ); - const keys = await keysFromMnemonic(registerData.mnemonic); + const keys = registerData.credentialId + ? await keysFromCredentialId(registerData.credentialId) + : await keysFromMnemonic(registerData.mnemonic!); const signature = await signChallenge(keys, challenge); + const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); const registerResult = await registerAccount(backendUrl, { challenge: { code: challenge, signature, }, - publicKey: bytesToBase64Url(keys.publicKey), + publicKey: publicKeyBase64Url, device: await encryptData(registerData.userData.device, keys.seed), profile: registerData.userData.profile, }); + // Store credential mapping if we have a credential ID + if (registerData.credentialId) { + storeCredentialMapping( + backendUrl, + publicKeyBase64Url, + registerData.credentialId, + ); + } + return userDataLogin( registerResult, registerResult.user, diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 1ec7364a..4cf31226 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -50,6 +50,10 @@ export function RegisterPage() { const [step, setStep] = useState(-1); const [mnemonic, setMnemonic] = useState(null); + const [credentialId, setCredentialId] = useState(null); + const [authMethod, setAuthMethod] = useState<"mnemonic" | "passkey">( + "mnemonic", + ); const [account, setAccount] = useState(null); const [siteKey, setSiteKey] = useState(null); const [selectedBackendUrl, setSelectedBackendUrl] = useState( @@ -113,6 +117,12 @@ export function RegisterPage() { { setMnemonic(m); + setAuthMethod("mnemonic"); + setStep(2); + }} + onPasskeyNext={(credId) => { + setCredentialId(credId); + setAuthMethod("passkey"); setStep(2); }} /> @@ -129,7 +139,10 @@ export function RegisterPage() { { navigate("/"); }} diff --git a/src/pages/parts/auth/LoginFormPart.tsx b/src/pages/parts/auth/LoginFormPart.tsx index ac63fad2..ecb2d868 100644 --- a/src/pages/parts/auth/LoginFormPart.tsx +++ b/src/pages/parts/auth/LoginFormPart.tsx @@ -3,8 +3,13 @@ import { Trans, useTranslation } from "react-i18next"; import { useAsyncFn } from "react-use"; import type { AsyncReturnType } from "type-fest"; -import { verifyValidMnemonic } from "@/backend/accounts/crypto"; +import { + authenticatePasskey, + isPasskeySupported, + verifyValidMnemonic, +} from "@/backend/accounts/crypto"; import { Button } from "@/components/buttons/Button"; +import { Icon, Icons } from "@/components/Icon"; import { BrandPill } from "@/components/layout/BrandPill"; import { LargeCard, @@ -14,6 +19,7 @@ import { import { MwLink } from "@/components/text/Link"; import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { useAuth } from "@/hooks/auth/useAuth"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useBookmarkStore } from "@/stores/bookmarks"; import { useProgressStore } from "@/stores/progress"; @@ -25,10 +31,51 @@ export function LoginFormPart(props: LoginFormPartProps) { const [mnemonic, setMnemonic] = useState(""); const [device, setDevice] = useState(""); const { login, restore, importData } = useAuth(); + const backendUrl = useBackendUrl(); const progressItems = useProgressStore((store) => store.items); const bookmarkItems = useBookmarkStore((store) => store.bookmarks); const { t } = useTranslation(); + const [passkeyResult, executePasskey] = useAsyncFn( + async (inputDevice: string) => { + if (!backendUrl) { + throw new Error(t("auth.login.noBackendUrl") ?? "No backend URL"); + } + + const validatedDevice = inputDevice.trim(); + if (validatedDevice.length === 0) + throw new Error(t("auth.login.deviceLengthError") ?? undefined); + + // Authenticate with passkey (no credential ID specified, browser will show all available) + const assertion = await authenticatePasskey(); + const credentialId = assertion.id; + + let account: AsyncReturnType; + try { + account = await login({ + credentialId, + userData: { + device: validatedDevice, + }, + }); + } catch (err) { + if ((err as any).status === 401) + throw new Error(t("auth.login.validationError") ?? undefined); + throw err; + } + + if (!account) + throw new Error(t("auth.login.validationError") ?? undefined); + + await importData(account, progressItems, bookmarkItems); + + await restore(account); + + props.onLogin?.(); + }, + [props, login, restore, backendUrl, t], + ); + const [result, execute] = useAsyncFn( async (inputMnemonic: string, inputdevice: string) => { if (!verifyValidMnemonic(inputMnemonic)) @@ -70,6 +117,34 @@ export function LoginFormPart(props: LoginFormPartProps) { {t("auth.login.description")}
+ {isPasskeySupported() && ( +
+ +
+
+
+
+
+ + {t("auth.login.or") ?? "or"} + +
+
+
+ )} - {result.error && !result.loading ? ( + {(result.error || passkeyResult.error) && + !result.loading && + !passkeyResult.loading ? (

- {result.error.message} + {result.error?.message || passkeyResult.error?.message}

) : null}
diff --git a/src/pages/parts/auth/PassphraseGeneratePart.tsx b/src/pages/parts/auth/PassphraseGeneratePart.tsx index 444b217f..ff20b03a 100644 --- a/src/pages/parts/auth/PassphraseGeneratePart.tsx +++ b/src/pages/parts/auth/PassphraseGeneratePart.tsx @@ -1,7 +1,12 @@ import { useCallback, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { useAsyncFn } from "react-use"; -import { genMnemonic } from "@/backend/accounts/crypto"; +import { + createPasskey, + genMnemonic, + isPasskeySupported, +} from "@/backend/accounts/crypto"; import { Button } from "@/components/buttons/Button"; import { PassphraseDisplay } from "@/components/form/PassphraseDisplay"; import { Icon, Icons } from "@/components/Icon"; @@ -13,6 +18,7 @@ import { interface PassphraseGeneratePartProps { onNext?: (mnemonic: string) => void; + onPasskeyNext?: (credentialId: string) => void; } export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { @@ -23,6 +29,29 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { setMnemonic(customPassphrase); }, []); + const [passkeyResult, createPasskeyFn] = useAsyncFn(async () => { + if (!isPasskeySupported()) { + throw new Error("Passkeys are not supported in this browser"); + } + + const credential = await createPasskey( + `user-${Date.now()}`, + "P-Stream User", + ); + return credential.id; + }, []); + + const handlePasskeyClick = useCallback(async () => { + try { + const credentialId = await createPasskeyFn(); + if (credentialId) { + props.onPasskeyNext?.(credentialId); + } + } catch (error) { + // Error is handled by passkeyResult.error + } + }, [createPasskeyFn, props]); + return ( + {isPasskeySupported() && ( +
+ + {passkeyResult.error && ( +

+ {passkeyResult.error.message} +

+ )} +
+ )} + + + +
+ ); + } + return (