add passkey support for login and register

commit f75666a47b
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 17:10:44 2025 -0700

    more minor ui changes

commit 7f9b91ea46
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 16:51:32 2025 -0700

    oopsie

commit bd7029723d
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 16:47:44 2025 -0700

    more ui tweaks

commit 4f86020ca0
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 16:40:17 2025 -0700

    ui tweaks

commit 4a8c756eb0
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 16:31:28 2025 -0700

    passkey translations

commit b7e08b505f
Author: Pas <74743263+Pasithea0@users.noreply.github.com>
Date:   Mon Dec 29 16:02:35 2025 -0700

    meow
This commit is contained in:
Pas 2025-12-29 17:15:09 -07:00
parent f09e71c16d
commit 474999f54a
7 changed files with 514 additions and 19 deletions

View file

@ -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.</0>",
"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": {

View file

@ -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<PasskeyCredential> {
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<PasskeyAssertion> {
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<Uint8Array> {
// 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<Keys> {
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);
}

View file

@ -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,

View file

@ -50,6 +50,10 @@ export function RegisterPage() {
const [step, setStep] = useState(-1);
const [mnemonic, setMnemonic] = useState<null | string>(null);
const [credentialId, setCredentialId] = useState<null | string>(null);
const [authMethod, setAuthMethod] = useState<"mnemonic" | "passkey">(
"mnemonic",
);
const [account, setAccount] = useState<null | AccountProfile>(null);
const [siteKey, setSiteKey] = useState<string | null>(null);
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
@ -113,6 +117,12 @@ export function RegisterPage() {
<PassphraseGeneratePart
onNext={(m) => {
setMnemonic(m);
setAuthMethod("mnemonic");
setStep(2);
}}
onPasskeyNext={(credId) => {
setCredentialId(credId);
setAuthMethod("passkey");
setStep(2);
}}
/>
@ -129,7 +139,10 @@ export function RegisterPage() {
<VerifyPassphrase
hasCaptcha={!!siteKey}
mnemonic={mnemonic}
credentialId={credentialId}
authMethod={authMethod}
userData={account}
backendUrl={selectedBackendUrl}
onNext={() => {
navigate("/");
}}

View file

@ -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<typeof login>;
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")}
</LargeCardText>
<div className="space-y-4">
<AuthInputBox
label={t("auth.deviceNameLabel") ?? undefined}
value={device}
onChange={setDevice}
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
/>
<AuthInputBox
label={t("auth.login.passphraseLabel") ?? undefined}
value={mnemonic}
@ -79,15 +132,39 @@ export function LoginFormPart(props: LoginFormPartProps) {
placeholder={t("auth.login.passphrasePlaceholder") ?? undefined}
passwordToggleable
/>
<AuthInputBox
label={t("auth.deviceNameLabel") ?? undefined}
value={device}
onChange={setDevice}
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
/>
{result.error && !result.loading ? (
{isPasskeySupported() && (
<div className="relative mb-4">
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-authentication-border/50" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-authentication-bg text-authentication-text">
{t("auth.login.or")}
</span>
</div>
</div>
<Button
theme="secondary"
onClick={() => executePasskey(device)}
loading={passkeyResult.loading}
disabled={
passkeyResult.loading ||
result.loading ||
device.trim().length === 0
}
className="w-full"
>
<Icon icon={Icons.LOCK} className="mr-2" />
{t("auth.login.usePasskey")}
</Button>
</div>
)}
{(result.error || passkeyResult.error) &&
!result.loading &&
!passkeyResult.loading ? (
<p className="text-authentication-errorText">
{result.error.message}
{result.error?.message || passkeyResult.error?.message}
</p>
) : null}
</div>

View file

@ -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 (
<LargeCard>
<LargeCardText
@ -42,6 +71,25 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
/>
<LargeCardButtons>
{isPasskeySupported() && (
<div className="mt-4">
<Button
theme="purple"
onClick={handlePasskeyClick}
loading={passkeyResult.loading}
disabled={passkeyResult.loading}
className="w-full"
>
<Icon icon={Icons.LOCK} className="mr-2" />
{t("auth.generate.usePasskeyInstead")}
</Button>
{passkeyResult.error && (
<p className="mt-2 text-authentication-errorText text-sm text-center">
{passkeyResult.error.message}
</p>
)}
</div>
)}
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
{t("auth.generate.next")}
</Button>

View file

@ -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 (
<LargeCard>
<form>
<LargeCardText
icon={<Icon icon={Icons.CIRCLE_CHECK} />}
title={t("auth.verify.title")}
>
{t("auth.verify.passkeyDescription")}
</LargeCardText>
{passkeyResult.error ? (
<p className="mt-3 text-authentication-errorText">
{t("auth.verify.passkeyError")}
</p>
) : null}
<LargeCardButtons>
<Button
theme="purple"
loading={passkeyResult.loading}
onClick={() => authenticatePasskeyFn()}
>
{!passkeyResult.loading && (
<Icon icon={Icons.LOCK} className="mr-2" />
)}
{t("auth.verify.authenticatePasskey")}
</Button>
</LargeCardButtons>
</form>
</LargeCard>
);
}
return (
<LargeCard>
<form>