Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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-Nns-Dapp-unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ proposal is successful, the changes it released will be moved from this file to

#### Security

- The Encode ICRC-1 Account utility now reads a large decimal subaccount ID as
a decimal number. Before, it read it as hexadecimal and returned a different
account.

#### Not Published

### Operations
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/lib/components/alfred/BuildIcrcAccountUtil.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@
return sub;
};

// The largest 32-byte value, 2**256 - 1, has 78 decimal digits.
const MAX_DECIMAL_SUBACCOUNT_DIGITS = 78;

// A decimal ID is the big-endian value of the 32-byte subaccount.
const parseDecimalSubAccount = (decimal: string): SubAccount => {
const digits = decimal.replace(/^0+/, "") || "0";
if (digits.length > MAX_DECIMAL_SUBACCOUNT_DIGITS) {
throw new Error($i18n.alfred.build_icrc_account_subaccount_error);
}
return parseHexSubAccount(BigInt(digits).toString(16));
};

const parseSubAccount = (input: string): SubAccount => {
const trimmed = input.trim();

Expand All @@ -42,11 +54,7 @@

const isDecimalNumber = /^\d+$/.test(trimmed);
if (isDecimalNumber) {
const num = Number(trimmed);
if (num > Number.MAX_SAFE_INTEGER) {
return parseHexSubAccount(trimmed);
}
return SubAccount.fromID(num);
return parseDecimalSubAccount(trimmed);
}
Comment thread
yhabib marked this conversation as resolved.

throw new Error($i18n.alfred.build_icrc_account_subaccount_error);
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/tests/e2e/alfred-icrc-account.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { AppPo } from "$tests/page-objects/App.page-object";
import { PlaywrightPageObjectElement } from "$tests/page-objects/playwright.page-object";
import { signInWithNewUser, step } from "$tests/utils/e2e.test-utils";
import { expect, test, type Page } from "@playwright/test";

// A canister principal. It keeps the expected account text short and fixed.
const OWNER = "rrkah-fqaaa-aaaaa-aaaaq-cai";

// The largest u64, and a common account ID. It is above
// Number.MAX_SAFE_INTEGER, so it is the value the old code read as hexadecimal.
const U64_MAX = "18446744073709551615";

// The account for the decimal value of U64_MAX.
const U64_MAX_AS_DECIMAL = `${OWNER}-pxt75ay.ffffffffffffffff`;

// The account the old code produced. It read the same digits as hexadecimal.
const U64_MAX_AS_HEX = `${OWNER}-rtjdh2q.18446744073709551615`;

// 2**256 - 1 is the largest subaccount. It has 78 decimal digits.
const MAX_SUBACCOUNT = (2n ** 256n - 1n).toString();
const MAX_SUBACCOUNT_ACCOUNT = `${OWNER}-37tqaua.${"f".repeat(64)}`;

// One digit more than the largest subaccount holds.
const TOO_LARGE_SUBACCOUNT = "1" + "0".repeat(78);

// A paste that is far longer than any real ID. The parser must reject it
// without a long BigInt conversion.
const VERY_LONG_INPUT = "1".repeat(200000);

// The budget for that paste, from the input event to the error message.
const VERY_LONG_INPUT_BUDGET_MS = 3000;

const SUBACCOUNT_ERROR =
"Invalid subaccount. Use a number, a 0x-prefixed hex, or a 64-char hex string.";

const openEncodeUtil = async (page: Page) => {
const palette = page.getByTestId("alfred-component");

await page.getByTestId("open-alfred").click();
await expect(palette).toBeVisible();
await palette.getByTestId("alfred-input").fill("Encode ICRC-1 Account");
await palette.getByTestId("alfred-result-button").first().click();
await expect(palette.getByTestId("alfred-util-principal")).toBeVisible();
};

// The parse function is pure, and the component spec at
// `src/tests/lib/components/alfred/BuildIcrcAccountUtil.spec.ts` covers every
// value. This spec covers what that one cannot: the util is reachable through
// the command palette, and a very long paste does not freeze the tab.
//
// `test.fixme` because no local replica runs on the review machine.
// `dfx start --pocketic` fails: dfx 0.32.0 ships pocket-ic-server 13.0.0, and
// that server rejects the saved snsdemo snapshot state. Remove `.fixme` when
// the replica starts again.
test.fixme(
"Encode ICRC-1 Account reads a decimal subaccount ID as decimal",
async ({ page, browser }) => {
const appPo = new AppPo(PlaywrightPageObjectElement.fromPage(page));
const palette = page.getByTestId("alfred-component");
const output = palette.getByTestId("alfred-util-hex-output");
const error = palette.locator(".error-message");
const principalInput = palette.getByTestId("alfred-util-principal");
const subaccountInput = palette.getByTestId("alfred-util-subaccount");

await page.goto("/");
await expect(page).toHaveTitle("Portfolio | Network Nervous System");

await step("Sign in");
await signInWithNewUser({ page, context: browser.contexts()[0] });

await step("Open the Encode ICRC-1 Account util from the palette");
await openEncodeUtil(page);

await step("A small decimal ID is unchanged");
await principalInput.fill(OWNER);
await subaccountInput.fill("12345");
await expect(output).toHaveText(`${OWNER}-xhbeadi.3039`);

await step("A decimal ID above MAX_SAFE_INTEGER reads as decimal");
await subaccountInput.fill(U64_MAX);
await expect(output).toHaveText(U64_MAX_AS_DECIMAL);

await step("That ID is never read as hexadecimal");
// This is the whole finding. The old code produced U64_MAX_AS_HEX here, and
// a deposit to it landed on another subaccount.
await expect(output).not.toHaveText(U64_MAX_AS_HEX);

await step("Leading zeros do not change the account");
await subaccountInput.fill(`${"0".repeat(40)}12345`);
await expect(output).toHaveText(`${OWNER}-xhbeadi.3039`);

await step("The largest subaccount, 2**256 - 1, is accepted");
await subaccountInput.fill(MAX_SUBACCOUNT);
await expect(output).toHaveText(MAX_SUBACCOUNT_ACCOUNT);

await step("A decimal ID above 32 bytes shows the error");
await subaccountInput.fill(TOO_LARGE_SUBACCOUNT);
await expect(error).toHaveText(SUBACCOUNT_ERROR);
await expect(output).toBeHidden();

await step("A 0x-prefixed hex ID still works");
await subaccountInput.fill("0xffffffffffffffff");
await expect(output).toHaveText(U64_MAX_AS_DECIMAL);

await step("A very long paste shows the error and does not freeze the tab");
const start = Date.now();
await subaccountInput.fill(VERY_LONG_INPUT);
await expect(error).toHaveText(SUBACCOUNT_ERROR);
expect(Date.now() - start).toBeLessThan(VERY_LONG_INPUT_BUDGET_MS);
Comment thread
yhabib marked this conversation as resolved.
Outdated

await step("The util still answers after that paste");
await subaccountInput.fill(U64_MAX);
await expect(output).toHaveText(U64_MAX_AS_DECIMAL);

await step("Escape closes the util and then the palette");
await page.keyboard.press("Escape");
await expect(palette.getByTestId("alfred-input")).toBeVisible();
// The panel drops a key that arrives within 50ms of the one before it.
await page.waitForTimeout(100);
await page.keyboard.press("Escape");
Comment thread
yhabib marked this conversation as resolved.
Outdated
await expect(palette).toBeHidden();

await appPo.waitForNotBusy();
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,51 @@ describe("BuildIcrcAccountUtil", () => {
);
});

it("should treat all-digit string exceeding MAX_SAFE_INTEGER as hex", async () => {
it("should encode a decimal subaccount ID above MAX_SAFE_INTEGER as decimal", async () => {
const { container } = render(BuildIcrcAccountUtil);

// 99999999999999999 is 0x016345785d89ffff.
const bigDigitString = "99999999999999999";
expect(Number(bigDigitString)).toBeGreaterThan(Number.MAX_SAFE_INTEGER);

await setInputValues(container, {
principal: testPrincipal,
subaccount: bigDigitString,
});

const output = getOutput(container);
expect(output).not.toBeNull();
expect(output?.textContent).toBe(
expectedIcrcAccount({
principal: testPrincipal,
subaccountBytes: hexStringToUint8Array(
"016345785d89ffff".padStart(64, "0")
),
})
);
});

it("should not read a decimal subaccount ID as hex", async () => {
const { container } = render(BuildIcrcAccountUtil);

const bigDigitString = "18446744073709551615";
await setInputValues(container, {
principal: testPrincipal,
subaccount: bigDigitString,
});

const uint64MaxBytes = new Uint8Array(32);
uint64MaxBytes.fill(0xff, 24);

const output = getOutput(container);
expect(output).not.toBeNull();
expect(output?.textContent).toBe(
expectedIcrcAccount({
principal: testPrincipal,
subaccountBytes: uint64MaxBytes,
})
);
expect(output?.textContent).not.toBe(
expectedIcrcAccount({
principal: testPrincipal,
subaccountBytes: hexStringToUint8Array(
Expand All @@ -122,6 +155,48 @@ describe("BuildIcrcAccountUtil", () => {
);
});

it("should show error for a decimal subaccount ID above 32 bytes", async () => {
const { container } = render(BuildIcrcAccountUtil);

await setInputValues(container, {
principal: testPrincipal,
subaccount: "9".repeat(78),
});

expect(getOutput(container)).toBeNull();
expect(getError(container)).not.toBeNull();
});

it("should show error for a very long digit string", async () => {
const { container } = render(BuildIcrcAccountUtil);

await setInputValues(container, {
principal: testPrincipal,
subaccount: "1".repeat(100000),
});

expect(getOutput(container)).toBeNull();
expect(getError(container)).not.toBeNull();
});

it("should ignore leading zeros in a decimal subaccount ID", async () => {
const { container } = render(BuildIcrcAccountUtil);

await setInputValues(container, {
principal: testPrincipal,
subaccount: "0".repeat(100) + "12345",
});

const output = getOutput(container);
expect(output).not.toBeNull();
expect(output?.textContent).toBe(
expectedIcrcAccount({
principal: testPrincipal,
subaccountBytes: SubAccount.fromID(12345).toUint8Array(),
})
);
});

it("should encode with a 32-byte hex subaccount", async () => {
const { container } = render(BuildIcrcAccountUtil);

Expand Down
Loading