Files
AutistMask/src/shared/approvalVerify.js
clawbot ad9162d057
All checks were successful
check / check (push) Successful in 24s
security: decrypt and sign dApp approvals in the popup (closes #157)
The password no longer crosses the extension messaging boundary: the popup
decrypts and signs, and sends only the raw signed transaction or the signature.
The background re-derives the signer from the artifact and checks it against
the approval it holds before broadcasting, so it is not a blind relay.
2026-08-10 15:59:30 +02:00

125 lines
4.5 KiB
JavaScript

// Verification of the signed artifacts produced by the approval popup.
//
// Signing happens in the popup, where the password is entered; the background
// only broadcasts the raw transaction and resolves the pending approval back
// to the requesting page. So that moving the signing out of the background
// does not turn the background into a blind relay, the background re-derives
// the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers.
//
// Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp.
const {
Transaction,
getAddress,
getBytes,
verifyMessage,
verifyTypedData,
} = require("ethers");
// Case-insensitive address comparison that tolerates absent values on either
// side. Two absent addresses compare equal (contract creation has no `to`).
function sameAddress(a, b) {
const aMissing = a === null || a === undefined || a === "";
const bMissing = b === null || b === undefined || b === "";
if (aMissing || bMissing) return aMissing && bMissing;
try {
return getAddress(a) === getAddress(b);
} catch {
return String(a).toLowerCase() === String(b).toLowerCase();
}
}
// Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers.
function normalizeValue(v) {
if (v === null || v === undefined || v === "") return 0n;
return BigInt(v);
}
// Normalize call data to a lowercase hex string. Absent data is "0x".
function normalizeData(v) {
if (v === null || v === undefined || v === "" || v === "0x") return "0x";
return String(v).toLowerCase();
}
// Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for. Returns the parsed
// ethers Transaction on success, throws otherwise.
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw new Error("The signed transaction is missing or malformed.");
}
let parsed;
try {
parsed = Transaction.from(rawSignedTx);
} catch {
throw new Error("The signed transaction could not be decoded.");
}
if (!parsed.from) {
throw new Error("The signed transaction carries no valid signature.");
}
if (!sameAddress(parsed.from, expectedFrom)) {
throw new Error(
"The signed transaction was signed by a different address than the one that was approved.",
);
}
if (!sameAddress(parsed.to, txParams.to)) {
throw new Error(
"The signed transaction does not go to the approved recipient.",
);
}
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw new Error(
"The signed transaction does not carry the approved value.",
);
}
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw new Error(
"The signed transaction does not carry the approved call data.",
);
}
return parsed;
}
// Assert that a signature over the approved message or typed data was
// produced by the address the approval was raised for. Returns the recovered
// address on success, throws otherwise.
function verifySignature(signParams, signature, expectedFrom) {
if (typeof signature !== "string" || !signature.startsWith("0x")) {
throw new Error("The signature is missing or malformed.");
}
let recovered;
try {
if (
signParams.method === "personal_sign" ||
signParams.method === "eth_sign"
) {
recovered = verifyMessage(getBytes(signParams.message), signature);
} else {
const typedData = JSON.parse(signParams.typedData);
const { domain, types, message } = typedData;
// ethers derives EIP712Domain itself and rejects it as an input.
delete types.EIP712Domain;
recovered = verifyTypedData(domain, types, message, signature);
}
} catch {
throw new Error("The signature could not be verified.");
}
if (!sameAddress(recovered, expectedFrom)) {
throw new Error(
"The signature was produced by a different address than the one that was approved.",
);
}
return recovered;
}
module.exports = { verifySignedTx, verifySignature, sameAddress };