Skip to content

Commit

Permalink
Retry processing password protected references for wrong passwords (#384
Browse files Browse the repository at this point in the history
)

* 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>
  • Loading branch information
jkoenig134 and mergify[bot] authored Jan 9, 2025
1 parent 3db301c commit f83c0ea
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 47 deletions.
86 changes: 59 additions & 27 deletions packages/app-runtime/src/AppStringProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<UserfriendlyResult<void>> {
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":
Expand Down Expand Up @@ -164,14 +170,40 @@ export class AppStringProcessor {
}
}

private async enterPassword(passwordProtection: SharedPasswordProtection): Promise<Result<string>> {
private async _fetchPasswordProtectedItemWithRetry<T>(
fetchFunction: (password: string) => Promise<Result<T>>,
passwordProtection: SharedPasswordProtection
): Promise<{ result: Result<T>; 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<UserfriendlyResult<LocalAccountDTO | undefined>> {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-runtime/src/extensibility/ui/IUIBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export interface IUIBridge {
showRequest(account: LocalAccountDTO, request: LocalRequestDVO): Promise<Result<void>>;
showError(error: UserfriendlyApplicationError, account?: LocalAccountDTO): Promise<Result<void>>;
requestAccountSelection(possibleAccounts: LocalAccountDTO[], title?: string, description?: string): Promise<Result<LocalAccountDTO | undefined>>;
enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise<Result<string>>;
enterPassword(passwordType: "pw" | "pin", pinLength?: number, attempt?: number): Promise<Result<string>>;
}
2 changes: 1 addition & 1 deletion packages/app-runtime/test/lib/FakeUIBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class FakeUIBridge implements IUIBridge {
throw new Error("Method not implemented.");
}

public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number): Promise<Result<string>> {
public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number, _attempt?: number): Promise<Result<string>> {
throw new Error("Method not implemented.");
}
}
9 changes: 5 additions & 4 deletions packages/app-runtime/test/lib/MockUIBridge.matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).");
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand Down
19 changes: 10 additions & 9 deletions packages/app-runtime/test/lib/MockUIBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ 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;
public set accountIdToReturn(value: string | undefined) {
this._accountIdToReturn = value;
}

private _passwordToReturn: string | undefined;
public set passwordToReturn(value: string | undefined) {
this._passwordToReturn = value;
private _passwordToReturnForAttempt: Record<number, string> = {};
public setPasswordToReturnForAttempt(attempt: number, password: string): void {
this._passwordToReturnForAttempt[attempt] = password;
}

private _calls: MockUIBridgeCall[] = [];
Expand All @@ -29,8 +29,8 @@ export class MockUIBridge implements IUIBridge {
}

public reset(): void {
this._passwordToReturn = undefined;
this._accountIdToReturn = undefined;
this._passwordToReturnForAttempt = {};

this._calls = [];
}
Expand Down Expand Up @@ -82,11 +82,12 @@ export class MockUIBridge implements IUIBridge {
return Promise.resolve(Result.ok(foundAccount));
}

public enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise<Result<string>> {
this._calls.push({ method: "enterPassword", passwordType, pinLength });
public enterPassword(passwordType: "pw" | "pin", pinLength?: number, attempt?: number): Promise<Result<string>> {
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));
}
}
33 changes: 28 additions & 5 deletions packages/app-runtime/test/runtime/AppStringProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down

0 comments on commit f83c0ea

Please sign in to comment.