Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 18188c2f62 fix: say a second wallet's password is separate when one is chosen (closes #374)
check / check (push) Failing after 0s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 0s
The add-wallet screen offered only "Choose a password" while each wallet
keeps its own encrypted secret, so a second wallet silently accepted a
password different from the first with nothing marking it as separate. A
note now appears on that screen when the profile already holds a wallet,
saying each wallet has its own password and this one need not match any
already in use. It is shown only then — the first wallet has no other
password to differ from — and is decided on screen entry, so it does not
move the password fields. It promises no recovery or reset, staying
consistent with the no-password-reset design.

Model: opus-4-8
2026-09-21 12:59:31 +00:00
clawbot 99292b9188 fix: honour a transaction response only for a transaction approval (closes #262)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m42s
The liveness fix this issue describes — settle 4001 on release when the window
a retry would use is gone — already landed with
#271. This completes the rest.

AUTISTMASK_TX_RESPONSE now refuses any approval that is not a transaction
approval, so a reject can no longer retire a sign or connection approval, and a
signed artifact never runs the broadcast path against one — which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval broadcasting
behind them: each is declined and the dApp still receives its broadcast result.

Model: opus-4-8
2026-09-21 09:54:40 +02:00
6 changed files with 266 additions and 0 deletions
+26
View File
@@ -45,6 +45,32 @@ but the review is broader than any of them.
# Completed Steps
- 2026-09-21: A transaction response is honoured only for a transaction
approval, and the three remaining approval-settlement paths are pinned
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
the issue asks for — settle `4001` on release when the window it would be
retried in is gone — already landed with
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
approval, so a reject no longer retires a sign or connection approval and a
signed artifact never runs the broadcast path against one, which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval
broadcasting behind them: each is declined and the dApp still receives its
broadcast result.
- 2026-09-21: Adding a second wallet no longer accepts a different password with
nothing saying it is a separate one
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
own encrypted secret, so per-wallet passwords are by design; the add-wallet
screen said only "Choose a password". A note now appears on that screen when
the profile already holds a wallet, stating that each wallet has its own
password and this one need not match any already in use. It is shown only
then, since the first wallet has no other password to differ from, and it
stays consistent with the no-reset reality of
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
recovery or reset.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
+9
View File
@@ -1344,6 +1344,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
// This message signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A sign or connection approval
// carries no approvedTx, and reaching the broadcast path with one used
// to fail closed by throwing deeper in; refusing here keeps a future
// refactor from turning that incidental throw into a live path, and
// keeps a reject on this message from retiring an approval of another
// kind.
if (approval.type !== "tx") return false;
// A reject arriving while an attempt holds the approval is refused,
// not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request
+15
View File
@@ -152,6 +152,21 @@
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<!-- Shown only when the profile already holds a wallet:
each wallet has its own password (its own
encryptedSecret), so a second wallet does not reuse
the first one's. addWallet.js toggles this on screen
entry from state.wallets.length, so it is constant
while the screen is up and moves nothing. -->
<p
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
id="add-wallet-separate-password-note"
>
You already have a wallet. Each wallet has its own
password: the one you choose here is only for this new
wallet, and it need not match any password you already
use.
</p>
<label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
+15
View File
@@ -100,9 +100,24 @@ function clear() {
$("add-wallet-phrase-warning").style.visibility = "hidden";
}
// Each wallet has its own password (its own encryptedSecret), so adding a
// second wallet does not reuse the first one's. The note that says so is
// only meaningful once a wallet exists — on the first wallet there is no
// other password to be separate from — so it is shown only then. This is
// decided on entry and stays put while the screen is up, so it does not
// move the password fields the way a per-tab hint would.
function updateSeparatePasswordNote() {
const hasExistingWallet = state.wallets.length > 0;
$("add-wallet-separate-password-note").classList.toggle(
"hidden",
!hasExistingWallet,
);
}
function show() {
clear();
switchMode("mnemonic");
updateSeparatePasswordNote();
showView("add-wallet");
}
+58
View File
@@ -0,0 +1,58 @@
// Adding a second wallet accepts a password different from the first one's
// with nothing on screen saying the two are separate — each wallet has its
// own encryptedSecret, so per-wallet passwords are by design, but the add
// screen said only "Choose a password"
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
//
// The fix is copy: a note on the password screen that says each wallet has
// its own password and this one need not match. It is only meaningful once
// a wallet exists — on the very first wallet there is no other password to
// be separate from — so it is shown then and hidden otherwise. These boot
// the real popup and reach the add-wallet screen through the same button a
// user presses, so the note's visibility is decided by the real show().
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
POPUP_HTML,
} = require("./support/popupBoot");
const NOTE = "add-wallet-separate-password-note";
afterEach(() => {
cleanupPopup();
});
describe("second-wallet password note", () => {
test("hidden while onboarding the first wallet", async () => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(true);
});
test("shown when a wallet already exists", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
await page.click("btn-main-add-wallet");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(false);
});
// The copy states the two facts the definition of done asks for — each
// wallet has its own password, and this one need not match — and stays
// consistent with the no-password-reset reality of
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
// recovery or reset here.
test("the note says the password is per-wallet and need not match", () => {
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
.exec(POPUP_HTML)[1]
.replace(/\s+/g, " ")
.trim();
expect(note).toContain("its own");
expect(note).toContain("need not match");
expect(note).not.toMatch(/recover|reset/i);
});
});
+143
View File
@@ -1541,6 +1541,149 @@ describe("a claimed approval outlives every other retirement path", () => {
});
});
// The approval popup connects a port named for its approval whatever the
// approval's kind, so a decision or a disconnect on that port can reach a
// transaction approval. Both must be declined: the port decides only
// site-connection approvals, and settling a transaction approval it does not
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
// bug, where the page is told the request was rejected as the transaction goes
// out. These three paths route through settleApproval() and, before this
// suite, were exercised only against site approvals.
describe("the site-connection port never retires a transaction approval", () => {
async function txMidBroadcast() {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
return { bg, pending, id, inFlight, first };
}
test("an approve on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(true, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a reject on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(false, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a port disconnect does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).disconnect();
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
});
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A reject shaped as this message used to
// retire a sign or connection approval outright, and an approve carrying a
// signed artifact used to run the broadcast path against an approval that names
// no transaction, failing closed only by throwing deeper in.
describe("a transaction response is honoured only for a transaction approval", () => {
test("a reject does not retire a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: its own reject settles it.
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
test("a reject does not retire a connection approval", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: the port that owns it connects the site.
const port = bg.connectApproval(id);
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(pending.result()).toBeNull();
});
});
// A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no