-
-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix unmarshal
bugs
#409
Fix unmarshal
bugs
#409
Conversation
Because `unmarshal` is marked as `discardable` we silently ignored the return value of `unmarshal`, which in some test cases indicated that the source value does not fit into target bigint
If a given source `src` is given to `unmarshal` where the most significant bytes are all zero beyond the size that fits into the destination BigInt, we still return a successful parse. Previously we would just return false whenever we found more bytes than can possibly fit (this leads to issues with e.g. EVM precompiles tests, which come as strings that are longer). Note: We can probably do the all zero check in a better way (i.e. using the fact that endianness does not matter and just compare all relevant bytes, but not sure about the most elegant way to do that, taking the dynamic sizes into account)
Great finds, will have a look over the weekend.
Yes, it's fine if the destination is a fixed sized array but for |
# significant two bytes are all zero. | ||
var allZero = true | ||
for jidx in src_idx ..< dst.len: | ||
if src[jidx] != 0: allZero = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll have to review more in-depth that case but we can't leak the position of non-zero bytes as this breaks constant-time property.
And even leaking if something is zero is tricky.
# significant two bytes are all zero. | ||
var allZero = true | ||
for jidx in countdown(src_idx, 0): | ||
if src[jidx] != 0: allZero = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same remark, analyzing the data itself instead of metadata (length) breaks constant-time.
After careful consideration:
This should be fixed, and
The serialization there cannot shorten or extend processing based on the data read to be constant-time (https://github.com/mratsim/constantine/wiki/Constant-time-arithmetics). Only the length can be leaked. constantine/tests/t_ethereum_eip4844_deneb_kzg.nim Lines 118 to 123 in ee818f0
What's the test in question? |
Ok, I understand the implication for non constant time behavior / calculation time depending on user input with my change in
The What is the intended way to handle these test cases, given that both the "supposed to pass" and "supposed to fail" tests use the same input format? In particular the Now, I don't fully grasp yet to what extent other libraries even attempt to have constant time guarantees, so if not I guess they just don't have to wonder about what to do.
Essentially, it's all the EVM precompiles tests in the following file: https://github.com/mratsim/constantine/blob/master/tests/t_ethereum_evm_precompiles.nim#L49-L54 Note how the I would probably adjust that test to also pass a static size for each test case (for each of the precompiles functions), which provides the static expected output size for the data. Similar to how we will handle it in Go once the updated API PR is done. |
Good point. For context, in general all parsers/codecs/serialization are protocol-specific unless the creator of the curve specifies a serialization. So in general none of the libraries agree on how to serialize elliptic curve points, a typical example is BN254 which is serialized in little-endian in some libraries.
So Constantine implements custom parsers according to protocol specs. |
Got it, so I'll write a custom parser following the details in: https://eips.ethereum.org/EIPS/eip-2537#fine-points-and-encoding-of-base-elements for BLS12-381. |
For BLS12-381 we now parse according to the spec https://eips.ethereum.org/EIPS/eip-2537#fine-points-and-encoding-of-base-elements making sure to check the 'upper' 16 bytes to be empty. If they are not we return IntLargerThanModulus.
I've now added custom parsing logic for BLS12-381 in the EVM precompiles file (and reverted the additional zero checkes in Also, the EVM precompiles tests should now all correctly pass or fail based on the test case. We pass the result size explicitly in the test cases now. |
# Now check that all lower bytes are empty | ||
var allZero = success # if `success` already false we still continue | ||
for i in 0 ..< 15: # order irrelevant | ||
allZero = allZero and (src[i] == 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in discussion, even if we don't require constant-time here, it is fine not to return early on a non-zero.
The difference is likely a couple nanoseconds. It's also an exceptional path.
Furthermore with an exact 16 bytes check, the compiler can transform this into a vector load + vector zero check and it may very well be faster than having an if branch in a hot path.
I encountered 3 issues working on the updated Go / Rust APIs. The (now more) static nature of the Go API (using fixed size arrays) showed me that (at least) one case succeeds that is supposed to fail. That test case is:
https://github.com/mratsim/constantine/blob/master/tests/protocol_ethereum_evm_precompiles/eip-2537/fail-add_G1_bls.json#L27-L31
The test is supposed to fail, because the X coordinate of the first point given in the input is way larger than the prime field size (see the string starting with a
10
byte). Investigating led me to realize that the BigInt parsing logic ignored thefalse
return value of theunmarshal
. The actualunmarshal
call failed with the intended error that the size is too large for the target BigInt. But because we ignored that return value, the not-fully-parsed BigInt ended up being a valid point on the curve.After fixing this issue, I noticed that the
io_limbs.nim
unmarshalling logic also had an issue, in the sense that it returnsfalse
even if the remaining data in the input source string is entirely zero. But of course that must be allowed (and again, this was also hidden by the bug above). I've put in a -- not that great -- manual check for all bytes still left in the input string. If they are all zero, we simply return true. We might want to do this in a different way, but at the moment I'm not sure what is appropriate.The other issue (to be fixed later) is that our Nim EVM precompiles tests always use the
Expected
field's length as the result buffer length. That causes the functions to prematurely fail, which hid the actual bug described above. This last bug means that we got the "correct" test result by accident, because the resulting buffer size was wrong, failing the wrong test; just for the wrong reasons.Edit: Note on
{.discardable.}
We might want to reconsider whether we want to use
{.discardable.}
in the library. Maybe too dangerous to accidentally ignore such parsing failures etc.?