From 4075c655044fd8572b8fcbe9b2da3752f142a461 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 28 Sep 2026 20:05:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(aegis):=200.13.2=20=E2=80=94=20accept=20a?= =?UTF-8?q?=20seed=20QR=20that=20carries=20a=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real export read "1|buffalo body coyote poem …" and was rejected: the classifier only accepted a bare phrase, so a version marker in front of the words was enough to make a valid seed look like junk. Wallets wrap the phrase in their own envelope — a version number, sometimes a derivation path, pipe- or comma-separated, sometimes JSON. Rather than guess which wallet produced it, the payload is now split on every run of non-letters (spaces survive, since they separate the words) and any BIP39-shaped run in the pieces is taken as the phrase. A derivation path found anywhere in the original string is carried over too. Being tolerant of the wrapper does not loosen what counts as a phrase: the pieces must still be 12/15/18/21/24 words of 3-8 lowercase letters, so a URL or an address breaks into single-word pieces and matches nothing. Verified that the phishing-URL and address cases still fill no field. The path only lands in the box when the box is empty. That field is prefilled with the coin's default and may have been edited, and silently changing which addresses get derived is what lost funds look like; if a path is already there and differs, the message names both and leaves the choice to the user. The unrecognised-payload preview also grew to 90 characters, since 48 truncated the evidence needed to tell what a rejected QR actually contained. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.js | 56 ++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 222e9354..4a2306f0 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.13.1", + "version": "0.13.2", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index bc30eecc..09574d77 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1483,18 +1483,42 @@ const IMPORT_COIN_CONFIG = { // phish; the user still reads what landed in the box and presses Import, and // the real validation happens in the host handler either way. const BIP39_LENGTHS = new Set([12, 15, 18, 21, 24]); +// A BIP39 English phrase is only lowercase a-z words, 3-8 letters each, in +// one of the defined lengths. Word membership is not checked here — the host +// handler does that when it derives; this only decides which box to fill. +function mnemonicIn(str) { + const words = String(str || "").toLowerCase().split(/\s+/).filter(Boolean); + if (!BIP39_LENGTHS.has(words.length)) return null; + if (!words.every((w) => /^[a-z]{3,8}$/.test(w))) return null; + return { value: words.join(" "), words: words.length }; +} function classifyScannedSecret(text) { const s = String(text || "").trim(); if (!s) return { kind: "empty" }; - // BIP39: only lowercase a-z words, in one of the defined lengths. - const words = s.toLowerCase().split(/\s+/).filter(Boolean); - if (BIP39_LENGTHS.has(words.length) && words.every((w) => /^[a-z]{3,8}$/.test(w))) { - return { kind: "mnemonic", value: words.join(" "), words: words.length }; - } + + const bare = mnemonicIn(s); + if (bare) return { kind: "mnemonic", value: bare.value, words: bare.words }; + // WIF: base58, 51-52 chars. 5/K/L = BTC-family mainnet, 9/c = testnet. if (/^[5KL9c][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return { kind: "wif", value: s }; // Raw 32-byte hex, with or without 0x. if (/^(0x)?[0-9a-fA-F]{64}$/.test(s)) return { kind: "hex", value: s }; + + // Wrapped phrases. Several wallets export the seed inside an envelope — + // a version marker, the words, sometimes a derivation path, pipe- or + // comma-separated ("1||m/44'/145'/0'"). Split on every run of + // non-letters (keeping spaces, which separate the words) and look for a + // BIP39-shaped run in any piece. This is tolerant of the wrapper without + // being loose about what counts as a phrase: a URL or an address still + // breaks into single-word pieces and matches nothing. + for (const piece of s.split(/[^A-Za-z ]+/)) { + const hit = mnemonicIn(piece); + if (!hit) continue; + // A path anywhere in the payload is worth carrying over — pulled from + // the ORIGINAL string, since splitting on non-letters shreds it. + const path = (s.match(/m(?:\/\d+'?)+/) || [])[0] || null; + return { kind: "mnemonic", value: hit.value, words: hit.words, path, wrapped: true }; + } return { kind: "unknown", value: s }; } @@ -1545,7 +1569,25 @@ function wireQrImport(overlay) { if (found.kind === "mnemonic") { const ta = overlay.querySelector("#imMnemonic"); if (ta) { ta.value = found.value; ta.dispatchEvent(new Event("input", { bubbles: true })); } - say(`Read a ${found.words}-word phrase. Check it, pick the coin and network, then press Import.`, false); + // A derivation path in the QR is worth surfacing, but it does not get + // to silently replace a path already in the box — that box is + // prefilled with this coin's default and may have been edited, and + // quietly changing which addresses get derived is the kind of thing + // that looks like lost funds. Fill it only when empty; otherwise say + // what the QR carried and let the user decide. + let pathNote = ""; + if (found.path) { + const pf = overlay.querySelector("#imPath"); + if (pf && !pf.value.trim()) { + pf.value = found.path; + pf.dispatchEvent(new Event("input", { bubbles: true })); + pathNote = ` Its derivation path ${found.path} went into the path box.`; + } else if (pf && pf.value.trim() !== found.path) { + pathNote = ` It also carried the path ${found.path} — the box says ${pf.value.trim()}, change it if that is wrong.`; + } + } + const unwrapped = found.wrapped ? " (unwrapped from the QR's own format)" : ""; + say(`Read a ${found.words}-word phrase${unwrapped}.${pathNote} Check it, pick the coin and network, then press Import.`, false); } else if (found.kind === "wif" || found.kind === "hex") { const raw = overlay.querySelector("#imRaw"); if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); } @@ -1553,7 +1595,7 @@ function wireQrImport(overlay) { } else { // Don't paste unrecognised payloads into a key field — show a short // preview so the user can see it was, say, a URL, and stop there. - const peek = found.value.length > 48 ? found.value.slice(0, 48) + "…" : found.value; + const peek = found.value.length > 90 ? found.value.slice(0, 90) + "…" : found.value; say(`That QR decoded to something that is not a seed phrase or key: "${peek}"`, true); } } catch (err) {