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:
parent
314bce0905
commit
0e7d6cc3c4
3 changed files with 200 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue