Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Stop transaction attempts when prerequisite synchronization fails or is cancelled [#953].
- Prevent pending login migration or password setup from restoring reset wallet credentials [#953].

## [1.8.0] - 2026-03-25

### Changed
Expand Down Expand Up @@ -671,6 +674,7 @@ dusk-network/rusk#3984: https://github.com/dusk-network/rusk/issues/3984
[#939]: https://github.com/dusk-network/web-wallet/issues/939
[#946]: https://github.com/dusk-network/web-wallet/issues/946
[#948]: https://github.com/dusk-network/web-wallet/issues/948
[#953]: https://github.com/dusk-network/web-wallet/pull/953

<!-- VERSIONS -->

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
if (result.status === "success") {
dispatch("incrementStep");
setTimeout(() => {
walletStore.sync();
walletStore.sync().catch(() => {});
}, 20000);
} else {
throw new Error("Could not validate the transaction receipt");
Expand Down
97 changes: 90 additions & 7 deletions src/lib/services/loginInfoStorage/__tests__/index.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { bytesToBase64 } from "$lib/dusk/base64";
import { refreshLocalStoragePasswordInfo } from "$lib/wallet";

import loginInfoStorage from "..";

Expand Down Expand Up @@ -54,25 +55,107 @@ describe("loginInfoStorage", () => {
expect(loginInfoStorage.get()).toBeNull();
});

it("should expose a method to remove the login info from the local storage", () => {
it("should expose a method to remove the login info from the local storage", async () => {
localStorage.setItem(storeKey, storedInfo);
loginInfoStorage.remove();
await loginInfoStorage.remove();

expect(localStorage.getItem(storeKey)).toBeNull();
});

it("should expose a method to set the login info and convert its values to base64 before serialization", () => {
loginInfoStorage.set(loginInfo);
it("should expose a method to set the login info and convert its values to base64 before serialization", async () => {
await loginInfoStorage.set(loginInfo);

const stored = localStorage.getItem(storeKey);

expect(stored).toBe(storedInfo);
});

it("should preserve the numeric encryption version", () => {
it.each(["remove", "set"])(
"should coordinate %s with conditional migration writes",
async (method) => {
await loginInfoStorage.set(loginInfo);
const acquired = Promise.withResolvers();
const release = Promise.withResolvers();
const held = navigator.locks.request(storeKey, () => {
acquired.resolve(undefined);
return release.promise;
});
await acquired.promise;
const replacement = { ...loginInfo, version: 1 };
const changed =
method === "remove"
? loginInfoStorage.remove()
: loginInfoStorage.set(replacement);
const migration = loginInfoStorage.replace(loginInfo, replacement);

try {
await vi.waitFor(async () => {
expect((await navigator.locks.query()).pending).toHaveLength(2);
});
expect(localStorage.getItem(storeKey)).toBe(storedInfo);
} finally {
release.resolve(undefined);
await Promise.all([held, changed]);
}
await expect(migration).resolves.toBe(false);
expect(localStorage.getItem(storeKey)).toBe(
method === "remove" ? null : JSON.stringify(valuesToBase64(replacement))
);
}
);

it("should let a reset win over pending password setup", async () => {
await loginInfoStorage.set(loginInfo);
const encrypted = Promise.withResolvers();
const resume = Promise.withResolvers();
const encrypt = crypto.subtle.encrypt.bind(crypto.subtle);
const encryptSpy = vi
.spyOn(crypto.subtle, "encrypt")
.mockImplementationOnce(async (...args) => {
const result = await encrypt(...args);
encrypted.resolve(undefined);
await resume.promise;
return result;
});
const setup = refreshLocalStoragePasswordInfo(
["test mnemonic"],
"password"
);
await encrypted.promise;
const reset = loginInfoStorage.remove();
try {
await vi.waitFor(async () => {
expect((await navigator.locks.query()).pending).toHaveLength(1);
});
expect(localStorage.getItem(storeKey)).toBe(storedInfo);
} finally {
resume.resolve(undefined);
await Promise.all([setup, reset]);
encryptSpy.mockRestore();
}
expect(loginInfoStorage.get()).toBeNull();
});

it("should preserve the old login record when password setup fails", async () => {
await loginInfoStorage.set(loginInfo);
const error = new Error("Encryption failed");
const encryptSpy = vi
.spyOn(crypto.subtle, "encrypt")
.mockRejectedValueOnce(error);
try {
await expect(
refreshLocalStoragePasswordInfo(["test mnemonic"], "password")
).rejects.toBe(error);
expect(localStorage.getItem(storeKey)).toBe(storedInfo);
} finally {
encryptSpy.mockRestore();
}
});

it("should preserve the numeric encryption version", async () => {
const versionedLoginInfo = { ...loginInfo, version: 1 };

loginInfoStorage.set(versionedLoginInfo);
await loginInfoStorage.set(versionedLoginInfo);

expect(byteValuesToArray(loginInfoStorage.get())).toStrictEqual(
byteValuesToArray(versionedLoginInfo)
Expand Down
42 changes: 39 additions & 3 deletions src/lib/services/loginInfoStorage/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,48 @@ const loginInfoStorage = {
},

remove() {
localStorage.removeItem(storeKey);
return navigator.locks.request(storeKey, () => {
localStorage.removeItem(storeKey);
});
},

/** @param {WalletEncryptInfo} info */
/**
* Replace only the record that was read before migration's async work.
* All login writes share this lock: storage events alone arrive too late.
*
* @param {WalletEncryptInfo} expected
* @param {WalletEncryptInfo} info
* @param {AbortSignal} [signal]
* @returns {Promise<boolean>}
*/
replace(expected, info, signal) {
return navigator.locks.request(storeKey, { signal }, () => {
signal?.throwIfAborted();
const current = loginInfoStorage.get();
if (
current === null ||
toStorageString(current) !== toStorageString(expected)
) {
return false;
}
localStorage.setItem(storeKey, toStorageString(info));
return true;
});
},

/**
* An async producer runs under the lock so a later reset wins even while
* a new password's encryption is pending.
* ponytail: reset waits for encryption; use a persisted revision if resets
* must preempt a suspended writer.
*
* @param {WalletEncryptInfo | (() => Promise<WalletEncryptInfo>)} info
*/
set(info) {
localStorage.setItem(storeKey, toStorageString(info));
return navigator.locks.request(storeKey, async () => {
const value = typeof info === "function" ? await info() : info;
localStorage.setItem(storeKey, toStorageString(value));
});
},
};

Expand Down
Loading
Loading