diff --git a/TODO.md b/TODO.md
index 8b17986..985e5b6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -57,6 +57,10 @@ undefined identifiers, which is how
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
+- 2026-08-11: Extended-key import hardened — the base58 checksum is now enforced
+ on every xprv and xpub, and a non-master key is refused with an explanation
+ instead of being derived beneath
+ ([#210](https://git.eeqj.de/sneak/AutistMask/issues/210)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
diff --git a/src/popup/index.html b/src/popup/index.html
index 361e864..b40e098 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -136,7 +136,9 @@
Paste your extended private key (xprv) below. This will
- import the HD wallet and scan for used addresses.
+ import the HD wallet and scan for used addresses. It
+ must be the master key for the wallet; an account-level
+ or child key is not supported.
{
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
@@ -299,19 +324,7 @@ describe("isValidXprv", () => {
expect(wallet.isValidXprv(xpub)).toBe(false);
});
- // Skipped: this asserts the correct behaviour, which the code does not
- // currently have. isValidXprv gates the paste-your-extended-private-key
- // import in src/popup/views/addWallet.js:215, and it accepts a key with a
- // one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58
- // checksum verification whenever the decoded payload is the usual 82
- // bytes, which is the whole point of that checksum. Measured on this
- // vector: changing any one of the last 14 characters passes validation,
- // and for 9 of those 14 positions the import silently yields a *different*
- // wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of
- // 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown.
- // Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope
- // here, which is tests only. Unskip when it is fixed.
- test.skip("rejects an extended key with a one-character typo", () => {
+ test("rejects an extended key with a one-character typo", () => {
const index = BIP32_VECTOR_1_XPRV.length - 8;
const typo =
BIP32_VECTOR_1_XPRV.slice(0, index) +
@@ -320,6 +333,125 @@ describe("isValidXprv", () => {
expect(wallet.isValidXprv(typo)).toBe(false);
});
+
+ // The base58 checksum exists to make a mistyped key impossible to use, and
+ // ethers does not enforce it: HDNodeWallet.fromExtendedKey skips checksum
+ // verification whenever the decoded payload is the usual 82 bytes, which
+ // is precisely the case it is there to catch. A typo anywhere in the key
+ // must be refused, not silently turned into someone else's wallet.
+ test("no single-character typo anywhere in the key is accepted", () => {
+ const accepted = singleCharacterTypos(BIP32_VECTOR_1_XPRV).filter(
+ (typo) => wallet.isValidXprv(typo),
+ );
+
+ expect(accepted).toEqual([]);
+ });
+
+ test("a typo never yields a wallet, let alone a different one", () => {
+ const correct = wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
+ const derived = [];
+ for (const typo of singleCharacterTypos(BIP32_VECTOR_1_XPRV)) {
+ try {
+ derived.push(wallet.hdWalletFromXprv(typo).firstAddress);
+ } catch {
+ // Rejected, which is the required behaviour.
+ }
+ }
+
+ expect(derived).toEqual([]);
+ expect(correct.firstAddress).toBe(
+ "0x022b971dFF0C43305e691DEd7a14367AF19D6407",
+ );
+ });
+});
+
+describe("extended key depth", () => {
+ // hdWalletFromXprv derives the BIP-44 Ethereum account path from the key
+ // it is given. That is only the path it names when the key is the master
+ // key. Under an account-level key the same derivation lands at
+ // m/44'/60'/0'/44'/60'/0'/0, whose addresses correspond to nothing the
+ // user holds, so a non-master key is refused rather than derived from.
+ test("a master key is a master key", () => {
+ expect(wallet.isMasterExtendedKey(masterXprv(VECTOR_PHRASE))).toBe(
+ true,
+ );
+ expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV)).toBe(true);
+ });
+
+ test("an account-level key is not a master key", () => {
+ expect(wallet.isMasterExtendedKey(accountXprv(VECTOR_PHRASE))).toBe(
+ false,
+ );
+ });
+
+ test("a derived xpub is not a master key", () => {
+ expect(
+ wallet.isMasterExtendedKey(
+ wallet.hdWalletFromMnemonic(VECTOR_PHRASE).xpub,
+ ),
+ ).toBe(false);
+ });
+
+ test("a mistyped key is not a master key either", () => {
+ expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV + "a")).toBe(
+ false,
+ );
+ });
+
+ test("hdWalletFromXprv rejects an account-level key", () => {
+ expect(() =>
+ wallet.hdWalletFromXprv(accountXprv(VECTOR_PHRASE)),
+ ).toThrow(/master/i);
+ });
+
+ test("getSignerForAddress rejects an account-level key", () => {
+ expect(() =>
+ wallet.getSignerForAddress(
+ { type: "xprv" },
+ 0,
+ accountXprv(VECTOR_PHRASE),
+ ),
+ ).toThrow(/master/i);
+ });
+
+ test("the account-level key is well-formed, so only depth rejects it", () => {
+ expect(wallet.isValidXprv(accountXprv(VECTOR_PHRASE))).toBe(true);
+ });
+
+ test("a master key still imports and derives the published addresses", () => {
+ const { xpub, firstAddress } = wallet.hdWalletFromXprv(
+ masterXprv(VECTOR_PHRASE),
+ );
+
+ expect(firstAddress).toBe(VECTOR_ADDRESSES[0]);
+ expect(
+ [0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
+ ).toEqual(VECTOR_ADDRESSES);
+ });
+});
+
+describe("deriveAddressFromXpub checksum enforcement", () => {
+ // The xpub path shares the hole: fromExtendedKey accepts a mistyped xpub
+ // just as readily, and deriveAddressFromXpub would hand back addresses
+ // from a different tree.
+ const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
+
+ test("the correct xpub still derives the published addresses", () => {
+ expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(VECTOR_ADDRESSES[0]);
+ });
+
+ test("no single-character typo anywhere in an xpub is accepted", () => {
+ const derived = [];
+ for (const typo of singleCharacterTypos(xpub)) {
+ try {
+ derived.push(wallet.deriveAddressFromXpub(typo, 0));
+ } catch {
+ // Rejected, which is the required behaviour.
+ }
+ }
+
+ expect(derived).toEqual([]);
+ });
});
describe("isValidMnemonic", () => {