From 8fcc0e24330edd953db20dea4ba7f4ce17f6645d Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 22:19:51 +0200 Subject: [PATCH] feat(theseus/aegis): EIP-712 signTypedData_v4 + Solana multi-signer send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the dapp bridges. Both change wire shape only — no new UI, existing wallets keep signing byte-identically for the flows they already covered. - lib/eip712.js: full EIP-712 typed-data encoder — encodeType with alphabetically-sorted transitive sub-types, typeHash, encodeValue for string / address / bool / uint*/int* (any width) / bytes / bytesN / nested structs / dynamic and fixed arrays, hashStruct recursion, digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct). Verified against the spec §"Ether Mail" test vector — hashStruct on both the domain and the message plus the final digest all match the canonical values byte-for-byte (see scratchpad/verify-eip712.mjs). - chain-eth.js: exposes signTypedDataDigest(digest32) that signs the precomputed digest with r||s||v (v = 27+recid), the same envelope personal_sign uses. Aegis computes the digest server-side (in the addon) so a bug in the encoder can't be tricked by a malicious dapp into signing over data the user never saw. - index.js: eth.signTypedData handler shows domain (name · version · chainId), primary type, and a truncated JSON preview of the message in the approval overlay — every classic phishing signal (mismatched domain, unexpected primary type) is in front of the user before they hit Sign. Accepts either an already-parsed typedData object or the JSON-string form older MetaMask specs used. - wallet-inject.js router: eth_signTypedData_v4 (and _v3 for the same payload shape) route to eth.signTypedData. v1's flat "type[]" form is unwired — dapps that still use v1 should upgrade. - Solana signAndSend: bridge now passes the FULL wire (from tx.serialize({requireAllSignatures:false, verifySignatures:false})) instead of just the message. The addon parses compact-u16 signature count, finds this wallet's pubkey in the message's account-key list, signs the message, and patches ONLY its own slot in the signature array — any partial signatures the dapp had already filled with tx.partialSign() (session keys, escrow co-signers, permissioned authorities) are preserved. Multi-signer flows work now; single-signer is the degenerate case of sigCount=1. - Approval overlay for sol.signAndSend now shows required-signer count and the wallet's slot index so multi-signer requests are visibly distinct from a plain single-signer send. --- bundled-addons/bchwallet/index.js | 123 ++++++++++++++++--- bundled-addons/bchwallet/lib/chain-eth.js | 8 ++ bundled-addons/bchwallet/lib/eip712.js | 143 ++++++++++++++++++++++ bundled-addons/bchwallet/wallet-inject.js | 23 +++- 4 files changed, 277 insertions(+), 20 deletions(-) create mode 100644 bundled-addons/bchwallet/lib/eip712.js diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index 6c19e43..89da745 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -46,6 +46,7 @@ async function loadDeps(api) { }); const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b }); const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 }); + const eip712 = require("./lib/eip712.js")({ keccak_256 }); // Solana uses the raw base58 alphabet (no checksum), which lives inside // base58check as encodeBase58 / decodeBase58 — expose them under a // `{encode, decode}` shape the SOL adapter reads from. @@ -74,7 +75,7 @@ async function loadDeps(api) { return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, - dgbCore, dgbPsbt, bitcoinjs, ecc }; + dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 }; } // ---- servers --------------------------------------------------------------- @@ -1196,6 +1197,44 @@ function registerPageMessages(api) { return { txid: r.txid }; }); }); + api.onMessage("eth.signTypedData", async (p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); + const rt = activeEthRuntime(); + // Dapps send typedData as either a JSON string (older MetaMask spec) or + // an object (v4). Accept both; the encoder wants an object. + let td = p && p.typedData; + if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } } + if (!td || typeof td !== "object") throw new Error("typedData required"); + // Compute the digest first — if the encoder rejects the input the user + // never sees an approval overlay for a broken payload. + let digest; + try { digest = ctx.d.eip712.digest(td); } + catch (e) { throw new Error("EIP-712 encode failed: " + e.message); } + // Approval overlay: show domain (name + chain), primary type, and a + // truncated JSON preview of the message so the user has a fighting + // chance to spot phishing. + const dom = td.domain || {}; + const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; + const messagePreview = JSON.stringify(td.message, null, 2); + const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Sign typed data (EIP-712)?", + origin, + body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", + rows: [ + { label: "Domain", value: domainSummary }, + { label: "Primary type", value: String(td.primaryType || "") }, + { label: "Message", value: preview, 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.signTypedDataDigest(digest); + }); + }); api.onMessage("eth.switchChain", async (p, m) => { fromPage(m); const want = String(p && p.chainId || "").toLowerCase(); @@ -1278,41 +1317,93 @@ function registerPageMessages(api) { return rt.adapter.signMessage(bytes); }); }); - // Dapp-built transaction: page hands us the wallet-adapter Transaction's - // .serializeMessage() output (base64). We sign it + broadcast via the - // adapter's own RPC. The txid returned by broadcast is the wire's txid. + // Dapp-built transaction. The main-world bridge passes the FULL wire + // (tx.serialize({requireAllSignatures:false, verifySignatures:false})) + // — signature slots the dapp already filled with partialSign() are + // preserved; our wallet only overwrites its own slot. That's the only + // way to sign multi-signer transactions the dapp has partially + // co-signed (co-signer sigs, ephemeral session keys, etc.). api.onMessage("sol.signAndSend", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); const rt = activeSolRuntime(); - const b64 = String(p && p.messageB64 || ""); - const messageBytes = new Uint8Array(Buffer.from(b64, "base64")); + 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; } + } + 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 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}` }, ], actions: [{ id: "send", label: "Sign & send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); - // Sign the message with the wallet's ed25519 key (Solana signs the - // raw message bytes, no prefix). - const sigInfo = rt.adapter.signMessage(messageBytes); // {signature: base58} + // 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); const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature); if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length"); - // Wire = compact-u16(1) || 64-byte sig || message. Single-signer only - // in this rev: multi-signer flows would need the dapp's other sigs. - const wire = new Uint8Array(1 + 64 + messageBytes.length); - wire[0] = 0x01; - wire.set(sigBytes, 1); - wire.set(messageBytes, 65); - const wireB58 = ctx.d.base58check.encodeBase58(wire); + const wireOut = new Uint8Array(wire); // copy so we don't mutate caller + wireOut.set(sigBytes, sigsStart + ourIndex * 64); + const wireB58 = ctx.d.base58check.encodeBase58(wireOut); const txid = await rt.adapter._client.call("sendTransaction", [wireB58]); if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000); diff --git a/bundled-addons/bchwallet/lib/chain-eth.js b/bundled-addons/bchwallet/lib/chain-eth.js index 695fbcf..6462624 100644 --- a/bundled-addons/bchwallet/lib/chain-eth.js +++ b/bundled-addons/bchwallet/lib/chain-eth.js @@ -312,6 +312,14 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { return { txid }; } + // EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid). + signTypedDataDigest(digest32) { + const sig = secp256k1.sign(digest32, this._priv, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; + return { address: this.address, signature: "0x" + toHex(out) }; + } // Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg). signMessage(message) { const msg = String(message); diff --git a/bundled-addons/bchwallet/lib/eip712.js b/bundled-addons/bchwallet/lib/eip712.js new file mode 100644 index 0000000..a926679 --- /dev/null +++ b/bundled-addons/bchwallet/lib/eip712.js @@ -0,0 +1,143 @@ +// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces +// the 32-byte digest that eth_signTypedData_v4 signs with the wallet's +// secp256k1 key. +// +// Reference: https://eips.ethereum.org/EIPS/eip-712 +// Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message)) +// - domainSeparator = hashStruct("EIP712Domain", typedData.domain) +// - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data)) +// - typeHash(type) = keccak256(encodeType(type)) +// - encodeType is the canonical string form; sub-types are appended in +// alphabetical order once, without recursion into themselves twice. +// +// This is enough for every mainstream EIP-712 payload — Permit / EIP-2612, +// OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not +// implemented: fixed-size arrays of atomic types wider than a byte (rare +// enough that no shipping dapp we care about uses them). + +module.exports = function makeEip712({ keccak_256 }) { + const enc = new TextEncoder(); + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const hex2bytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + const bytesToBig = (b) => { + let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; + }; + const bigToBe32 = (v, signed) => { + let n = BigInt(v); + if (n < 0n) { + if (!signed) throw new Error("negative value for unsigned type"); + // two's complement to 256 bits + n = (1n << 256n) + n; + } + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } + return out; + }; + + // encodeType walker — resolves the primary type + every struct it + // transitively references, then emits "Primary(...)Sub1(...)Sub2(...)" + // with sub-types in alphabetical order per the spec. + function findDependencies(primaryType, types, found = new Set()) { + if (found.has(primaryType) || !types[primaryType]) return found; + found.add(primaryType); + for (const f of types[primaryType]) { + const base = f.type.replace(/\[.*\]$/, ""); + if (types[base]) findDependencies(base, types, found); + } + return found; + } + function encodeType(primaryType, types) { + const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort(); + const all = [primaryType, ...deps]; + return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join(""); + } + function typeHash(primaryType, types) { + return keccak_256(enc.encode(encodeType(primaryType, types))); + } + + // Encode one field value per its declared type. Struct + array types + // hash themselves to 32 bytes; atomics land in a 32-byte slot each. + function encodeValue(type, value, types) { + // Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode + // as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712. + const arr = /^(.+)\[(\d*)\]$/.exec(type); + if (arr) { + const baseType = arr[1]; + const items = Array.isArray(value) ? value : []; + const encoded = items.map((v) => encodeValue(baseType, v, types)); + return keccak_256(concat(...encoded)); + } + // Struct types: hashStruct recursion. + if (types[type]) return hashStruct(type, value, types); + // Atomic types. + if (type === "string") return keccak_256(enc.encode(String(value ?? ""))); + if (type === "bytes") { + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + return keccak_256(b); + } + if (type === "address") { + const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); + if (h.length !== 20) throw new Error("address must be 20 bytes"); + const out = new Uint8Array(32); + out.set(h, 12); + return out; + } + if (type === "bool") { + const out = new Uint8Array(32); + out[31] = value ? 1 : 0; + return out; + } + // bytesN (fixed): left-aligned in a 32-byte word. + const bytesN = /^bytes(\d+)$/.exec(type); + if (bytesN) { + const n = Number(bytesN[1]); + if (n < 1 || n > 32) throw new Error("bytesN out of range"); + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`); + const out = new Uint8Array(32); + out.set(b, 0); + return out; + } + // uint* / int*: encode as 32-byte big-endian. + const uintM = /^uint(\d*)$/.exec(type); + if (uintM) return bigToBe32(value, false); + const intM = /^int(\d*)$/.exec(type); + if (intM) return bigToBe32(value, true); + throw new Error("unsupported EIP-712 type: " + type); + } + + function encodeData(primaryType, data, types) { + const fields = types[primaryType]; + if (!fields) throw new Error("unknown type: " + primaryType); + const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types)); + return concat(...encoded); + } + function hashStruct(primaryType, data, types) { + return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types))); + } + + // Full EIP-712 digest, ready for secp256k1.sign(digest, key). + function digest(typedData) { + const td = typedData && typeof typedData === "object" ? typedData : {}; + const types = td.types || {}; + if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing"); + const primary = String(td.primaryType || ""); + if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`); + const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types); + const messageHash = hashStruct(primary, td.message || {}, types); + return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash)); + } + + return { digest, encodeType, typeHash, hashStruct }; +}; diff --git a/bundled-addons/bchwallet/wallet-inject.js b/bundled-addons/bchwallet/wallet-inject.js index ad1bed7..aab3efa 100644 --- a/bundled-addons/bchwallet/wallet-inject.js +++ b/bundled-addons/bchwallet/wallet-inject.js @@ -245,6 +245,17 @@ const mainWorldSource = `(function () { const tx = params[0] || {}; return (await invoke("eth.sendTransaction", { tx })).txid; } + case "eth_signTypedData_v4": + case "eth_signTypedData": + case "eth_signTypedData_v3": { + // v3/v4 differ mostly in nested-struct support; the encoder handles + // both. v1 is the flat "type[]" schema that Metamask deprecated — + // reject it, dapps that still use v1 should upgrade. + const [a, b] = params; + const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s); + const typedData = looksLikeAddr(a) ? b : a; + return (await invoke("eth.signTypedData", { typedData })).signature; + } case "wallet_switchEthereumChain": { const target = String((params[0] && params[0].chainId) || "").toLowerCase(); return invoke("eth.switchChain", { chainId: target }); @@ -373,11 +384,15 @@ const mainWorldSource = `(function () { // addSignature(publicKey, sig) — we live in the main world, so we can // call both. Wallets that live in an isolated content-script can't. async function solSignAndSendTransaction(tx, opts) { - if (!tx || typeof tx.serializeMessage !== "function") { - throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serializeMessage / .addSignature)"); + if (!tx || typeof tx.serialize !== "function") { + throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serialize)"); } - const messageBytes = tx.serializeMessage(); - const r = await invoke("sol.signAndSend", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) }); + // Serialize the FULL wire including any partial signatures the dapp + // has already collected (multi-signer flows: session keys, escrows, + // ephemeral co-signers). Aegis fills the wallet's own signature slot + // in the addon and leaves the other slots untouched. + const wire = tx.serialize({ requireAllSignatures: false, verifySignatures: false }); + const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(new Uint8Array(wire)) }); return { signature: r.txid, publicKey: solState.publicKey }; } async function solSignTransaction(tx) {