security: decrypt and sign dApp approvals in the popup (closes #157)
All checks were successful
check / check (push) Successful in 24s
All checks were successful
check / check (push) Successful in 24s
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.
This commit was merged in pull request #171.
This commit is contained in:
355
tests/approvalVerify.test.js
Normal file
355
tests/approvalVerify.test.js
Normal file
@@ -0,0 +1,355 @@
|
||||
const { Network, Transaction, Wallet } = require("ethers");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
sameAddress,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||
|
||||
// Fixed test keys — never used for anything but these tests.
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const OTHER_KEY =
|
||||
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
|
||||
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
const other = new Wallet(OTHER_KEY);
|
||||
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0xdeadbeef",
|
||||
gas: "0x5208",
|
||||
};
|
||||
|
||||
// Build a signable transaction from approved params. The popup does the same
|
||||
// thing through populateTransaction(); here the fields are fixed so the test
|
||||
// needs no provider.
|
||||
function txFor(params) {
|
||||
return {
|
||||
chainId: 1,
|
||||
nonce: 7,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
type: 2,
|
||||
to: params.to,
|
||||
value: params.value === undefined ? 0n : BigInt(params.value),
|
||||
data: params.data || "0x",
|
||||
};
|
||||
}
|
||||
|
||||
async function signedFor(params, withWallet) {
|
||||
return (withWallet || signer).signTransaction(txFor(params));
|
||||
}
|
||||
|
||||
describe("sameAddress", () => {
|
||||
test("compares checksummed and lowercase forms as equal", () => {
|
||||
expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true);
|
||||
});
|
||||
|
||||
test("treats two absent addresses as equal (contract creation)", () => {
|
||||
expect(sameAddress(null, undefined)).toBe(true);
|
||||
expect(sameAddress("", null)).toBe(true);
|
||||
});
|
||||
|
||||
test("treats one absent address as unequal", () => {
|
||||
expect(sameAddress(RECIPIENT, null)).toBe(false);
|
||||
expect(sameAddress(null, RECIPIENT)).toBe(false);
|
||||
});
|
||||
|
||||
test("does not throw on values that are not addresses", () => {
|
||||
expect(sameAddress("not-an-address", RECIPIENT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifySignedTx", () => {
|
||||
test("accepts the approved transaction signed by the approved address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
||||
});
|
||||
|
||||
test("accepts a contract creation with no recipient", async () => {
|
||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an absent value as zero", async () => {
|
||||
const approved = { to: RECIPIENT, data: "0x" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts call data whose case differs from the approval", async () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a swapped recipient", async () => {
|
||||
const raw = await signedFor({
|
||||
...TX_PARAMS,
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved recipient/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an inflated value", async () => {
|
||||
const raw = await signedFor({
|
||||
...TX_PARAMS,
|
||||
value: "0x4563918244f40000",
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved value/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects substituted call data", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved call data/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a transaction signed by a different address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS, other);
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/different address/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an unsigned transaction", () => {
|
||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
||||
expect(() =>
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address),
|
||||
).toThrow(/no valid signature/);
|
||||
});
|
||||
|
||||
test("rejects a missing or malformed payload", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
|
||||
/missing or malformed/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
|
||||
).toThrow(/could not be decoded/);
|
||||
});
|
||||
|
||||
test("every rejection message is a full sentence", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const TYPED_DATA = JSON.stringify({
|
||||
domain: {
|
||||
name: "AutistMask Test",
|
||||
version: "1",
|
||||
chainId: 1,
|
||||
verifyingContract: OTHER_RECIPIENT,
|
||||
},
|
||||
primaryType: "Mail",
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
{ name: "verifyingContract", type: "address" },
|
||||
],
|
||||
Mail: [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "contents", type: "string" },
|
||||
],
|
||||
},
|
||||
message: {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
contents: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
describe("verifySignature", () => {
|
||||
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
||||
const personalParams = {
|
||||
method: "personal_sign",
|
||||
message: MESSAGE,
|
||||
from: signer.address,
|
||||
};
|
||||
const typedParams = {
|
||||
method: "eth_signTypedData_v4",
|
||||
typedData: TYPED_DATA,
|
||||
from: signer.address,
|
||||
};
|
||||
|
||||
async function signPersonal(withWallet) {
|
||||
return (withWallet || signer).signMessage(
|
||||
Buffer.from(MESSAGE.slice(2), "hex"),
|
||||
);
|
||||
}
|
||||
|
||||
async function signTyped(withWallet) {
|
||||
const { domain, types, message } = JSON.parse(TYPED_DATA);
|
||||
delete types.EIP712Domain;
|
||||
return (withWallet || signer).signTypedData(domain, types, message);
|
||||
}
|
||||
|
||||
test("accepts a personal_sign signature from the approved address", async () => {
|
||||
const signature = await signPersonal();
|
||||
expect(verifySignature(personalParams, signature, signer.address)).toBe(
|
||||
signer.address,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts an eth_sign signature the same way", async () => {
|
||||
const signature = await signPersonal();
|
||||
const params = { ...personalParams, method: "eth_sign" };
|
||||
expect(() =>
|
||||
verifySignature(params, signature, signer.address),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a typed data signature from the approved address", async () => {
|
||||
const signature = await signTyped();
|
||||
expect(verifySignature(typedParams, signature, signer.address)).toBe(
|
||||
signer.address,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not mutate the approved typed data while verifying", async () => {
|
||||
const signature = await signTyped();
|
||||
const before = typedParams.typedData;
|
||||
verifySignature(typedParams, signature, signer.address);
|
||||
expect(typedParams.typedData).toBe(before);
|
||||
expect(
|
||||
JSON.parse(typedParams.typedData).types.EIP712Domain,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("rejects a personal_sign signature from a different address", async () => {
|
||||
const signature = await signPersonal(other);
|
||||
expect(() =>
|
||||
verifySignature(personalParams, signature, signer.address),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
test("rejects a typed data signature from a different address", async () => {
|
||||
const signature = await signTyped(other);
|
||||
expect(() =>
|
||||
verifySignature(typedParams, signature, signer.address),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
test("rejects a signature over a different message", async () => {
|
||||
const signature = await signer.signMessage(
|
||||
Buffer.from("00112233", "hex"),
|
||||
);
|
||||
expect(() =>
|
||||
verifySignature(personalParams, signature, signer.address),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
test("rejects a missing or malformed signature", async () => {
|
||||
expect(() =>
|
||||
verifySignature(personalParams, undefined, signer.address),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() =>
|
||||
verifySignature(personalParams, "0x1234", signer.address),
|
||||
).toThrow(/could not be verified/);
|
||||
});
|
||||
});
|
||||
|
||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
||||
// the background runs before it broadcasts or resolves. Only what the popup
|
||||
// puts on the wire is passed along, so this also pins down that the wire
|
||||
// payload is sufficient on its own.
|
||||
describe("popup signing sequence to background verification", () => {
|
||||
// Stand-in for the JSON-RPC provider. populateTransaction only needs the
|
||||
// nonce, the gas estimate, the network and the fee data.
|
||||
const fakeProvider = {
|
||||
getNetwork: async () => Network.from(1),
|
||||
getTransactionCount: async () => 7,
|
||||
estimateGas: async () => 21000n,
|
||||
getFeeData: async () => ({
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
};
|
||||
|
||||
// A private-key wallet as it is persisted in state, so the test goes
|
||||
// through getSignerForAddress() the way the popup does.
|
||||
const walletData = { type: "privkey" };
|
||||
|
||||
async function popupSignsTx(txParams) {
|
||||
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
||||
const connected = localSigner.connect(fakeProvider);
|
||||
const populated = await connected.populateTransaction(txParams);
|
||||
delete populated.from;
|
||||
return connected.signTransaction(populated);
|
||||
}
|
||||
|
||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
|
||||
expect(parsed.nonce).toBe(7);
|
||||
expect(parsed.chainId).toBe(1n);
|
||||
expect(parsed.gasLimit).toBe(21000n);
|
||||
expect(parsed.to).toBe(RECIPIENT);
|
||||
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
||||
expect(parsed.data).toBe(TX_PARAMS.data);
|
||||
expect(parsed.signature).not.toBeNull();
|
||||
});
|
||||
|
||||
test("the wire payload carries no password and no secret", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const payload = {
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: "test-approval-id",
|
||||
approved: true,
|
||||
rawSignedTx,
|
||||
};
|
||||
expect(Object.keys(payload).sort()).toEqual([
|
||||
"approved",
|
||||
"id",
|
||||
"rawSignedTx",
|
||||
"type",
|
||||
]);
|
||||
const wire = JSON.stringify(payload).toLowerCase();
|
||||
expect(wire).not.toContain("password");
|
||||
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
|
||||
});
|
||||
|
||||
test("the background rejects a transaction the popup did not approve", async () => {
|
||||
const rawSignedTx = await popupSignsTx({
|
||||
...TX_PARAMS,
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user