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.
This commit is contained in:
Local Dev 2026-10-03 22:52:41 +02:00
parent 314bce0905
commit 0e7d6cc3c4
3 changed files with 200 additions and 68 deletions

View file

@ -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

View file

@ -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,

View file

@ -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;