From d726d2fd65e660269e0fcf678e404056b660256f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 18:36:20 +0000 Subject: [PATCH] feat(portal): passkeys as a second factor Adds the page the app opens in the browser to create or use a passkey (/2fa/passkey), a passkey button at the portal's own sign-in, and the same button where a password change asks for a second factor. The page takes a one-time handle from the URL fragment, drops it from the address bar, shows the account and what was asked for, and sends the browser on to the address the server names once the passkey has answered. Sign-in, sign-up and the new page now share one page shell. --- portal/app/2fa/passkey/page.tsx | 120 ++++++++++++++++++ portal/app/AuthShell.tsx | 33 +++++ portal/app/TwoFactorField.tsx | 27 ++++ portal/app/account/ChangePasswordModal.tsx | 37 ++++-- portal/app/signin/page.tsx | 139 ++++++++++----------- portal/app/signup/page.tsx | 108 +++++++--------- portal/lib/api.ts | 67 +++++++++- portal/lib/passkey.ts | 100 +++++++++++++++ 8 files changed, 484 insertions(+), 147 deletions(-) create mode 100644 portal/app/2fa/passkey/page.tsx create mode 100644 portal/app/AuthShell.tsx create mode 100644 portal/lib/passkey.ts diff --git a/portal/app/2fa/passkey/page.tsx b/portal/app/2fa/passkey/page.tsx new file mode 100644 index 0000000..e6c458a --- /dev/null +++ b/portal/app/2fa/passkey/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import AuthShell, { PRIMARY_BUTTON } from "../../AuthShell"; +import { beginPasskey, type PasskeyCeremony, type PasskeyPurpose } from "../../../lib/api"; +import { askPasskey, passkeyMessage, passkeysSupported } from "../../../lib/passkey"; + +const COPY: Record string; action: string }> = { + sign_in: { + title: "Finish signing in", + body: (email) => `Use your passkey to finish signing in to Voltius as ${email}.`, + action: "Use passkey", + }, + step_up: { + title: "Confirm it's you", + body: (email) => `Use your passkey to confirm a change to the Voltius account ${email}.`, + action: "Use passkey", + }, + register: { + title: "Add a passkey", + body: (email) => `Create a passkey for the Voltius account ${email}.`, + action: "Create passkey", + }, +}; + +type Phase = + | { kind: "loading" } + | { kind: "ready"; ceremony: PasskeyCeremony; error: string; working: boolean } + | { kind: "done"; openApp?: string } + | { kind: "stopped"; message: string }; + +export default function PasskeyPage() { + const fragment = useRef(null); + const handle = useRef(""); + const [phase, setPhase] = useState({ kind: "loading" }); + + useEffect(() => { + // The handle is a one-time secret: read it once, then keep it out of the address bar and history. + if (fragment.current === null) { + fragment.current = window.location.hash.slice(1); + window.history.replaceState(null, "", window.location.pathname); + } + if (fragment.current === "done") { + setPhase({ kind: "done" }); + return; + } + handle.current = new URLSearchParams(fragment.current).get("h") ?? ""; + if (!handle.current) { + setPhase({ kind: "stopped", message: "This page only works when Voltius opens it. Go back to the app and try again." }); + return; + } + if (!passkeysSupported()) { + setPhase({ kind: "stopped", message: "This browser cannot use passkeys. Go back to Voltius and use a code instead." }); + return; + } + let current = true; + beginPasskey(handle.current) + .then((ceremony) => current && setPhase({ kind: "ready", ceremony, error: "", working: false })) + .catch((err) => current && setPhase({ kind: "stopped", message: `${passkeyMessage(err)} Go back to Voltius and try again.` })); + return () => { + current = false; + }; + }, []); + + async function run(ceremony: PasskeyCeremony) { + setPhase({ kind: "ready", ceremony, error: "", working: true }); + try { + const { redirect } = await askPasskey(handle.current, ceremony); + if (!redirect) throw new Error("Nothing asked for this passkey."); + const toApp = redirect.startsWith("voltius:"); + setPhase({ kind: "done", openApp: toApp ? redirect : undefined }); + window.location.href = redirect; + } catch (err) { + setPhase({ kind: "ready", ceremony, error: passkeyMessage(err), working: false }); + } + } + + if (phase.kind === "loading") { + return ( + +

Loading…

+
+ ); + } + if (phase.kind === "stopped") { + return ( + +

Passkey

+

{phase.message}

+
+ ); + } + if (phase.kind === "done") { + return ( + +

Done

+

Go back to Voltius. You can close this tab.

+ {phase.openApp && ( + + Open Voltius + + )} +
+ ); + } + + const { ceremony, error, working } = phase; + const copy = COPY[ceremony.purpose]; + return ( + +

{copy.title}

+

{copy.body(ceremony.email)}

+

Only continue if you just asked for this in the Voltius app on this device.

+ {error &&

{error}

} + +
+ ); +} diff --git a/portal/app/AuthShell.tsx b/portal/app/AuthShell.tsx new file mode 100644 index 0000000..43ae01e --- /dev/null +++ b/portal/app/AuthShell.tsx @@ -0,0 +1,33 @@ +import Image from "next/image"; +import type { ReactNode } from "react"; + +/** The centred card every signed-out page sits in. */ +export default function AuthShell({ children, above, below }: { + children: ReactNode; + above?: ReactNode; + below?: ReactNode; +}) { + return ( +
+
+
+
+
+ +
+ + Voltius + Voltius + + {above} +
+ {children} +
+ {below} +
+
+ ); +} + +export const PRIMARY_BUTTON = + "w-full py-3 rounded-xl bg-cyan-500 hover:bg-cyan-400 disabled:bg-[#1e1e2e] disabled:text-zinc-500 disabled:cursor-default text-black font-semibold text-sm transition-all duration-200 hover:shadow-[0_0_24px_rgba(6,182,212,0.4)]"; diff --git a/portal/app/TwoFactorField.tsx b/portal/app/TwoFactorField.tsx index 701db88..8326fd7 100644 --- a/portal/app/TwoFactorField.tsx +++ b/portal/app/TwoFactorField.tsx @@ -1,6 +1,7 @@ "use client"; import type { TwoFactorProof } from "../lib/api"; +import { passkeysSupported } from "../lib/passkey"; export interface TwoFactorEntry { recovery: boolean; @@ -17,6 +18,32 @@ export function entryProof(entry: TwoFactorEntry): TwoFactorProof | null { const ALL_METHODS = ["totp", "recovery"]; +export function twoFactorPrompt(methods: string[]): string { + const typed = methods.includes("totp") + ? "a code from your authenticator app or a recovery code" + : "one of your recovery codes"; + return methods.includes("passkey") && passkeysSupported() ? `Use your passkey, or enter ${typed}.` : `Enter ${typed}.`; +} + +/** The alternative to typing a code, for an account with a passkey in a browser that can use one. */ +export function PasskeyButton({ methods, onClick, disabled }: { + methods: string[]; + onClick: () => void; + disabled?: boolean; +}) { + if (!methods.includes("passkey") || !passkeysSupported()) return null; + return ( + + ); +} + export function TwoFactorField({ entry, onChange, methods = ALL_METHODS, autoFocus }: { entry: TwoFactorEntry; onChange: (entry: TwoFactorEntry) => void; diff --git a/portal/app/account/ChangePasswordModal.tsx b/portal/app/account/ChangePasswordModal.tsx index ed5f134..abbf12c 100644 --- a/portal/app/account/ChangePasswordModal.tsx +++ b/portal/app/account/ChangePasswordModal.tsx @@ -2,8 +2,13 @@ import { useState, type FormEvent } from "react"; import { deriveAuthKey, deriveKek, wrapUserSecrets, base64ToBytes, bytesToBase64, unwrapUserSecretsDek, unwrapUserSecretsX25519 } from "../../lib/crypto"; -import { getMe, updatePassword, twoFactorMessage, ApiError } from "../../lib/api"; -import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField"; +import { + getFactorMethods, getMe, passkeyStepUpHandle, updatePassword, twoFactorMessage, ApiError, type TwoFactorProof, +} from "../../lib/api"; +import { inlinePasskeyCode } from "../../lib/passkey"; +import { + EMPTY_TWO_FACTOR_ENTRY, PasskeyButton, TwoFactorField, entryProof, twoFactorPrompt, type TwoFactorEntry, +} from "../TwoFactorField"; interface Props { accountId: string; @@ -19,11 +24,15 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [done, setDone] = useState(false); - const [needsCode, setNeedsCode] = useState(false); + const [methods, setMethods] = useState(null); const [twoFactor, setTwoFactor] = useState(EMPTY_TWO_FACTOR_ENTRY); - async function handleSubmit(e: FormEvent) { + function handleSubmit(e: FormEvent) { e.preventDefault(); + return change(async () => entryProof(twoFactor)); + } + + async function change(prove: () => Promise) { if (!currentPassword) { setError("Current password required"); return; } if (newPassword.length < 8) { setError("New password must be at least 8 characters"); return; } if (newPassword === currentPassword) { setError("New password must differ from current"); return; } @@ -38,10 +47,11 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce setLoading(false); return; } - const proof = entryProof(twoFactor); + const proof = await prove(); if (me.two_factor_enabled && !proof) { - setNeedsCode(true); - setError("Enter a code from your authenticator app to confirm the change."); + const offered = await getFactorMethods(token); + setMethods(offered); + setError(twoFactorPrompt(offered)); setLoading(false); return; } @@ -68,7 +78,7 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce } catch (err) { const refusal = twoFactorMessage(err); if (refusal) { - setNeedsCode(true); + setMethods((offered) => offered ?? ["totp", "recovery"]); setError(refusal); setTwoFactor((entry) => ({ ...entry, value: "" })); } else if (err instanceof ApiError && err.status === 401) { @@ -99,7 +109,16 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" /> setConfirmPassword(e.target.value)} className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" /> - {needsCode && } + {methods && ( + <> + change(async () => ({ passkeyCode: await inlinePasskeyCode(() => passkeyStepUpHandle(token)) }))} + /> + + + )} {error &&

{error}

}
diff --git a/portal/app/signin/page.tsx b/portal/app/signin/page.tsx index 0e3deb0..a9ac23f 100644 --- a/portal/app/signin/page.tsx +++ b/portal/app/signin/page.tsx @@ -2,13 +2,16 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import Image from "next/image"; +import AuthShell, { PRIMARY_BUTTON } from "../AuthShell"; import { deriveAuthKey } from "../../lib/crypto"; import { - ApiError, getChallenge, login, loginSecondFactor, twoFactorMessage, + ApiError, getChallenge, login, loginSecondFactor, passkeySignInHandle, twoFactorMessage, type AuthResponse, type TwoFactorChallenge, } from "../../lib/api"; -import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField"; +import { inlinePasskeyCode } from "../../lib/passkey"; +import { + EMPTY_TWO_FACTOR_ENTRY, PasskeyButton, TwoFactorField, entryProof, twoFactorPrompt, type TwoFactorEntry, +} from "../TwoFactorField"; export default function SigninPage() { const router = useRouter(); @@ -74,82 +77,74 @@ export default function SigninPage() { return attempt(async () => enter(await loginSecondFactor(challenge.pending_token, proof))); } - return ( -
- {/* Ambient glow */} -
-
-
- {/* Grid */} -
- -
- {/* Logo */} - - Voltius - Voltius - - - {/* Card */} -
-

{challenge ? "Two-factor authentication" : "Welcome back"}

-

- {challenge - ? "Enter the code from your authenticator app, or one of your recovery codes." - : "Your password never leaves your device."} -

- - {challenge ? ( -
- - - {error && ( -

{error}

- )} - - - - - ) : ( -
- - - - {error && ( -

{error}

- )} - - - - )} -
+ function handlePasskey() { + if (!challenge) return; + const { pending_token } = challenge; + return attempt(async () => { + const passkeyCode = await inlinePasskeyCode(() => passkeySignInHandle(pending_token)); + enter(await loginSecondFactor(pending_token, { passkeyCode })); + }); + } + return ( + No account?{" "} Create one

-
-
+ } + > +

{challenge ? "Two-factor authentication" : "Welcome back"}

+

+ {challenge ? twoFactorPrompt(challenge.methods) : "Your password never leaves your device."} +

+ + {challenge ? ( +
+ + + + {error && ( +

{error}

+ )} + + + + + ) : ( +
+ + + + {error && ( +

{error}

+ )} + + + + )} + ); } diff --git a/portal/app/signup/page.tsx b/portal/app/signup/page.tsx index 846fd3e..eadab55 100644 --- a/portal/app/signup/page.tsx +++ b/portal/app/signup/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, Suspense } from "react"; import { useRouter } from "next/navigation"; -import Image from "next/image"; +import AuthShell, { PRIMARY_BUTTON } from "../AuthShell"; import { deriveAuthKey, deriveKek, generateUserSecrets, wrapUserSecrets, bytesToBase64 } from "../../lib/crypto"; import { register } from "../../lib/api"; @@ -64,78 +64,62 @@ function SignupContent() { } return ( -
- {/* Ambient glow */} -
-
-
- {/* Grid */} -
- -
- {/* Logo */} - - Voltius - Voltius - - - {/* Badges */} + E2EE Zero-knowledge Free to start
- - {/* Card */} -
-

Create your account

-

- Your master password never leaves your device. -

- -
- - - - - {error &&

{error}

} - - - -

- By continuing you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - . -

- -
- + } + below={

Already have an account?{" "} Sign in

-
-
+ } + > +

Create your account

+

+ Your master password never leaves your device. +

+ +
+ + + + + {error &&

{error}

} + + + +

+ By continuing you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + . +

+ + ); } diff --git a/portal/lib/api.ts b/portal/lib/api.ts index 41df15f..8734a3f 100644 --- a/portal/lib/api.ts +++ b/portal/lib/api.ts @@ -68,11 +68,13 @@ export interface MeResponse { two_factor_enabled?: boolean; } -export type TwoFactorProof = { code: string } | { recoveryCode: string }; +export type TwoFactorProof = { code: string } | { recoveryCode: string } | { passkeyCode: string }; -function proofBody(proof?: TwoFactorProof | null): { code?: string; recovery_code?: string } { +function proofBody(proof?: TwoFactorProof | null): { code?: string; recovery_code?: string; passkey_code?: string } { if (!proof) return {}; - return "code" in proof ? { code: proof.code.trim() } : { recovery_code: proof.recoveryCode.trim() }; + if ("code" in proof) return { code: proof.code.trim() }; + if ("recoveryCode" in proof) return { recovery_code: proof.recoveryCode.trim() }; + return { passkey_code: proof.passkeyCode }; } const TWO_FACTOR_MESSAGES: Record = { @@ -90,10 +92,33 @@ export function twoFactorMessage(err: unknown): string | null { export interface TwoFactorChallenge { pending_token: string; - /** "totp" and/or "recovery". */ + /** Any of "totp", "passkey" and "recovery". */ methods: string[]; } +export type PasskeyPurpose = "sign_in" | "step_up" | "register"; + +export interface PasskeyHandle { + handle: string; +} + +export interface PasskeyCeremony { + purpose: PasskeyPurpose; + email: string; + options: { publicKey: Record }; +} + +/** The code stays in the page that asked for it, or travels to the app in `redirect`. */ +export interface PasskeyResult { + code?: string; + redirect?: string; +} + +export interface Factors { + totp: boolean; + passkeys: { id: string; name: string }[]; +} + export type LoginResult = { session: AuthResponse } | { challenge: TwoFactorChallenge }; export interface CheckoutResponse { @@ -198,6 +223,40 @@ export function loginSecondFactor(pendingToken: string, proof: TwoFactorProof): }); } +const INLINE = { return_to: { kind: "inline" } }; + +export function passkeySignInHandle(pendingToken: string): Promise { + return request("/v1/auth/login/2fa/passkey", { + method: "POST", + body: JSON.stringify({ pending_token: pendingToken, ...INLINE }), + }); +} + +export function passkeyStepUpHandle(token: string): Promise { + return request("/v1/auth/2fa/passkeys/step-up", { method: "POST", body: JSON.stringify(INLINE) }, token); +} + +export function beginPasskey(handle: string): Promise { + return request("/v1/auth/passkey/begin", { method: "POST", body: JSON.stringify({ handle }) }); +} + +export function finishPasskey(handle: string, response: unknown): Promise { + return request("/v1/auth/passkey/finish", { + method: "POST", + body: JSON.stringify({ handle, response }), + }); +} + +/** What the account can confirm a change with. A server that predates passkeys only knows codes. */ +export async function getFactorMethods(token: string): Promise { + try { + const factors = await request("/v1/auth/2fa", {}, token); + return [...(factors.totp ? ["totp"] : []), ...(factors.passkeys.length ? ["passkey"] : []), "recovery"]; + } catch { + return ["totp", "recovery"]; + } +} + export function verifyEmail(token: string): Promise<{ email: string; user_id: string }> { return request<{ email: string; user_id: string }>("/v1/auth/verify-email", { method: "POST", diff --git a/portal/lib/passkey.ts b/portal/lib/passkey.ts new file mode 100644 index 0000000..1371e7d --- /dev/null +++ b/portal/lib/passkey.ts @@ -0,0 +1,100 @@ +import { + ApiError, beginPasskey, finishPasskey, twoFactorMessage, + type PasskeyCeremony, type PasskeyHandle, type PasskeyResult, +} from "./api"; + +type Json = Record; + +function fromBase64Url(value: string): ArrayBuffer { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)).buffer; +} + +function toBase64Url(buffer: ArrayBuffer): string { + const binary = Array.from(new Uint8Array(buffer), (byte) => String.fromCharCode(byte)).join(""); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function descriptors(list: unknown): PublicKeyCredentialDescriptor[] | undefined { + return (list as { id: string }[] | undefined)?.map( + (item) => ({ ...item, id: fromBase64Url(item.id) }) as PublicKeyCredentialDescriptor, + ); +} + +function creationOptions(publicKey: Json): PublicKeyCredentialCreationOptions { + const user = publicKey.user as Json; + return { + ...(publicKey as unknown as PublicKeyCredentialCreationOptions), + challenge: fromBase64Url(publicKey.challenge as string), + user: { ...(user as unknown as PublicKeyCredentialUserEntity), id: fromBase64Url(user.id as string) }, + excludeCredentials: descriptors(publicKey.excludeCredentials), + }; +} + +function requestOptions(publicKey: Json): PublicKeyCredentialRequestOptions { + return { + ...(publicKey as unknown as PublicKeyCredentialRequestOptions), + challenge: fromBase64Url(publicKey.challenge as string), + allowCredentials: descriptors(publicKey.allowCredentials), + }; +} + +function responseJson(credential: PublicKeyCredential): Json { + const response = credential.response; + const fields = + response instanceof AuthenticatorAttestationResponse + ? { attestationObject: toBase64Url(response.attestationObject), transports: response.getTransports?.() } + : { + authenticatorData: toBase64Url((response as AuthenticatorAssertionResponse).authenticatorData), + signature: toBase64Url((response as AuthenticatorAssertionResponse).signature), + }; + return { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + extensions: credential.getClientExtensionResults(), + response: { ...fields, clientDataJSON: toBase64Url(response.clientDataJSON) }, + }; +} + +export function passkeysSupported(): boolean { + return typeof window !== "undefined" && "PublicKeyCredential" in window && Boolean(navigator.credentials); +} + +/** What to tell the user about a failed ceremony, whether the browser or the server refused it. */ +export function passkeyMessage(err: unknown): string { + if (err instanceof DOMException) { + if (err.name === "InvalidStateError") return "This device already holds a passkey for this account."; + return "The passkey prompt was closed or timed out. Try again."; + } + if (err instanceof ApiError) { + if (err.status === 410 || err.status === 401) return "This request has expired. Start again."; + if (err.body?.error === "TWO_FACTOR_INVALID") return "That passkey was not accepted."; + if (err.body?.error === "TWO_FACTOR_UNAVAILABLE") return "This server does not offer passkeys."; + return twoFactorMessage(err) ?? "The passkey could not be checked. Try again."; + } + return err instanceof Error ? err.message : "The passkey could not be checked. Try again."; +} + +/** Shows the browser's passkey prompt for a ceremony the server has begun and reports the answer. */ +export async function askPasskey(handle: string, ceremony: PasskeyCeremony): Promise { + const { publicKey } = ceremony.options; + const credential = + ceremony.purpose === "register" + ? await navigator.credentials.create({ publicKey: creationOptions(publicKey) }) + : await navigator.credentials.get({ publicKey: requestOptions(publicKey) }); + if (!(credential instanceof PublicKeyCredential)) throw new Error("The browser returned no passkey."); + return finishPasskey(handle, responseJson(credential)); +} + +/** A ceremony whose code stays in this page: opens it, runs it, returns the code. */ +export async function inlinePasskeyCode(open: () => Promise): Promise { + try { + const { handle } = await open(); + const { code } = await askPasskey(handle, await beginPasskey(handle)); + if (!code) throw new Error("The passkey could not be checked. Try again."); + return code; + } catch (err) { + throw new Error(passkeyMessage(err)); + } +}