commit d6ed4ad5d080fea3130fabb2f6f603cb031f2a09 parent 5bdfa4e17b22ae6addecda805e57e6790289e307 Author: Florian Dold <dold@taler.net> Date: Sat, 29 Aug 2026 18:51:50 +0200 merchant web UI: delegate password reauthentication to backend Diffstat:
23 files changed, 151 insertions(+), 249 deletions(-)
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -1504,6 +1504,7 @@ deploymentCli { method: MerchantAuthMethod.TOKEN, password: randomPassword, + old_password: prevPassword, }, ); if (resp.type === "fail") { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts @@ -142,6 +142,7 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { { method: MerchantAuthMethod.TOKEN, password: "foobar", + old_password: MERCHANT_DEFAULT_AUTH.password, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts @@ -23,7 +23,11 @@ import { } from "@gnu-taler/taler-util"; import fs from "node:fs"; import path from "node:path"; -import { GlobalTestState, waitMs } from "../harness/harness.js"; +import { + GlobalTestState, + MERCHANT_DEFAULT_AUTH, + waitMs, +} from "../harness/harness.js"; import { createTopsEnvironment } from "../harness/tops.js"; import { assertNoUnexpectedErrorBanner, @@ -60,6 +64,7 @@ export async function runMerchantWebuiKycSwapTest(t: GlobalTestState) { { method: MerchantAuthMethod.TOKEN, password, + old_password: MERCHANT_DEFAULT_AUTH.password, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts @@ -475,9 +475,9 @@ async function runMerchantWebuiMfaTestImpl( ); t.assertDeepEqual(changedDetails.phone_number, changedPhone); - // A wrong current password is rejected by the browser-local verifier and - // must not start a backend request. The correct password then exercises - // POST /private/auth through MFA while retaining the portal access token. + // A wrong current password is rejected by the backend before any MFA + // challenge is started. The correct password then exercises POST + // /private/auth while retaining the portal access token. const changedPassword = "merchant-mfa-new-password"; fs.rmSync(mfa.email.path, { force: true }); await page.getByRole("button", { name: /Account password/ }).click(); @@ -492,7 +492,7 @@ async function runMerchantWebuiMfaTestImpl( .waitFor({ state: "visible", timeout: 15_000 }); t.assertTrue( !fs.existsSync(mfa.email.path), - "a local current-password mismatch unexpectedly reached MFA", + "a current-password mismatch unexpectedly reached MFA", ); await page diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts @@ -1380,6 +1380,7 @@ export async function runMerchantWebuiSimpleTest(t: GlobalTestState) { await merchantClient.updateCurrentInstanceAuthentication(adminAccessToken, { method: MerchantAuthMethod.TOKEN, password: instancePassword, + old_password: MERCHANT_DEFAULT_AUTH.password, }); const merchantInstanceClient = new TalerMerchantInstanceHttpClient(baseUrl); diff --git a/packages/taler-merchant-webui/src/App.tsx b/packages/taler-merchant-webui/src/App.tsx @@ -249,14 +249,12 @@ export function AppContent(): VNode { token: string; backendUrl?: string; tokenInfo?: LoginTokenInfo; - passwordVerifier?: import("./stores/session.js").PasswordVerifierV1; }) => { signIn( data.account, data.token as AccessToken, data.backendUrl, data.tokenInfo, - data.passwordVerifier, ); setLocation(consumeSignInReturnPath() || "/orders"); }; @@ -372,7 +370,6 @@ export function AppContent(): VNode { data.token, data.backendUrl, data.tokenInfo, - data.passwordVerifier, ); setLocation("/setup"); }} @@ -518,7 +515,6 @@ export function AppContent(): VNode { data.token as AccessToken, data.backendUrl, data.tokenInfo, - data.passwordVerifier, ); setLocation("/setup"); }} diff --git a/packages/taler-merchant-webui/src/routes/BootstrapInstanceRoute.tsx b/packages/taler-merchant-webui/src/routes/BootstrapInstanceRoute.tsx @@ -14,7 +14,7 @@ import { } from "@gnu-taler/taler-util"; import { BootstrapInstanceScreen } from "../screens/BootstrapInstanceScreen.js"; import { authenticate } from "./SignInRoute.js"; -import { createPasswordVerifier, type LoginTokenInfo } from "../stores/session.js"; +import type { LoginTokenInfo } from "../stores/session.js"; import { normalizeApiFailure } from "../api/failure.js"; export interface BootstrapInstanceRouteProps { @@ -28,7 +28,6 @@ export interface BootstrapInstanceRouteProps { token: AccessToken; backendUrl: string; tokenInfo?: LoginTokenInfo; - passwordVerifier?: import("../stores/session.js").PasswordVerifierV1; }) => void; } @@ -49,7 +48,6 @@ export function BootstrapInstanceRoute({ account: "admin", token: created.access_token as AccessToken, backendUrl, - passwordVerifier: await createPasswordVerifier(password), tokenInfo: { expiresS: typeof created.expiration?.t_s === "number" ? created.expiration.t_s @@ -77,7 +75,6 @@ export function BootstrapInstanceRoute({ token: authenticated.token as AccessToken, backendUrl, tokenInfo: authenticated.tokenInfo, - passwordVerifier: authenticated.passwordVerifier, }); }} /> diff --git a/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx b/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx @@ -16,16 +16,17 @@ import type { VNode } from "preact"; import type { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + MerchantAuthMethod, + TalerErrorCode, +} from "@gnu-taler/taler-util"; import { useBusinessSettings, useMerchantConfig } from "../api/hooks.js"; import { merchantClient } from "../api/client.js"; import { - createPasswordVerifier, session, currentBackendUrl, currentAccount, - setSessionPasswordVerifier, - verifySessionPassword, } from "../stores/session.js"; import { useTranslation } from "../context/translation.js"; import { BusinessSettingsScreen } from "../screens/BusinessSettingsScreen.js"; @@ -48,13 +49,19 @@ export function BusinessSettingsRoute({ const { settings, updateSettings, resource } = useBusinessSettings(); const { phoneRegex } = useMerchantConfig(); const hasToken = Boolean(session.value.token); + const normalizePasswordChangeFailure = (failure: unknown) => { + const normalized = normalizeApiFailure(failure); + return normalized.talerCode === + TalerErrorCode.MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_OLD_PASSWORD + ? configurationFailure(t`Your current password is not correct.`) + : normalized; + }; return ( <BusinessSettingsScreen settings={hasToken ? settings : undefined} settingsResource={resource} phoneRegex={phoneRegex} - passwordVerifierAvailable={Boolean(session.value.passwordVerifier)} onSave={async (newSettings) => { if (hasToken && updateSettings) { const res = await updateSettings(newSettings); @@ -97,29 +104,12 @@ export function BusinessSettingsRoute({ rootUrl, account: currentAccount.value, }); - if (session.value.passwordVerifier) { - const matches = data.currentPassword - ? await verifySessionPassword(data.currentPassword) - : false; - if (!matches) { - return { - status: "error", - error: configurationFailure( - t`Your current password is not correct.`, - ), - }; - } - } - // Prepare the replacement before mutating the server. If local - // hashing fails, the form can report that without leaving the - // merchant uncertain whether the backend changed the password. - const nextVerifier = await createPasswordVerifier(data.newPassword); - const changeRes = await client.updateCurrentInstanceAuthentication( session.value.token!, { method: MerchantAuthMethod.TOKEN, password: data.newPassword, + old_password: data.currentPassword, }, ); if (changeRes.type === "fail") { @@ -139,6 +129,7 @@ export function BusinessSettingsRoute({ { method: MerchantAuthMethod.TOKEN, password: data.newPassword, + old_password: data.currentPassword, }, { challengeIds }, ); @@ -152,16 +143,20 @@ export function BusinessSettingsRoute({ continued.body as TalerMerchantApi.ChallengeResponse, }; } + if (continued.type === "fail") { + throw normalizePasswordChangeFailure(continued); + } unwrapEmpty(continued); - setSessionPasswordVerifier(nextVerifier); return { redirectTo: "/settings/account" }; }), }); return { status: "challenge" }; } - return { status: "error", error: normalizeApiFailure(changeRes) }; + return { + status: "error", + error: normalizePasswordChangeFailure(changeRes), + }; } - setSessionPasswordVerifier(nextVerifier); return { status: "changed" }; } catch (err: unknown) { return { status: "error", error: normalizeApiFailure(err) }; diff --git a/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx b/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx @@ -37,7 +37,6 @@ import { import { configurationFailure, normalizeApiFailure } from "../api/failure.js"; import { useTranslation } from "../context/translation.js"; import type { TranslateFn } from "../context/translation.js"; -import { createPasswordVerifier } from "../stores/session.js"; import { parseChallengeResponse } from "../utils/challengeRounds.js"; import { useMerchantConfig } from "../api/hooks/useMerchantConfig.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; @@ -80,7 +79,6 @@ async function tokenFor( type: "ok", token: signedIn.token, tokenInfo: signedIn.tokenInfo, - passwordVerifier: signedIn.passwordVerifier, }; } const challenges = tokenChallenges(signedIn); @@ -189,7 +187,6 @@ export async function provision( return { type: "ok", token: String(body.access_token), - passwordVerifier: await createPasswordVerifier(req.password), tokenInfo: { expiresS: typeof expiresS === "number" ? expiresS : undefined, refreshable: body.refreshable === true, diff --git a/packages/taler-merchant-webui/src/routes/SignInRoute.tsx b/packages/taler-merchant-webui/src/routes/SignInRoute.tsx @@ -17,7 +17,7 @@ import type { VNode } from "preact"; import { AccessToken, HttpStatusCode, LoginTokenScope } from "@gnu-taler/taler-util"; import { merchantClient } from "../api/client.js"; -import { createPasswordVerifier, normalizeToken } from "../stores/session.js"; +import { normalizeToken } from "../stores/session.js"; import { loginTokenLifetimeMinutes } from "../stores/devSettings.js"; import { handleRouteSendChallenge, handleRouteSolveChallenge } from "../utils/tan.js"; import { @@ -137,7 +137,6 @@ export async function authenticate(input: { return { type: "ok", token: res.body.access_token, - passwordVerifier: await createPasswordVerifier(secret), tokenInfo: { expiresS: typeof expiresS === "number" ? expiresS : undefined, refreshable: res.body.refreshable === true, diff --git a/packages/taler-merchant-webui/src/routes/selfProvision.test.ts b/packages/taler-merchant-webui/src/routes/selfProvision.test.ts @@ -31,7 +31,6 @@ import { import { FakeHttpLib, ok, talerError } from "../testing/fake-http.js"; import { useHttpLibForTesting } from "../api/client.js"; import { provision, signupPolicy } from "./SelfProvisionRoute.js"; -import { verifyPassword } from "../stores/session.js"; import { isValidInstanceId } from "../screens/SelfProvisionScreen.js"; const BACKEND = "https://backend.example.test/"; @@ -122,11 +121,6 @@ test("registering yields a login token, never the chosen password", async () => "the password must never become the credential", ); assert.equal(res.tokenInfo?.refreshable, true); - assert.ok(res.passwordVerifier); - assert.equal( - await verifyPassword(REQ.password, res.passwordVerifier!), - true, - ); assert.equal(http.requests.length, 1); // The chosen password goes in the body, never the query string. assert.doesNotMatch(http.lastRequest!.url, /hunter2/); @@ -148,7 +142,6 @@ test("a registration that issues no token exchanges the password for one", async if (res.type !== "ok") return; assert.equal(res.token, "secret-token:NEWACCOUNT"); assert.notEqual(res.token, REQ.password); - assert.ok(res.passwordVerifier); assert.equal(http.requests.length, 2, "register, then exchange"); } finally { restore(); diff --git a/packages/taler-merchant-webui/src/routes/signIn.test.ts b/packages/taler-merchant-webui/src/routes/signIn.test.ts @@ -30,7 +30,6 @@ import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; import { FakeHttpLib, ok, notFound, talerError, unauthorized } from "../testing/fake-http.js"; import { useHttpLibForTesting } from "../api/client.js"; import { authenticate } from "./SignInRoute.js"; -import { verifyPassword } from "../stores/session.js"; const BACKEND = "https://backend.example.test/"; const PASSWORD = "correct horse battery staple"; @@ -61,9 +60,6 @@ test("a password is exchanged for a login token and never stored", async () => { res.tokenInfo?.expiresS, "the expiry must be carried so renewal can be scheduled", ); - assert.ok(res.passwordVerifier); - assert.equal(await verifyPassword(PASSWORD, res.passwordVerifier!), true); - // Exactly one request, and the password went only to the token endpoint. assert.equal(http.requests.length, 1); assert.match(http.lastRequest!.url, /\/private\/token$/); @@ -94,12 +90,6 @@ test("a pasted token is used as-is, with no exchange and no renewal metadata", a undefined, "a pasted credential must not be scheduled for renewal", ); - assert.equal( - res.passwordVerifier, - undefined, - "a pasted credential cannot create a password verifier", - ); - // It was checked by being used, not by being exchanged. assert.match(http.lastRequest!.url, /\/private\/orders/); assert.match(String(http.lastRequest!.headers?.Authorization), /^Bearer secret-token:GIVEN$/); diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx @@ -47,9 +47,8 @@ export interface BusinessSettingsScreenProps { settingsResource?: RemoteResource<BusinessSettings>; phoneRegex?: string; onSave?: (settings: BusinessSettings) => Promise<BusinessSettingsSaveResult>; - passwordVerifierAvailable?: boolean; onChangePassword?: (data: { - currentPassword?: string; + currentPassword: string; newPassword: string; }) => Promise<PasswordChangeResult>; /** Open the relevant editor in focused tutorial previews. */ @@ -244,7 +243,6 @@ export function BusinessSettingsScreen({ phoneRegex, onSave, onChangePassword, - passwordVerifierAvailable = false, initialSection, }: BusinessSettingsScreenProps): VNode { const { t } = useTranslation(); @@ -409,7 +407,7 @@ export function BusinessSettingsScreen({ })); return; } - if (passwordVerifierAvailable && !currentPassword) { + if (!currentPassword) { setSectionErrors((current) => ({ ...current, security: new Error(t`Please enter your current password.`), @@ -419,7 +417,7 @@ export function BusinessSettingsScreen({ setPendingSection("security"); try { const result = await onChangePassword?.({ - currentPassword: currentPassword || undefined, + currentPassword, newPassword, }); if (!result) return; @@ -918,25 +916,14 @@ export function BusinessSettingsScreen({ onSubmit={(event) => void savePassword(event)} class="max-w-xl space-y-5" > - {passwordVerifierAvailable ? ( - <PasswordInput - id="pwd-current" - label={t`Current Password`} - value={currentPassword} - onInput={setCurrentPassword} - autoComplete="current-password" - required - helpText={t`Confirmed locally in this browser before the change is sent to the server.`} - /> - ) : ( - <div - role="note" - class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900" - > - <strong class="block font-bold">{t`Current password confirmation is unavailable`}</strong> - <span class="mt-1 block text-xs">{t`This session was started with an access token, so this browser cannot confirm your current password. The server may still require verification before changing it.`}</span> - </div> - )} + <PasswordInput + id="pwd-current" + label={t`Current Password`} + value={currentPassword} + onInput={setCurrentPassword} + autoComplete="current-password" + required + /> <PasswordInput id="pwd-new" label={t`New Password`} diff --git a/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx b/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx @@ -27,10 +27,7 @@ import { CopyErrorButton } from "../ui/CopyErrorButton.js"; import { FooterControls } from "../ui/FooterControls.js"; import { useTranslation } from "../context/translation.js"; import { devSettings } from "../stores/devSettings.js"; -import { - type LoginTokenInfo, - type PasswordVerifierV1, -} from "../stores/session.js"; +import { type LoginTokenInfo } from "../stores/session.js"; import { presetMerchantBaseUrl } from "../stores/webuiConfig.js"; import { sanitizeTanCode, formatTanDigits } from "../utils/tan.js"; import { TanCodeInputGroup } from "../ui/TanCodeInputGroup.js"; @@ -65,7 +62,6 @@ export type ProvisionAttempt = type: "ok"; token?: string; tokenInfo?: LoginTokenInfo; - passwordVerifier?: PasswordVerifierV1; } /** * The account exists, but the server wants one more code before it will @@ -108,7 +104,6 @@ export interface SelfProvisionScreenProps { */ token?: string; tokenInfo?: LoginTokenInfo; - passwordVerifier?: PasswordVerifierV1; businessName: string; email?: string; phone?: string; @@ -364,7 +359,6 @@ export function SelfProvisionScreen({ account: normalizeMerchantInstanceId(username), token: resp.token, tokenInfo: resp.tokenInfo, - passwordVerifier: resp.passwordVerifier, businessName, email, phone, diff --git a/packages/taler-merchant-webui/src/screens/SignInScreen.tsx b/packages/taler-merchant-webui/src/screens/SignInScreen.tsx @@ -27,7 +27,6 @@ import { currentAccount, lastMerchantAccountStore, type LoginTokenInfo, - type PasswordVerifierV1, } from "../stores/session.js"; import { sanitizeTanCode, formatTanDigits } from "../utils/tan.js"; import { TanCodeInputGroup } from "../ui/TanCodeInputGroup.js"; @@ -55,7 +54,6 @@ export type SignInAttempt = type: "ok"; token: string; tokenInfo?: LoginTokenInfo; - passwordVerifier?: PasswordVerifierV1; } | { type: "fail"; case?: number; detail?: unknown; failure?: ApiFailure }; @@ -80,7 +78,6 @@ export interface SignInScreenProps { backendUrl?: string; /** Absent when the merchant pasted a credential: nothing to renew. */ tokenInfo?: LoginTokenInfo; - passwordVerifier?: PasswordVerifierV1; }) => void | Promise<void>; onMfaSuccess?: (solvedChallengeIds: string[]) => Promise<void>; /** @@ -458,7 +455,6 @@ export function SignInScreen({ token: formattedToken, backendUrl, tokenInfo, - passwordVerifier: res.passwordVerifier, }); } } catch (cause) { diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -1751,7 +1751,7 @@ test("BusinessSettingsScreen keeps an MFA-challenged save open and disables it w document.body.removeChild(container); }); -test("BusinessSettingsScreen requires the locally verifiable current password", async () => { +test("BusinessSettingsScreen requires the current password", async () => { const container = document.createElement("div"); document.body.appendChild(container); let changes = 0; @@ -1759,7 +1759,6 @@ test("BusinessSettingsScreen requires the locally verifiable current password", <BusinessSettingsScreen settings={{ name: "ACME Coffee" }} initialSection="security" - passwordVerifierAvailable onChangePassword={async () => { changes += 1; return { status: "changed" }; @@ -1796,25 +1795,19 @@ test("BusinessSettingsScreen requires the locally verifiable current password", document.body.removeChild(container); }); -test("BusinessSettingsScreen explains token-only password changes and waits for MFA", async () => { +test("BusinessSettingsScreen keeps the password editor open while waiting for MFA", async () => { const container = document.createElement("div"); document.body.appendChild(container); render( <BusinessSettingsScreen settings={{ name: "ACME Coffee" }} initialSection="security" - passwordVerifierAvailable={false} onChangePassword={async () => ({ status: "challenge" })} />, container, ); - assert.equal(container.querySelector("#pwd-current"), null); - assert.match( - container.textContent ?? "", - /Current password confirmation is unavailable/, - ); - for (const selector of ["#pwd-new", "#pwd-confirm"]) { + for (const selector of ["#pwd-current", "#pwd-new", "#pwd-confirm"]) { const input = container.querySelector(selector) as HTMLInputElement; act(() => { input.value = "new password"; diff --git a/packages/taler-merchant-webui/src/stores/session.test.ts b/packages/taler-merchant-webui/src/stores/session.test.ts @@ -10,7 +10,6 @@ import assert from "node:assert"; import test from "node:test"; import { - createPasswordVerifier, consumeSignInReturnPath, currentBackendUrl, customBackendUrl, @@ -22,31 +21,9 @@ import { signOut, signIn, session, - verifyPassword, } from "./session.js"; import { webUiConfig } from "./webuiConfig.js"; -test("password verifier accepts only the password used to create it", async () => { - const verifier = await createPasswordVerifier("correct horse battery staple"); - assert.strictEqual( - await verifyPassword("correct horse battery staple", verifier), - true, - ); - assert.strictEqual(await verifyPassword("wrong password", verifier), false); - assert.strictEqual(verifier.algorithm, "argon2id-v1"); - assert.notStrictEqual(verifier.digest, "correct horse battery staple"); -}); - -test("token-only sign-in clears a password verifier from an older session", async () => { - const verifier = await createPasswordVerifier("old password"); - signIn("shop", "secret-token:first", undefined, undefined, verifier); - assert.ok(session.value.passwordVerifier); - - signIn("shop", "secret-token:pasted"); - assert.strictEqual(session.value.passwordVerifier, undefined); - signOut(); -}); - test("a fixed deployment URL clears stale overrides and invalidates foreign sessions", () => { webUiConfig.value = {}; customBackendUrlStore.clear(); diff --git a/packages/taler-merchant-webui/src/stores/session.ts b/packages/taler-merchant-webui/src/stores/session.ts @@ -19,14 +19,9 @@ import { buildCodecForObject, Codec, codecForBoolean, - codecForConstString, codecForNumber, codecForString, codecOptional, - decodeCrock, - encodeCrock, - getRandomBytes, - hashArgon2id, } from "@gnu-taler/taler-util"; import { computed, signal, type Signal } from "@preact/signals"; import { persistedSignal } from "./persisted.js"; @@ -96,38 +91,8 @@ export interface Session { tokenRefreshable?: boolean; /** What the backend granted, which need not be what was asked for. */ tokenScope?: string; - /** - * A deliberately expensive, salted browser-local verifier for the password - * used to start this session. It is only a guard against a quick password - * change from an unattended, already signed-in browser; it is not a backend - * authentication credential. - */ - passwordVerifier?: PasswordVerifierV1; -} - -export interface PasswordVerifierV1 { - algorithm: "argon2id-v1"; - salt: string; - digest: string; - iterations: number; - memoryKiB: number; - length: number; } -const PASSWORD_VERIFIER_ITERATIONS = 2; -const PASSWORD_VERIFIER_MEMORY_KIB = 19 * 1024; -const PASSWORD_VERIFIER_LENGTH = 32; - -const codecForPasswordVerifier = (): Codec<PasswordVerifierV1> => - buildCodecForObject<PasswordVerifierV1>() - .property("algorithm", codecForConstString("argon2id-v1")) - .property("salt", codecForString()) - .property("digest", codecForString()) - .property("iterations", codecForNumber()) - .property("memoryKiB", codecForNumber()) - .property("length", codecForNumber()) - .build("PasswordVerifierV1"); - const codecForSession = (): Codec<Session> => buildCodecForObject<Session>() .property("account", codecOptional(codecForString())) @@ -139,7 +104,6 @@ const codecForSession = (): Codec<Session> => .property("tokenExpiresS", codecOptional(codecForNumber())) .property("tokenRefreshable", codecOptional(codecForBoolean())) .property("tokenScope", codecOptional(codecForString())) - .property("passwordVerifier", codecOptional(codecForPasswordVerifier())) .build("Session"); const store = persistedSignal<Session>("session", codecForSession(), {}); @@ -278,86 +242,11 @@ export interface LoginTokenInfo { scope?: string; } -export async function createPasswordVerifier( - password: string, -): Promise<PasswordVerifierV1> { - const salt = getRandomBytes(16); - const digest = await hashArgon2id( - new TextEncoder().encode(password), - salt, - PASSWORD_VERIFIER_ITERATIONS, - PASSWORD_VERIFIER_MEMORY_KIB, - PASSWORD_VERIFIER_LENGTH, - ); - return { - algorithm: "argon2id-v1", - salt: encodeCrock(salt), - digest: encodeCrock(digest), - iterations: PASSWORD_VERIFIER_ITERATIONS, - memoryKiB: PASSWORD_VERIFIER_MEMORY_KIB, - length: PASSWORD_VERIFIER_LENGTH, - }; -} - -export async function verifyPassword( - password: string, - verifier: PasswordVerifierV1, -): Promise<boolean> { - if ( - verifier.algorithm !== "argon2id-v1" || - verifier.iterations !== PASSWORD_VERIFIER_ITERATIONS || - verifier.memoryKiB !== PASSWORD_VERIFIER_MEMORY_KIB || - verifier.length !== PASSWORD_VERIFIER_LENGTH - ) { - return false; - } - let expected: Uint8Array; - let salt: Uint8Array; - try { - expected = decodeCrock(verifier.digest); - salt = decodeCrock(verifier.salt); - } catch { - return false; - } - const actual = await hashArgon2id( - new TextEncoder().encode(password), - salt, - verifier.iterations, - verifier.memoryKiB, - verifier.length, - ); - if ( - salt.length !== 16 || - expected.length !== PASSWORD_VERIFIER_LENGTH || - actual.length !== expected.length - ) - return false; - let difference = 0; - for (let i = 0; i < actual.length; i += 1) { - difference |= actual[i]! ^ expected[i]!; - } - return difference === 0; -} - -export async function verifySessionPassword( - password: string, -): Promise<boolean> { - const verifier = session.value.passwordVerifier; - return verifier ? verifyPassword(password, verifier) : false; -} - -export function setSessionPasswordVerifier(verifier: PasswordVerifierV1): void { - const current = store.get(); - if (!current.token) return; - store.set({ ...current, passwordVerifier: verifier }); -} - export function signIn( account: string, rawToken: string, backendBaseUrl?: string, tokenInfo?: LoginTokenInfo, - passwordVerifier?: PasswordVerifierV1, ): void { // Lower case throughout: the backend's identity is case sensitive and the // portal must not offer a spelling that fails to authenticate. @@ -380,8 +269,6 @@ export function signIn( tokenExpiresS: tokenInfo?.expiresS, tokenRefreshable: tokenInfo?.refreshable, tokenScope: tokenInfo?.scope, - // A token-only sign-in must clear a verifier from an older session. - passwordVerifier, }); } diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx @@ -850,7 +850,6 @@ export const STORIES: Story[] = [ description: "Grouped business profile, order defaults, and account security settings.", render: () => ( <BusinessSettingsScreen - passwordVerifierAvailable settings={{ name: "Alpenblick Coffee", email: "hello@alpenblick.example", diff --git a/packages/taler-util/src/http-client/merchant-management.test.ts b/packages/taler-util/src/http-client/merchant-management.test.ts @@ -10,10 +10,14 @@ import assert from "node:assert"; import { test } from "node:test"; import { HttpStatusCode } from "../http-status-codes.js"; -import { FakeHttpLib, noContent, ok } from "../http-fake.js"; -import { TalerMerchantManagementHttpClient } from "./merchant.js"; +import { FakeHttpLib, noContent, ok, talerError } from "../http-fake.js"; +import { + TalerMerchantInstanceHttpClient, + TalerMerchantManagementHttpClient, +} from "./merchant.js"; import type { AccessToken } from "../types-taler-common.js"; import { MerchantAuthMethod } from "../types-taler-merchant.js"; +import { TalerErrorCode } from "../taler-error-codes.js"; const token = "secret-token:admin" as AccessToken; const baseUrl = "https://merchant.example.com/"; @@ -121,6 +125,64 @@ test("management writes support login-token and challenge responses", async () = assert.strictEqual(http.lastRequest?.headers?.["Taler-Challenge-Ids"], "c3"); }); +test("self-service authentication requests use their distinct wire formats", async () => { + const http = new FakeHttpLib() + .on("POST", "/private/auth", noContent()) + .on("POST", "/forgot-password", ok({ + access_token: "secret-token:recovered", + scope: "all", + expiration: { t_s: 10 }, + refreshable: false, + })); + const client = new TalerMerchantInstanceHttpClient(baseUrl, http); + + const changed = await client.updateCurrentInstanceAuthentication(token, { + method: MerchantAuthMethod.TOKEN, + password: "new password", + old_password: "current password", + }); + assert.strictEqual(changed.type, "ok"); + assert.deepStrictEqual(http.lastRequest?.body, { + method: MerchantAuthMethod.TOKEN, + password: "new password", + old_password: "current password", + }); + + const recovered = await client.forgotPasswordSelfProvision({ + method: MerchantAuthMethod.TOKEN, + password: "recovered password", + token_duration: { d_us: 60_000_000 }, + }); + assert.strictEqual(recovered.type, "ok"); + assert.strictEqual(recovered.body?.access_token, "secret-token:recovered"); + assert.deepStrictEqual(http.lastRequest?.body, { + method: MerchantAuthMethod.TOKEN, + password: "recovered password", + token_duration: { d_us: 60_000_000 }, + }); +}); + +test("self-service password changes preserve the bad-current-password error", async () => { + const http = new FakeHttpLib().on("POST", "/private/auth", { + status: HttpStatusCode.Unauthorized, + body: talerError( + TalerErrorCode.MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_OLD_PASSWORD, + ), + }); + const client = new TalerMerchantInstanceHttpClient(baseUrl, http); + + const changed = await client.updateCurrentInstanceAuthentication(token, { + method: MerchantAuthMethod.TOKEN, + password: "new password", + old_password: "wrong password", + }); + assert.strictEqual(changed.type, "fail"); + assert.strictEqual( + changed.case, + TalerErrorCode.MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_OLD_PASSWORD, + ); +}); + test("management deletion and KYC use the documented wire format", async () => { const http = new FakeHttpLib() .on("DELETE", "/management/instances/shop", noContent()) diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -808,7 +808,7 @@ export class TalerMerchantInstanceHttpClient { */ async updateCurrentInstanceAuthentication( token: AccessToken, - body: TalerMerchantApi.InstanceAuthConfigurationMessage, + body: TalerMerchantApi.InstanceAuthChangeRequest, params: { challengeIds?: string[]; } = {}, @@ -840,8 +840,15 @@ export class TalerMerchantInstanceHttpClient { } case HttpStatusCode.NoContent: return opEmptySuccess(resp); - case HttpStatusCode.Unauthorized: // FIXME: missing in docs - return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Unauthorized: { + const details = await readTalerErrorResponse(resp); + switch (details.code) { + case TalerErrorCode.MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_OLD_PASSWORD: + return opKnownTalerFailure(resp, details.code, details); + default: + return opUnknownHttpFailure(resp, details); + } + } case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); default: @@ -3275,7 +3282,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#post--instances-$INSTANCE-forgot-password */ async forgotPasswordSelfProvision( - body: TalerMerchantApi.InstanceAuthConfigurationMessage, + body: TalerMerchantApi.ForgotPasswordRequest, params: { challengeIds?: string[]; } = {}, @@ -3293,6 +3300,9 @@ export class TalerMerchantInstanceHttpClient { }); switch (resp.status) { + case HttpStatusCode.Ok: { + return opSuccessFromHttp(resp, codecForLoginTokenSuccessResponse()); + } case HttpStatusCode.NoContent: { return opEmptySuccess(resp); } diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts @@ -3801,6 +3801,14 @@ export enum TalerErrorCode { /** + * The merchant backend cannot update an instance's authentication settings because the required current password was missing or did not match. + * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_OLD_PASSWORD = 2604, + + + /** * The merchant backend cannot update an instance under the given identifier, the previous one was deleted but must be purged first. * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1920,6 +1920,20 @@ export interface InstanceAuthConfigurationMessage { password: string; } +/** Authentication configuration for the current instance. */ +export interface InstanceAuthChangeRequest + extends InstanceAuthConfigurationMessage { + // Current password used to re-authenticate the request. + // @since merchant protocol **v40**. + old_password: string; +} + +/** Authentication configuration used by public account recovery. */ +export interface ForgotPasswordRequest + extends InstanceAuthConfigurationMessage { + token_duration?: RelativeTime; +} + export enum LoginTokenScope { ReadOnly = "readonly", All = "all",