From 0e7d6cc3c41ce29c0a13230c92ac4aad9fc9543d Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 22:52:41 +0200 Subject: [PATCH] Aegis: Solana bridge signs the real bytes and shows what they do - signAndSendTransaction signed String(Uint8Array) ("1,2,3,..."), so every dapp transaction got an invalid signature. Adapters gain signBytes(), which signs the exact message bytes. - v0 (VersionedTransaction) messages were parsed with the version byte as the header; the shared parser handles legacy and v0. - window.solana.signTransaction went through signMessage and its "moves no SOL" overlay. It now has its own handler and overlay, and signMessage refuses bytes that parse as a transaction (Phantom's rule), since such a signature is a valid transaction signature. - Overlays decode System transfers and SPL transfer / approve / set-authority, and list everything else as not decoded. --- bundled-addons/aegis/index.js | 240 +++++++++++++++++++------- bundled-addons/aegis/lib/chain-sol.js | 15 +- bundled-addons/aegis/wallet-inject.js | 13 +- 3 files changed, 200 insertions(+), 68 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 539fb817..112da42b 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -2687,6 +2687,128 @@ function previewBytes(bytes, max = 400) { return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`; } +// ---- Solana message parsing ------------------------------------------------ +const SOL_TOKEN_PROGRAMS = new Set([ + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", // SPL Token + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", // Token-2022 +]); +// Full wire: compact-u16(sigCount) || sig[64]*sigCount || message. +function splitSolWire(wire) { + let off = 0; + const compact = () => { + let n = 0, shift = 0; + for (;;) { + if (off >= wire.length) throw new Error("truncated tx wire"); + const b = wire[off++]; + n |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 14) throw new Error("compact-u16 too long"); + } + return n; + }; + const sigCount = compact(); + if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount); + const sigsStart = off; + const messageStart = sigsStart + sigCount * 64; + if (wire.length < messageStart) throw new Error("truncated tx wire"); + return { sigCount, sigsStart, messageBytes: wire.slice(messageStart) }; +} +// Structural parse of a legacy or v0 message. `ok:false` means the bytes +// cannot be a message — used both to sign transactions and to REFUSE +// transaction-shaped payloads handed to signMessage. +function parseSolMessage(msg, ourPub) { + try { + let off = 0, version = null; + if (!(msg instanceof Uint8Array) || msg.length < 3 + 1 + 32 + 32) throw new Error("too short"); + if (msg[0] & 0x80) { + version = msg[0] & 0x7f; off = 1; + if (version !== 0) throw new Error("unsupported message version " + version); + } + const numRequiredSigs = msg[off], numReadonlySigned = msg[off + 1], numReadonlyUnsigned = msg[off + 2]; + off += 3; + const compact = () => { + let n = 0, shift = 0; + for (;;) { + if (off >= msg.length) throw new Error("truncated"); + const b = msg[off++]; + n |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 14) throw new Error("bad compact-u16"); + } + return n; + }; + const keyCount = compact(); + if (keyCount < 1 || keyCount > 256) throw new Error("bad account key count"); + if (numRequiredSigs < 1 || numRequiredSigs > keyCount) throw new Error("bad header"); + if (numReadonlySigned > numRequiredSigs || numReadonlyUnsigned > keyCount - numRequiredSigs) throw new Error("bad header"); + if (msg.length < off + keyCount * 32 + 32) throw new Error("truncated keys"); + const keys = []; + for (let i = 0; i < keyCount; i++) { keys.push(msg.subarray(off, off + 32)); off += 32; } + const blockhash = msg.subarray(off, off + 32); off += 32; + const ixCount = compact(); + const instructions = []; + for (let i = 0; i < ixCount; i++) { + if (off >= msg.length) throw new Error("truncated instruction"); + const programIdx = msg[off++]; + const na = compact(); const accounts = []; + for (let k = 0; k < na; k++) { if (off >= msg.length) throw new Error("truncated accounts"); accounts.push(msg[off++]); } + const nd = compact(); + if (off + nd > msg.length) throw new Error("truncated instruction data"); + const data = msg.subarray(off, off + nd); off += nd; + instructions.push({ programIdx, accounts, data }); + } + let lookupTables = 0; + if (version === 0) lookupTables = compact(); // static keys suffice for the signer checks + let ourIndex = -1; + if (ourPub) { + for (let i = 0; i < keyCount; i++) { + let eq = true; + for (let j = 0; j < 32; j++) if (keys[i][j] !== ourPub[j]) { eq = false; break; } + if (eq) { ourIndex = i; break; } + } + } + return { ok: true, version, numRequiredSigs, keys, blockhash, instructions, lookupTables, ourIndex, length: msg.length }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } +} +// Overlay rows: System transfers and SPL transfer/approve/set-authority are +// decoded; everything else is named by program so the user at least sees +// how much of the transaction Aegis could not read. +function solInstructionRows(parsed) { + const b58 = (b) => ctx.d.base58check.encodeBase58(b); + const u64le = (d, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(d[o + i] || 0); return v; }; + const rows = []; + const lines = parsed.instructions.map((ix, i) => { + if (ix.programIdx >= parsed.keys.length) return `${i + 1}. program from a lookup table (not decoded)`; + const progB58 = b58(parsed.keys[ix.programIdx]); + const acct = (n) => { + const idx = ix.accounts[n]; + return idx == null ? "?" : (idx < parsed.keys.length ? b58(parsed.keys[idx]) : `lookup-table account #${idx}`); + }; + const d = ix.data; + if (progB58 === "11111111111111111111111111111111" && d.length >= 12 && d[0] === 2 && d[1] === 0 && d[2] === 0 && d[3] === 0) { + return `${i + 1}. System transfer ${fmtValue(u64le(d, 4).toString(), 9)} SOL → ${acct(1)}`; + } + if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 9) { + if (d[0] === 3) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(1)}`; + if (d[0] === 12) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(2)}`; + if (d[0] === 4) return `${i + 1}. Token APPROVE: delegate ${acct(1)} may spend ${u64le(d, 1).toString()} units`; + } + if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 1 && d[0] === 6) return `${i + 1}. Token SET AUTHORITY on ${acct(0)} — hands the account to someone else`; + return `${i + 1}. program ${progB58.slice(0, 8)}…: ${d.length} bytes, ${ix.accounts.length} account${ix.accounts.length === 1 ? "" : "s"} (not decoded)`; + }); + rows.push({ label: parsed.instructions.length === 1 ? "Instruction" : "Instructions", value: lines.join("\n") || "(none)", mono: true }); + if (parsed.version === 0) { + rows.push({ label: "Format", value: `v0 · ${parsed.lookupTables} address-lookup table${parsed.lookupTables === 1 ? "" : "s"} (accounts inside them are not shown)` }); + } + const others = parsed.numRequiredSigs - 1; + rows.push({ label: "Signers", value: others ? `${parsed.numRequiredSigs} — you (slot #${parsed.ourIndex}) + ${others} other${others === 1 ? "" : "s"}` : "1 — you" }); + return rows; +} + async function withOriginLock(origin, fn) { if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); pendingByOrigin.add(origin); @@ -3318,22 +3440,55 @@ function registerPageMessages(api) { if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); const rt = activeSolRuntime(); const b64 = String(p && p.messageB64 || ""); - const bytes = Buffer.from(b64, "base64"); - if (bytes.length > 4096) throw new Error("message too long"); + const bytes = new Uint8Array(Buffer.from(b64, "base64")); + if (bytes.length > 16384) throw new Error("message too long"); + // A "message" that parses as a transaction message would, once signed, + // be a valid transaction signature behind a "moves no SOL" overlay. + // Phantom refuses these; so do we. + if (parseSolMessage(bytes, rt.adapter._pub).ok) { + throw new Error("refusing to sign: this message is a Solana transaction. Use signTransaction."); + } return withOriginLock(origin, async () => { - const preview = bytes.every((c) => c >= 0x20 && c < 0x7f) ? bytes.toString("utf8") : `<${bytes.length} bytes: 0x${bytes.toString("hex").slice(0, 60)}…>`; const pick = await api.approvalModal({ title: "Sign a Solana message?", origin, body: "Signing proves you control this address. It moves no SOL.", rows: [ - { label: "Message", value: preview.length > 400 ? preview.slice(0, 400) + "…" : preview, mono: true }, + { label: "Message", value: previewBytes(bytes), mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); - return rt.adapter.signMessage(bytes); + return rt.adapter.signBytes(bytes); + }); + }); + // Dapp-built transaction the dapp will broadcast itself (window.solana + // .signTransaction). It used to go through signMessage and its "moves no + // SOL" overlay; it gets its own decoded overlay now: signing IS sending. + api.onMessage("sol.signTransaction", async (p, m) => { + const origin = fromPage(m); + if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); + const rt = activeSolRuntime(); + const messageBytes = new Uint8Array(Buffer.from(String(p && p.messageB64 || ""), "base64")); + const parsed = parseSolMessage(messageBytes, rt.adapter._pub); + if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error); + if (parsed.ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); + if (parsed.ourIndex >= parsed.numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${parsed.ourIndex}, requiredSigs ${parsed.numRequiredSigs})`); + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + const rows = solInstructionRows(parsed); + rows.push({ label: "Address", value: snap.address, mono: true }); + rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); + const pick = await api.approvalModal({ + title: "Sign a Solana transaction?", + origin, + body: "The site built this transaction and will broadcast it itself — signing it is the same as sending it.", + rows, + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signBytes(messageBytes); }); }); // Dapp-built transaction. The main-world bridge passes the FULL wire @@ -3349,75 +3504,34 @@ function registerPageMessages(api) { const wireB64 = String(p && p.wireB64 || ""); if (!wireB64) throw new Error("wireB64 required (full serialized transaction)"); const wire = new Uint8Array(Buffer.from(wireB64, "base64")); - // Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message - let off = 0; - const readCompactU16 = () => { - let n = 0, shift = 0; - while (true) { - const b = wire[off++]; - n |= (b & 0x7f) << shift; - if ((b & 0x80) === 0) break; - shift += 7; - if (shift > 21) throw new Error("compact-u16 too long"); - } - return n; - }; - const sigCount = readCompactU16(); - if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount); - const sigsStart = off; - const messageStart = sigsStart + sigCount * 64; - if (wire.length < messageStart) throw new Error("truncated tx wire"); - const messageBytes = wire.slice(messageStart); - // Parse the message enough to find our pubkey's index in the account - // list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || … - if (messageBytes.length < 3 + 1 + 32) throw new Error("message too short"); - const numRequiredSigs = messageBytes[0]; - let moff = 3; - const readKeyCount = () => { - let n = 0, shift = 0; - while (true) { - const b = messageBytes[moff++]; - n |= (b & 0x7f) << shift; - if ((b & 0x80) === 0) break; - shift += 7; - } - return n; - }; - const keyCount = readKeyCount(); - if (keyCount < 1 || keyCount > 64) throw new Error("bad account key count"); - // Find our public key among the key list. - const ourPub = rt.adapter._pub; - let ourIndex = -1; - for (let i = 0; i < keyCount; i++) { - const key = messageBytes.subarray(moff + i * 32, moff + (i + 1) * 32); - let eq = true; - for (let j = 0; j < 32; j++) if (key[j] !== ourPub[j]) { eq = false; break; } - if (eq) { ourIndex = i; break; } - } + const { sigsStart, messageBytes } = splitSolWire(wire); + // Legacy and v0 messages both parse (v0 used to be read with its + // version byte as the header, so the signer lookup was garbage); our + // key must be a required signer. + const parsed = parseSolMessage(messageBytes, rt.adapter._pub); + if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error); + const { numRequiredSigs, ourIndex } = parsed; if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`); return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); - const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0; + const rows = solInstructionRows(parsed); + rows.push({ label: "Address", value: snap.address, mono: true }); + rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); const pick = await api.approvalModal({ title: "Sign + send a Solana transaction?", origin, - body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.", - rows: [ - { label: "Message size", value: `${messageBytes.length} bytes` }, - { label: "Required signers", value: otherSigners - ? `${numRequiredSigs} — you (slot #${ourIndex}) + ${otherSigners} other${otherSigners === 1 ? "" : "s"}` - : "1 — you" }, - { label: "Address", value: snap.address, mono: true }, - { label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }, - ], + body: "The site built this transaction. Check every instruction — anything marked 'not decoded' is a program Aegis cannot read.", + rows, actions: [{ id: "send", label: "Sign & send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); - // Sign the message and patch our slot. Any partial signatures already - // in the wire (from tx.partialSign()) at other slots are preserved. - const sigInfo = rt.adapter.signMessage(messageBytes); + // Sign the exact message bytes and patch our slot. signMessage() ran + // the bytes through String(), so every signature was over "1,2,3,…" + // and the network rejected it. Partial signatures already in the wire + // (tx.partialSign()) at other slots are preserved. + const sigInfo = rt.adapter.signBytes(messageBytes); const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature); if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length"); const wireOut = new Uint8Array(wire); // copy so we don't mutate caller diff --git a/bundled-addons/aegis/lib/chain-sol.js b/bundled-addons/aegis/lib/chain-sol.js index 8d478292..44cf84d7 100644 --- a/bundled-addons/aegis/lib/chain-sol.js +++ b/bundled-addons/aegis/lib/chain-sol.js @@ -444,8 +444,21 @@ module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { // Solana's convention: ed25519 signature over the raw message bytes, // returned as {publicKey, signature} both base58. Dapps that follow // the wallet-adapter standard verify against these. + // Sign exact bytes — transaction messages from dapps, or the UTF-8 of a + // text message. Never coerce through String(): a Uint8Array stringifies + // to "1,2,3" and a Buffer to lossy UTF-8, both of which produce + // signatures over the wrong bytes. + signBytes(bytes) { + if (!(bytes instanceof Uint8Array)) throw new Error("signBytes expects a Uint8Array"); + const sig = ed25519.sign(bytes, this._priv); + return { + address: this.address, + publicKey: base58.encode(this._pub), + signature: base58.encode(sig), + }; + } signMessage(message) { - const bytes = new TextEncoder().encode(String(message)); + const bytes = message instanceof Uint8Array ? message : new TextEncoder().encode(String(message)); const sig = ed25519.sign(bytes, this._priv); return { address: this.address, diff --git a/bundled-addons/aegis/wallet-inject.js b/bundled-addons/aegis/wallet-inject.js index 41068427..1a73782d 100644 --- a/bundled-addons/aegis/wallet-inject.js +++ b/bundled-addons/aegis/wallet-inject.js @@ -467,11 +467,16 @@ const mainWorldSource = `(function () { return { signature: r.txid, publicKey: solState.publicKey }; } async function solSignTransaction(tx) { - if (!tx || typeof tx.serializeMessage !== "function" || typeof tx.addSignature !== "function") { - throw new Error("Aegis: pass a @solana/web3.js Transaction"); + // Legacy Transaction has serializeMessage(); VersionedTransaction keeps + // its message under .message. Both take addSignature(publicKey, sig). + const legacy = tx && typeof tx.serializeMessage === "function"; + if (!tx || typeof tx.addSignature !== "function" || (!legacy && !(tx.message && typeof tx.message.serialize === "function"))) { + throw new Error("Aegis: pass a @solana/web3.js Transaction or VersionedTransaction"); } - const messageBytes = tx.serializeMessage(); - const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) }); + const messageBytes = legacy ? tx.serializeMessage() : tx.message.serialize(); + // A transaction is signed through its own handler + overlay; the + // "sign a message" path refuses transaction-shaped bytes on purpose. + const r = await invoke("sol.signTransaction", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) }); // r.signature is base58 of the 64-byte ed25519 sig. tx.addSignature(solState.publicKey, base58ToBytes(r.signature)); return tx;