From f83c0eae87a7a5a8640a24c96bece7e97848d1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:36:17 +0100 Subject: [PATCH] Retry processing password protected references for wrong passwords (#384) * feat: allow to pass an iteration for the password prompt * feat: retry processing password protected references for wrong passwords * feat: add async await * chore: simplify * refactor: wording * feat: add iteration to MockUIBridge * refactor: use new functionalities * test: add test for retrying a password entry * fix: assume 1 for undefined iteration * refactor: simplify * chore: add iteration * refactor: naming * fix: do not allow unlimited retries * fix: handle user cancellation gracefully --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../app-runtime/src/AppStringProcessor.ts | 86 +++++++++++++------ .../src/extensibility/ui/IUIBridge.ts | 2 +- packages/app-runtime/test/lib/FakeUIBridge.ts | 2 +- .../test/lib/MockUIBridge.matchers.ts | 9 +- packages/app-runtime/test/lib/MockUIBridge.ts | 19 ++-- .../test/runtime/AppStringProcessor.test.ts | 33 +++++-- 6 files changed, 104 insertions(+), 47 deletions(-) diff --git a/packages/app-runtime/src/AppStringProcessor.ts b/packages/app-runtime/src/AppStringProcessor.ts index 4629a5505..f12640663 100644 --- a/packages/app-runtime/src/AppStringProcessor.ts +++ b/packages/app-runtime/src/AppStringProcessor.ts @@ -75,20 +75,22 @@ export class AppStringProcessor { const uiBridge = await this.runtime.uiBridge(); - let password: string | undefined; - if (reference.passwordProtection) { - const passwordResult = await this.enterPassword(reference.passwordProtection); - if (passwordResult.isError) { - return UserfriendlyResult.fail(new UserfriendlyApplicationError("error.appStringProcessor.passwordNotProvided", "No password was provided.")); - } - - password = passwordResult.value; + const tokenResultHolder = reference.passwordProtection + ? await this._fetchPasswordProtectedItemWithRetry( + async (password) => await this.runtime.anonymousServices.tokens.loadPeerToken({ reference: truncatedReference, password }), + reference.passwordProtection + ) + : { result: await this.runtime.anonymousServices.tokens.loadPeerToken({ reference: truncatedReference }) }; + + if (tokenResultHolder.result.isError && tokenResultHolder.result.error.code === "error.appStringProcessor.passwordNotProvided") { + return UserfriendlyResult.ok(undefined); } - const tokenResult = await this.runtime.anonymousServices.tokens.loadPeerToken({ reference: truncatedReference, password: password }); - if (tokenResult.isError) return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(tokenResult.error)); + if (tokenResultHolder.result.isError) { + return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(tokenResultHolder.result.error)); + } - const tokenDTO = tokenResult.value; + const tokenDTO = tokenResultHolder.result.value; const tokenContent = this.parseTokenContent(tokenDTO.content); if (!tokenContent) { const error = AppRuntimeErrors.startup.wrongCode(); @@ -111,25 +113,29 @@ export class AppStringProcessor { return UserfriendlyResult.ok(undefined); } - return await this._handleReference(reference, selectedAccount, password); + return await this._handleReference(reference, selectedAccount, tokenResultHolder.password); } private async _handleReference(reference: Reference, account: LocalAccountDTO, existingPassword?: string): Promise> { const services = await this.runtime.getServices(account.id); const uiBridge = await this.runtime.uiBridge(); - let password: string | undefined = existingPassword; - if (reference.passwordProtection && !password) { - const passwordResult = await this.enterPassword(reference.passwordProtection); - if (passwordResult.isError) { - return UserfriendlyResult.fail(new UserfriendlyApplicationError("error.appStringProcessor.passwordNotProvided", "No password was provided.")); - } + const result = reference.passwordProtection + ? ( + await this._fetchPasswordProtectedItemWithRetry( + async (password) => await services.transportServices.account.loadItemFromTruncatedReference({ reference: reference.truncate(), password }), + reference.passwordProtection + ) + ).result + : await services.transportServices.account.loadItemFromTruncatedReference({ reference: reference.truncate(), password: existingPassword }); - password = passwordResult.value; + if (result.isError && result.error.code === "error.appStringProcessor.passwordNotProvided") { + return UserfriendlyResult.ok(undefined); } - const result = await services.transportServices.account.loadItemFromTruncatedReference({ reference: reference.truncate(), password: password }); - if (result.isError) return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(result.error)); + if (result.isError) { + return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(result.error)); + } switch (result.value.type) { case "File": @@ -164,14 +170,40 @@ export class AppStringProcessor { } } - private async enterPassword(passwordProtection: SharedPasswordProtection): Promise> { + private async _fetchPasswordProtectedItemWithRetry( + fetchFunction: (password: string) => Promise>, + passwordProtection: SharedPasswordProtection + ): Promise<{ result: Result; password?: string }> { + let attempt = 1; + const uiBridge = await this.runtime.uiBridge(); - const passwordResult = await uiBridge.enterPassword( - passwordProtection.passwordType === "pw" ? "pw" : "pin", - passwordProtection.passwordType.startsWith("pin") ? parseInt(passwordProtection.passwordType.substring(3)) : undefined - ); - return passwordResult; + const maxRetries = 1000; + while (attempt <= maxRetries) { + const passwordResult = await uiBridge.enterPassword( + passwordProtection.passwordType === "pw" ? "pw" : "pin", + passwordProtection.passwordType.startsWith("pin") ? parseInt(passwordProtection.passwordType.substring(3)) : undefined, + attempt + ); + if (passwordResult.isError) { + return { result: UserfriendlyResult.fail(new UserfriendlyApplicationError("error.appStringProcessor.passwordNotProvided", "No password was provided.")) }; + } + + const password = passwordResult.value; + + const result = await fetchFunction(password); + attempt++; + + if (result.isSuccess) return { result, password }; + if (result.isError && result.error.code === "error.runtime.recordNotFound") continue; + return { result }; + } + + return { + result: UserfriendlyResult.fail( + new UserfriendlyApplicationError("error.appStringProcessor.passwordRetryLimitReached", "The maximum number of attempts to enter the password was reached.") + ) + }; } private async selectAccount(forIdentityTruncated?: string): Promise> { diff --git a/packages/app-runtime/src/extensibility/ui/IUIBridge.ts b/packages/app-runtime/src/extensibility/ui/IUIBridge.ts index afbbefacd..ae4761dda 100644 --- a/packages/app-runtime/src/extensibility/ui/IUIBridge.ts +++ b/packages/app-runtime/src/extensibility/ui/IUIBridge.ts @@ -11,5 +11,5 @@ export interface IUIBridge { showRequest(account: LocalAccountDTO, request: LocalRequestDVO): Promise>; showError(error: UserfriendlyApplicationError, account?: LocalAccountDTO): Promise>; requestAccountSelection(possibleAccounts: LocalAccountDTO[], title?: string, description?: string): Promise>; - enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise>; + enterPassword(passwordType: "pw" | "pin", pinLength?: number, attempt?: number): Promise>; } diff --git a/packages/app-runtime/test/lib/FakeUIBridge.ts b/packages/app-runtime/test/lib/FakeUIBridge.ts index f411ab891..006f7a021 100644 --- a/packages/app-runtime/test/lib/FakeUIBridge.ts +++ b/packages/app-runtime/test/lib/FakeUIBridge.ts @@ -30,7 +30,7 @@ export class FakeUIBridge implements IUIBridge { throw new Error("Method not implemented."); } - public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number): Promise> { + public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number, _attempt?: number): Promise> { throw new Error("Method not implemented."); } } diff --git a/packages/app-runtime/test/lib/MockUIBridge.matchers.ts b/packages/app-runtime/test/lib/MockUIBridge.matchers.ts index b3720888c..6802eaecd 100644 --- a/packages/app-runtime/test/lib/MockUIBridge.matchers.ts +++ b/packages/app-runtime/test/lib/MockUIBridge.matchers.ts @@ -67,7 +67,7 @@ expect.extend({ return { pass: true, message: () => "" }; }, - enterPasswordCalled(mockUIBridge: unknown, passwordType: "pw" | "pin", pinLength?: number) { + enterPasswordCalled(mockUIBridge: unknown, passwordType: "pw" | "pin", pinLength?: number, attempt?: number) { if (!(mockUIBridge instanceof MockUIBridge)) { throw new Error("This method can only be used with expect(MockUIBridge)."); } @@ -77,12 +77,13 @@ expect.extend({ return { pass: false, message: () => "The method enterPassword was not called." }; } - const matchingCalls = calls.filter((x) => x.passwordType === passwordType && x.pinLength === pinLength); + const matchingCalls = calls.filter((x) => x.passwordType === passwordType && x.pinLength === pinLength && x.attempt === (attempt ?? 1)); if (matchingCalls.length === 0) { const parameters = calls .map((e) => { - return { passwordType: e.passwordType, pinLength: e.pinLength }; + return { passwordType: e.passwordType, pinLength: e.pinLength, attempt: e.attempt }; }) + .map((e) => JSON.stringify(e)) .join(", "); return { @@ -115,7 +116,7 @@ declare global { showDeviceOnboardingNotCalled(): R; requestAccountSelectionCalled(possibleAccountsLength: number): R; requestAccountSelectionNotCalled(): R; - enterPasswordCalled(passwordType: "pw" | "pin", pinLength?: number): R; + enterPasswordCalled(passwordType: "pw" | "pin", pinLength?: number, attempt?: number): R; enterPasswordNotCalled(): R; } } diff --git a/packages/app-runtime/test/lib/MockUIBridge.ts b/packages/app-runtime/test/lib/MockUIBridge.ts index 5e9d40982..90cf97158 100644 --- a/packages/app-runtime/test/lib/MockUIBridge.ts +++ b/packages/app-runtime/test/lib/MockUIBridge.ts @@ -10,7 +10,7 @@ export type MockUIBridgeCall = | { method: "showRequest"; account: LocalAccountDTO; request: LocalRequestDVO } | { method: "showError"; error: UserfriendlyApplicationError; account?: LocalAccountDTO } | { method: "requestAccountSelection"; possibleAccounts: LocalAccountDTO[]; title?: string; description?: string } - | { method: "enterPassword"; passwordType: "pw" | "pin"; pinLength?: number }; + | { method: "enterPassword"; passwordType: "pw" | "pin"; pinLength?: number; attempt?: number }; export class MockUIBridge implements IUIBridge { private _accountIdToReturn: string | undefined; @@ -18,9 +18,9 @@ export class MockUIBridge implements IUIBridge { this._accountIdToReturn = value; } - private _passwordToReturn: string | undefined; - public set passwordToReturn(value: string | undefined) { - this._passwordToReturn = value; + private _passwordToReturnForAttempt: Record = {}; + public setPasswordToReturnForAttempt(attempt: number, password: string): void { + this._passwordToReturnForAttempt[attempt] = password; } private _calls: MockUIBridgeCall[] = []; @@ -29,8 +29,8 @@ export class MockUIBridge implements IUIBridge { } public reset(): void { - this._passwordToReturn = undefined; this._accountIdToReturn = undefined; + this._passwordToReturnForAttempt = {}; this._calls = []; } @@ -82,11 +82,12 @@ export class MockUIBridge implements IUIBridge { return Promise.resolve(Result.ok(foundAccount)); } - public enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise> { - this._calls.push({ method: "enterPassword", passwordType, pinLength }); + public enterPassword(passwordType: "pw" | "pin", pinLength?: number, attempt?: number): Promise> { + this._calls.push({ method: "enterPassword", passwordType, pinLength, attempt }); - if (!this._passwordToReturn) return Promise.resolve(Result.fail(new ApplicationError("code", "message"))); + const password = this._passwordToReturnForAttempt[attempt ?? 1]; + if (!password) return Promise.resolve(Result.fail(new ApplicationError("code", "message"))); - return Promise.resolve(Result.ok(this._passwordToReturn)); + return Promise.resolve(Result.ok(password)); } } diff --git a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts index fdd62536e..5918dfb18 100644 --- a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts +++ b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts @@ -96,7 +96,7 @@ describe("AppStringProcessor", function () { passwordProtection: { password: "password" } }); - mockUiBridge.passwordToReturn = "password"; + mockUiBridge.setPasswordToReturnForAttempt(1, "password"); mockUiBridge.accountIdToReturn = runtime2SessionA.account.id; const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); @@ -116,7 +116,7 @@ describe("AppStringProcessor", function () { passwordProtection: { password: "000000", passwordIsPin: true } }); - mockUiBridge.passwordToReturn = "000000"; + mockUiBridge.setPasswordToReturnForAttempt(1, "000000"); mockUiBridge.accountIdToReturn = runtime2SessionA.account.id; const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); @@ -137,7 +137,7 @@ describe("AppStringProcessor", function () { forIdentity: runtime2SessionA.account.address! }); - mockUiBridge.passwordToReturn = "password"; + mockUiBridge.setPasswordToReturnForAttempt(1, "password"); const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); expect(result).toBeSuccessful(); @@ -157,7 +157,7 @@ describe("AppStringProcessor", function () { forIdentity: runtime2SessionA.account.address! }); - mockUiBridge.passwordToReturn = "000000"; + mockUiBridge.setPasswordToReturnForAttempt(1, "000000"); const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); expect(result).toBeSuccessful(); @@ -169,6 +169,29 @@ describe("AppStringProcessor", function () { expect(mockUiBridge).requestAccountSelectionNotCalled(); }); + test("should retry for a wrong password when handling a password protected RelationshipTemplate", async function () { + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + passwordProtection: { password: "password" } + }); + + mockUiBridge.setPasswordToReturnForAttempt(1, "wrongPassword"); + mockUiBridge.setPasswordToReturnForAttempt(2, "password"); + + mockUiBridge.accountIdToReturn = runtime2SessionA.account.id; + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordCalled("pw", undefined, 1); + expect(mockUiBridge).enterPasswordCalled("pw", undefined, 2); + expect(mockUiBridge).requestAccountSelectionCalled(2); + }); + describe("onboarding", function () { let runtime3: AppRuntime; const runtime3MockUiBridge = new MockUIBridge(); @@ -187,7 +210,7 @@ describe("AppStringProcessor", function () { passwordProtection: { password: "password" } }); - mockUiBridge.passwordToReturn = "password"; + mockUiBridge.setPasswordToReturnForAttempt(1, "password"); const result = await runtime2.stringProcessor.processTruncatedReference(tokenResult.value.truncatedReference); expect(result).toBeSuccessful();