fix(aegis): 0.13.2 — accept a seed QR that carries a wrapper

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.
This commit is contained in:
Local Dev 2026-09-28 20:05:16 +02:00
parent 72b086fa7e
commit 4075c65504
2 changed files with 50 additions and 8 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.13.1", "version": "0.13.2",
"category": "plugin", "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.", "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", "author": "Silent Mode",

View file

@ -1483,18 +1483,42 @@ const IMPORT_COIN_CONFIG = {
// phish; the user still reads what landed in the box and presses Import, and // phish; the user still reads what landed in the box and presses Import, and
// the real validation happens in the host handler either way. // the real validation happens in the host handler either way.
const BIP39_LENGTHS = new Set([12, 15, 18, 21, 24]); 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) { function classifyScannedSecret(text) {
const s = String(text || "").trim(); const s = String(text || "").trim();
if (!s) return { kind: "empty" }; 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); const bare = mnemonicIn(s);
if (BIP39_LENGTHS.has(words.length) && words.every((w) => /^[a-z]{3,8}$/.test(w))) { if (bare) return { kind: "mnemonic", value: bare.value, words: bare.words };
return { kind: "mnemonic", value: words.join(" "), words: words.length };
}
// WIF: base58, 51-52 chars. 5/K/L = BTC-family mainnet, 9/c = testnet. // 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 }; 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. // Raw 32-byte hex, with or without 0x.
if (/^(0x)?[0-9a-fA-F]{64}$/.test(s)) return { kind: "hex", value: s }; 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|<words>|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 }; return { kind: "unknown", value: s };
} }
@ -1545,7 +1569,25 @@ function wireQrImport(overlay) {
if (found.kind === "mnemonic") { if (found.kind === "mnemonic") {
const ta = overlay.querySelector("#imMnemonic"); const ta = overlay.querySelector("#imMnemonic");
if (ta) { ta.value = found.value; ta.dispatchEvent(new Event("input", { bubbles: true })); } 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") { } else if (found.kind === "wif" || found.kind === "hex") {
const raw = overlay.querySelector("#imRaw"); const raw = overlay.querySelector("#imRaw");
if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); } if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); }
@ -1553,7 +1595,7 @@ function wireQrImport(overlay) {
} else { } else {
// Don't paste unrecognised payloads into a key field — show a short // 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. // 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); say(`That QR decoded to something that is not a seed phrase or key: "${peek}"`, true);
} }
} catch (err) { } catch (err) {