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)); + } +}