[P1-H01] Require additional data in vote commit hash to prevent duplication of votes#1217
[P1-H01] Require additional data in vote commit hash to prevent duplication of votes#1217nicholaspai merged 22 commits intomasterfrom
Conversation
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
… governor-payable-proposals
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
… governor-payable-proposals
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
| // Committed hash doesn't match revealed price and salt | ||
| require(keccak256(abi.encode(price, salt)) == voteSubmission.commit, "Invalid commit hash & salt"); | ||
| // Committed hash doesn't match revealed voter address, price and salt | ||
| require(keccak256(abi.encodePacked(price, salt, msg.sender)) == voteSubmission.commit, "Invalid commit hash, address & salt"); |
There was a problem hiding this comment.
abi.encode(...., address) does not return the same hash that web3.utils.soliditySha3(....,address) does, but abi.encodePacked does. I'm not 100% sure why this works, but I believe that if an address is input into abi.encode(), its format needs to be manipulated a bit, including getting rid of the leading 0x...
There was a problem hiding this comment.
My understanding is that encode and encodePacked differ for types that are less than 32 bytes or dynamically sized. Since an address is less than 32 bytes, I'm guessing it's creating the problem. See here for the solidity docs around this. In the docs for soliditySha3, they say that they "tightly pack" the arguments before hashing. So I think this is actually more correct than it was before.
Now that we're tossing more types in here, we may need to be more careful about how we compute soliditySha3 offchain. web3 attempts to autodetect types, but it's not perfect. Since solidity knows the types, if web3 autodetects incorrectly, it could lead to hash differences due to differences in encoding. To solve this problem, web3 offers a way to provide the types of the arguments passed to soliditySha3 (see examples here). We don't need to do this in this PR, but it's something we may consider doing in the future to make sure these hashes are always computed in exactly the same way.
There was a problem hiding this comment.
Right. I'll raise an issue for this so we can do in a subsequent PR.
There was a problem hiding this comment.
Just to add one more note on this.
I want to explain my concerns around js autodecting types. If you look at the docs, it autodetects bytes, but not addresses. My guess is that addresses just get encoded the same way as bytes, which probably works fine most of the time. However, ~1 out of every 128 addresses has a 0 for the last byte (or first byte), assuming they're evenly distributed. If the string representation of that address drops the trailing 0, then it might be interpreted as a bytes19 by js. However, solidity would still encode that 0 because it knows an address is 20 bytes. This would lead to a different hash in solidity and js. This could similarly cause problems when hashing the bytes32 representation of our identifiers since there will be padded 0s on the end, which may not show up in the string or may be thrown out by web3. I haven't tested any of this, but these sorts of errors are plausible to the point where I think it might make sense to specify the types on the client side to avoid corrupted hashes.
There was a problem hiding this comment.
Just to add one more note on this.
I want to explain my concerns around js autodecting types. If you look at the docs, it autodetects bytes, but not addresses. My guess is that addresses just get encoded the same way as bytes, which probably works fine most of the time. However, ~1 out of every 128 addresses has a 0 for the last byte (or first byte), assuming they're evenly distributed. If the string representation of that address drops the trailing 0, then it might be interpreted as a
bytes19by js. However, solidity would still encode that 0 because it knows an address is 20 bytes. This would lead to a different hash in solidity and js. This could similarly cause problems when hashing thebytes32representation of our identifiers since there will be padded 0s on the end, which may not show up in the string or may be thrown out by web3. I haven't tested any of this, but these sorts of errors are plausible to the point where I think it might make sense to specify the types on the client side to avoid corrupted hashes.
Agreed with this. Will add comments to issue.
There was a problem hiding this comment.
what about type casing the input concatenation of price + salt + voter to a string and then it will always be the same, irrespective of type or how js interprets the leading (or trailing) 0 in the address?
There was a problem hiding this comment.
@chrismaree that would work but it would require changing the contract interface. Its much easier IMO to just make the client code specify the types to hash:
old code:
const newHash = web3.utils.soliditySha3(
newPrice,
newSalt,
account1,
time,
currentRoundId,
identifier
);
new code:
const newHash = web3.utils.soliditySha3(
{ t: "int", v: newPrice },
{ t: "int", v: newSalt },
{ t: "address", v: account1 },
{ t: "uint", v: time },
{ t: "uint", v: currentRoundId },
{ t: "bytes32", v: identifier }
);
There was a problem hiding this comment.
@chrismaree while unlikely, one minor issue with the concatenation approach is that it technically can create hash collisions between different inputs:
price1 = 15
salt1 = 100
price1 + salt1 = 15100
price2 = 1
salt2 = 5100
price2 + salt2 = 15100
I think fixed length inputs are always safest for this reason. We should have the inputs to the hash always include all their bits, even if there are leading or trailing zeros.
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
I know this might make this a huge PR, but can we add them all in one PR? Just so it's easier for OZ to review? |
mrice32
left a comment
There was a problem hiding this comment.
Looks awesome! Thanks for tackling this. Like I mentioned in my above comment, I think we should probably go ahead and implement all the suggested additions to the hash in this PR just so it's easier to review the fix.
| // Committed hash doesn't match revealed price and salt | ||
| require(keccak256(abi.encode(price, salt)) == voteSubmission.commit, "Invalid commit hash & salt"); | ||
| // Committed hash doesn't match revealed voter address, price and salt | ||
| require(keccak256(abi.encodePacked(price, salt, msg.sender)) == voteSubmission.commit, "Invalid commit hash, address & salt"); |
There was a problem hiding this comment.
My understanding is that encode and encodePacked differ for types that are less than 32 bytes or dynamically sized. Since an address is less than 32 bytes, I'm guessing it's creating the problem. See here for the solidity docs around this. In the docs for soliditySha3, they say that they "tightly pack" the arguments before hashing. So I think this is actually more correct than it was before.
Now that we're tossing more types in here, we may need to be more careful about how we compute soliditySha3 offchain. web3 attempts to autodetect types, but it's not perfect. Since solidity knows the types, if web3 autodetects incorrectly, it could lead to hash differences due to differences in encoding. To solve this problem, web3 offers a way to provide the types of the arguments passed to soliditySha3 (see examples here). We don't need to do this in this PR, but it's something we may consider doing in the future to make sure these hashes are always computed in exactly the same way.
That's exactly why I didn't put them in this PR haha. How about I'll add them after an initial round of reviews |
Good idea. Let's do that. |
|
@ptare could you take a pass on this PR? |
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
| const newPrice = getRandomSignedInt(); | ||
| const newSalt = getRandomUnsignedInt(); | ||
| const newHash = web3.utils.soliditySha3(newPrice, newSalt); | ||
| const newHash = web3.utils.soliditySha3( |
There was a problem hiding this comment.
@mrice32 Added an example of overriding soliditySha3 type autodetection, but will hold off on making sweeping changes across repo until another PR
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
Yes this definitely breaks the voter-dapp, and I've made the neccessary changes in |
|
Right, but don't we need the old voter-dapp in order to vote on DVM upgrades? |
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
Ah yes I see the problem now. Can we create two sites? |
|
We'll have to, it looks like, since this is a high(ish) severity issue. Here's what I recommend:
|
Sounds like this and #1231 will be blocked by a PR to come that will freeze the voter-dapp site |
mrice32
left a comment
There was a problem hiding this comment.
Looks great! Two minor comments/nits.
| assert(await didContractThrow(voting.revealVote(identifier, time, newPrice, newSalt))); | ||
| }); | ||
|
|
||
| it("Multiple voters", async function() { |
There was a problem hiding this comment.
nit: why was this test deleted?
There was a problem hiding this comment.
I deleted this test because it doesn't really add anything to the previous test. The "Multiple voters" test checks that different voters can't reveal each other's votes, but this is implied from the previous test which checks that the hashed account is correct.
There was a problem hiding this comment.
There's one other check in that test: that a voter who hasn't committed can't reveal. Are we testing that anywhere else?
There was a problem hiding this comment.
Isn't that the same thing as the test above? If a voter has not committed, then their account component of the hash must fail (revealVote checks msg.sender).
| /** | ||
| * @notice Reveal a previously committed vote for `identifier` at `time`. | ||
| * @dev The revealed `price` and `salt` must match the latest `hash` that `commitVote()` was called with. | ||
| * @dev The revealed `address`, `price` and `salt` must match the latest `hash` that `commitVote()` was called with. |
There was a problem hiding this comment.
Should this comment also include roundId, identifier, time, etc? Or are those implied?
Signed-off-by: Nick Pai <npai.nyc@gmail.com>
chrismaree
left a comment
There was a problem hiding this comment.
This looks excellent to me, great work!
| assert(await didContractThrow(voting.revealVote(identifier, time, price, newSalt))); | ||
|
|
||
| // Can't reveal with the incorrect address. | ||
| assert(await didContractThrow(voting.revealVote(identifier, time, newPrice, newSalt, { from: account2 }))); |
There was a problem hiding this comment.
This is great. Thanks for increaseing the code coverage here.
| for (var i = 0; i < requestNum; i++) { | ||
| const salt = getRandomUnsignedInt(); | ||
| const hash = web3.utils.soliditySha3(price, salt); | ||
| const hash = computeVoteHash({ |
Co-Authored-By: Chris Maree <christopher.maree@gmail.com>
Co-Authored-By: Chris Maree <christopher.maree@gmail.com>
Co-Authored-By: Chris Maree <christopher.maree@gmail.com>
Resolves #1216
Resolves #1219
Caller now must include an address, price request timestamp, round ID, and pricefeed identifier with their vote hash. The hash is enforced in
revealVote.This includes fixes for the voting-cli and the voter-dapp as well.