diff --git a/public/notifications.xml b/public/notifications.xml index 1a171d7c..b8f6b88d 100644 --- a/public/notifications.xml +++ b/public/notifications.xml @@ -8,7 +8,22 @@ Mon, 29 Sep 2025 18:00:00 MST - + + notification-055 + Backend issues have been fixed! + You are now able to log into your account again! Hopefully less downtime going forward! + +We decided to move the backend (previously server.fifthwit.net) to a new server at backend.pstream.mov. + +All of your account data has been migrated to the new server, so you can log in with existing passphrase. + +We've also introduced some new backend servers, so you can now choose which one you want to use. Sorry for all the downtime! + + Mon, 29 Dec 2025 13:30:00 MST + announcement + + + notification-054 P-Stream v5.3.3 released! Merry Christmas everyone! 🎄 diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index c7c4637a..11da017a 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -158,7 +158,8 @@ "customPassphrasePlaceholder": "Enter your custom passphrase", "useCustomPassphrase": "Use Custom Passphrase", "invalidPassphraseCharacters": "Invalid passphrase characters. Only English letters, numbers 1-10, and normal symbols are allowed.", - "passphraseTooShort": "Passphrase must be at least 8 characters long." + "passphraseTooShort": "Passphrase must be at least 8 characters long.", + "usePasskeyInstead": "Use passkey instead" }, "hasAccount": "Already have an account? <0>Login here.", "login": { @@ -168,7 +169,10 @@ "passphrasePlaceholder": "Passphrase", "submit": "Login", "title": "Login to your account", - "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\" + "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\", + "usePasskey": "Use passkey", + "or": "or", + "noBackendUrl": "No backend URL" }, "register": { "information": { @@ -209,7 +213,10 @@ "passphraseLabel": "Your 12-word passphrase", "recaptchaFailed": "ReCaptcha validation failed", "register": "Create account", - "title": "Confirm your passphrase" + "title": "Confirm your account", + "passkeyDescription": "Please authenticate with your passkey to complete registration.", + "authenticatePasskey": "Authenticate with Passkey", + "passkeyError": "Passkey verification failed" } }, "errors": { diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index ca5b72fd..f98fc5da 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -152,3 +152,217 @@ export function decryptData(data: string, secret: Uint8Array) { return decipher.output.toString(); } + +// Passkey/WebAuthn utilities + +export function isPasskeySupported(): boolean { + // Passkeys require HTTPS + const isSecureContext = + typeof window !== "undefined" && window.location.protocol === "https:"; + + return ( + isSecureContext && + 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/backend/accounts/meta.ts b/src/backend/accounts/meta.ts index 0886dc11..af515e95 100644 --- a/src/backend/accounts/meta.ts +++ b/src/backend/accounts/meta.ts @@ -9,7 +9,14 @@ export interface MetaResponse { } export async function getBackendMeta(url: string): Promise { - return ofetch("/meta", { + const meta = await ofetch("/meta", { baseURL: url, }); + + // Remove escaped backslashes before apostrophes (e.g., \' becomes ') + return { + ...meta, + name: meta.name.replace(/\\'/g, "'"), + description: meta.description?.replace(/\\'/g, "'"), + }; } diff --git a/src/components/form/BackendSelector.tsx b/src/components/form/BackendSelector.tsx index 9587f968..9b7e63f5 100644 --- a/src/components/form/BackendSelector.tsx +++ b/src/components/form/BackendSelector.tsx @@ -80,6 +80,9 @@ function BackendOptionItem({ ) : option.meta ? (

{option.meta.name}

+

+ {option.meta.description} +

{hostname}

) : ( diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 79e3dd66..87774de4 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -308,8 +308,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { }); hls.on(Hls.Events.LEVEL_SWITCHED, () => { if (!hls) return; - const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); - emit("changedquality", quality); + if (automaticQuality) { + // Only emit quality changes when automatic quality is enabled + const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); + emit("changedquality", quality); + } else { + // When automatic quality is disabled, re-lock to preferred quality + // This prevents HLS.js from switching levels unexpectedly + setupQualityForHls(); + } }); hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => { for (const [lang, resolve] of languagePromises) { 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/index.tsx b/src/index.tsx index 26bdf087..a9823b58 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -161,7 +161,6 @@ function AuthWrapper() { const backendUrl = conf().BACKEND_URL; const userBackendUrl = useBackendUrl(); const { t } = useTranslation(); - const isLoggedIn = !!useAuthStore((s) => s.account); const isCustomUrl = backendUrl !== userBackendUrl; diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 1ec7364a..cdbbd7b9 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -48,12 +48,23 @@ export function RegisterPage() { ? [config.BACKEND_URL] : []; - const [step, setStep] = useState(-1); + // If there's only one backend and user hasn't selected a custom one, auto-select it + const defaultBackend = + currentBackendUrl ?? + (availableBackends.length === 1 ? availableBackends[0] : null); + + const [step, setStep] = useState( + availableBackends.length > 1 || !defaultBackend ? -1 : 0, + ); 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( - currentBackendUrl ?? null, + currentBackendUrl ?? defaultBackend ?? null, ); const handleBackendSelect = (url: string | null) => { @@ -67,7 +78,7 @@ export function RegisterPage() { - {step === -1 ? ( + {step === -1 && (availableBackends.length > 1 || !defaultBackend) ? ( {t("auth.backendSelection.description")} @@ -113,6 +124,12 @@ export function RegisterPage() { { setMnemonic(m); + setAuthMethod("mnemonic"); + setStep(2); + }} + onPasskeyNext={(credId) => { + setCredentialId(credId); + setAuthMethod("passkey"); setStep(2); }} /> @@ -129,7 +146,10 @@ export function RegisterPage() { { navigate("/"); }} diff --git a/src/pages/parts/auth/LoginFormPart.tsx b/src/pages/parts/auth/LoginFormPart.tsx index ac63fad2..c8b6e00c 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,12 @@ export function LoginFormPart(props: LoginFormPartProps) { {t("auth.login.description")}
+ - - {result.error && !result.loading ? ( + {isPasskeySupported() && ( +
+
+
+
+
+
+ + {t("auth.login.or")} + +
+
+ +
+ )} + {(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..498772f7 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} +

+ )} +
+ )} diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx index 880d7fcc..68930868 100644 --- a/src/pages/parts/auth/VerifyPassphrasePart.tsx +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -3,6 +3,7 @@ import { useGoogleReCaptcha } from "react-google-recaptcha-v3"; import { useTranslation } from "react-i18next"; import { useAsyncFn } from "react-use"; +import { authenticatePasskey } from "@/backend/accounts/crypto"; import { updateSettings } from "@/backend/accounts/settings"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; @@ -24,8 +25,11 @@ import { useThemeStore } from "@/stores/theme"; interface VerifyPassphraseProps { mnemonic: string | null; + credentialId: string | null; + authMethod: "mnemonic" | "passkey"; hasCaptcha?: boolean; userData: AccountProfile | null; + backendUrl: string | null; onNext?: () => void; } @@ -73,6 +77,64 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { const { executeRecaptcha } = useGoogleReCaptcha(); + const [passkeyResult, authenticatePasskeyFn] = useAsyncFn(async () => { + if (!props.backendUrl) + throw new Error(t("auth.verify.noBackendUrl") ?? undefined); + if (!props.userData) + throw new Error(t("auth.verify.invalidData") ?? undefined); + + // Validate credential ID is a non-empty string + if ( + !props.credentialId || + typeof props.credentialId !== "string" || + props.credentialId.length === 0 + ) { + throw new Error( + t("auth.verify.invalidData") ?? "Invalid passkey credential", + ); + } + + let recaptchaToken: string | undefined; + if (props.hasCaptcha) { + recaptchaToken = executeRecaptcha ? await executeRecaptcha() : undefined; + if (!recaptchaToken) + throw new Error(t("auth.verify.recaptchaFailed") ?? undefined); + } + + // Authenticate with passkey using the credential ID from registration + const assertion = await authenticatePasskey(props.credentialId); + + // Verify the credential ID matches + if (assertion.id !== props.credentialId) { + throw new Error( + t("auth.verify.noMatch") ?? "Passkey verification failed", + ); + } + + const account = await register({ + credentialId: props.credentialId, + userData: props.userData, + recaptchaToken, + }); + + if (!account) + throw new Error(t("auth.verify.registrationFailed") ?? undefined); + + await importData(account, progressItems, bookmarkItems); + + await updateSettings(props.backendUrl, account, { + applicationLanguage, + defaultSubtitleLanguage: defaultSubtitleLanguage ?? undefined, + applicationTheme: applicationTheme ?? undefined, + proxyUrls: undefined, + ...preferences, + }); + + await restore(account); + + props.onNext?.(); + }, [props, register, restore, executeRecaptcha]); + const [result, execute] = useAsyncFn( async (inputMnemonic: string) => { if (!backendUrl) @@ -115,9 +177,41 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { props.onNext?.(); }, - [props, register, restore], + [props, register, restore, executeRecaptcha], ); + if (props.authMethod === "passkey") { + return ( + +
+ } + title={t("auth.verify.title")} + > + {t("auth.verify.passkeyDescription")} + + {passkeyResult.error ? ( +

+ {t("auth.verify.passkeyError")} +

+ ) : null} + + + +
+
+ ); + } + return (