Repository navigation
Description
Inspired by https://users.rust-lang.org/t/hash-prefix-collisions/71823/10?u=scottmcm
Hash::hash_slice has a bunch of text clarifying that h.hash_slice(&[a, b]); h.hash_slice(&[c]); is not guaranteed to be the same as h.hash_slice(&[a]); h.hash_slice(&[b, c]);.
However, Hasher::write is unclear whether that same rule applies to it. It's very clear that .write(&[a]) is not the same as .write_u8(a), but not whether the same sequence of bytes to write is supposed to be the same thing, even if they're in different groupings, like h.write(&[a, b]); h.write(&[c]); vs h.write(&[a]); h.write(&[b, c]);.
This is important for the same kind of things as the VecDeque example mentioned on hash_slice. If I have a circular byte buffer, is it legal for its Hash to just .write the two parts? Or does it need to write_u8 all the individual bytes since two circular buffers should compare equal regardless of where the split happens to be?
Given that Hash for str and Hash for [T] are doing prefix-freedom already, it feels to me like write should not be doing it again.
Also, our SipHasher implementation is going out of its way to maintain the "different chunking of writes is fine":
rust/library/core/src/hash/sip.rs
Lines 264 to 308 in 6bf3008
| fn write(&mut self, msg: &[u8]) { | |
| let length = msg.len(); | |
| self.length += length; | |
| let mut needed = 0; | |
| if self.ntail != 0 { | |
| needed = 8 - self.ntail; | |
| // SAFETY: `cmp::min(length, needed)` is guaranteed to not be over `length` | |
| self.tail |= unsafe { u8to64_le(msg, 0, cmp::min(length, needed)) } << (8 * self.ntail); | |
| if length < needed { | |
| self.ntail += length; | |
| return; | |
| } else { | |
| self.state.v3 ^= self.tail; | |
| S::c_rounds(&mut self.state); | |
| self.state.v0 ^= self.tail; | |
| self.ntail = 0; | |
| } | |
| } | |
| // Buffered tail is now flushed, process new input. | |
| let len = length - needed; | |
| let left = len & 0x7; // len % 8 | |
| let mut i = needed; | |
| while i < len - left { | |
| // SAFETY: because `len - left` is the biggest multiple of 8 under | |
| // `len`, and because `i` starts at `needed` where `len` is `length - needed`, | |
| // `i + 8` is guaranteed to be less than or equal to `length`. | |
| let mi = unsafe { load_int_le!(msg, i, u64) }; | |
| self.state.v3 ^= mi; | |
| S::c_rounds(&mut self.state); | |
| self.state.v0 ^= mi; | |
| i += 8; | |
| } | |
| // SAFETY: `i` is now `needed + len.div_euclid(8) * 8`, | |
| // so `i + left` = `needed + len` = `length`, which is by | |
| // definition equal to `msg.len()`. | |
| self.tail = unsafe { u8to64_le(msg, i, left) }; | |
| self.ntail = left; | |
| } |
So it seems to me like this has been the expected behaviour the whole time. And if not, we should optimize SipHasher to be faster.
cc #80303 which lead to this text in hash_slice.
Activity
tczajka commentedon Feb 15, 2022
While we are at it, it would be good to also clarify what prefix-freedom means in the presence of a mix of
writecalls,write_u64calls, etc when the type is alsoEq.I propose the following rules for
Hash::hash:Hashermethod calls by combining consecutivewritecalls, concatenating the byte slices. Calls to otherHashermethods are not combined.x == y, the compressed sequences of calls must be identical.x != y:Hashermethod with different arguments.write, the two (compressed) byte sequence arguments must be such that neither is a prefix of the other.There should also be analogous rules for
Hash::hash_slicebut I'm not sure what they should be, because currentlyHashdocumentation documentation states that it's not OK to hashVecDequeby using two calls tohash_slice. Is there a reason for this? I think it should be OK, i.e. theHashcontract should say that hashing two slices is equivalent to hashing a concatenated slice.Either way, I think for
Hash::hash_slicea decision should be made one way or the other. Either:hash_slice(&[a]); hash_slice(&[b]);as equivalent tohash_slice(&[a, b]), in which case the documentation ofHashshould state that as a requirement. Or:hash_sliceshould change, because it currently causes a collision on such calls.Amanieu commentedon Feb 16, 2022
I strongly disagree with the notion that
h.write(&[a, b]); h.write(&[c]);andh.write(&[a]); h.write(&[b, c]);are required to result in the same hash value. This would prevent a lot of optimizations on hashers, in particular with the use of unaligned memory access.Consider the case of a 7-byte slice: this can be hashed by reading the first 4 bytes and last 4 bytes (with 1 byte of overlap) and hashing those two values. This is much more efficient than hashing each byte individually or buffering the bytes until they form a full word.
We should absolutely optimize SipHasher to be faster.
tczajka commentedon Feb 16, 2022
Wouldn't that mean that strings "abcdefg" and "abcddefg" always hash to the same value? If so, that would make a poor
Hasher.Mark-Simulacrum commentedon Feb 16, 2022
I think more generally, beyond the optimization for unaligned memory access, it seems easy to assume a simple hasher -- e.g., FxHash from rustc, is not going to keep a buffer around to keep 'partial' writes prior to inserting them into the hash function. If you write a partial slice, it'll still end up hashing a full usize -- so writing a series of slices rather than one large one can have a large impact.
(Effectively, this is a form of zero-padding the input buffer to fit a 8-byte block).
tczajka commentedon Feb 16, 2022
If multiple calls to
writeare not concatenated, the section about "prefix collisions" inHashdocumentation really needs rewriting, because it becomes very unclear what it means for one sequence of calls to be a prefix of another sequence of calls.impl Hash for strcurrently assumes that it is the same. It callswrite_u8(0xff)as the end marker rather thanwrite(&[0xff]). If it's not the same thing, it's a bug.RalfJung commentedon Feb 20, 2022
Why that? As long as
write_u8(0xff)always hashes the same way, theimpl Hash for strseems fine.(Incidentally, I just wondered what that
0xffis for anyway. There is no comment explaining it so the reader has to resort to guessing.)bjorn3 commentedon Feb 20, 2022
0xffcan never exist in a valid UTF-8 string. The only bit patterns used in the UTF-8 encoding of any codepoint are0xxxxxxx,10xxxxxx,110xxxxx,1110xxxxand11110xxx.11111111is not valid.RalfJung commentedon Feb 20, 2022
That doesn't explain why
write_u8is called at all. My guess is that it serves to give a byte slice andstrwith the same data different hashes, but (a) it's just a guess, and (b) that still doesn't explain why one would want those hashes to differ in the first place.tczajka commentedon Feb 20, 2022
It is required for the property that unequal values write different sequences to the
Hasher(at least for standard types).For instance, suppose that a hasher (say,
SipHasher) were to treatwrite_u8(0xff)the same way as it treatswrite(&[0x41]).Then this would cause a guaranteed collision between
("AAA", "AAA")and("AA", "AAAA")regardless of the random seed insideSipHasher, destroying its DoS protection.bjorn3 commentedon Feb 20, 2022
To ensure that hashing
abcand thedefgives a different hash from first hashingabcdand thenef.scottmcm commentedon Feb 20, 2022
Hmm, if hashers don't merge
writes that's a shame since the nice\xFFtrick forstrends up not really being any better, since if it'll do a whole block for the one byte anyway, and thus doesn't matter compared to using the length.(And the
AHashapproach of length-prehashing on everywritemakes the\xFFpointless, so I feel like that's wrong forAHashto do that regardless.)RalfJung commentedon Feb 20, 2022
That sounds like a job for the slice hash function (that
strcalls), not somethingstrshould do. And indeed that function hashes the length, so thewrite_u8is unnecessary to achieve the goal you state.No, it wouldn't, since the lengths of the strings are also hashed.
tczajka commentedon Feb 20, 2022
This is not true. The
strHashimplementation doesn't call the sliceHashimplementation, it callsHasherwriteandwrite_u8methods directly.4 remaining items
joshtriplett commentedon Mar 2, 2022
cc @Amanieu for summary of libs discussion and reply
Amanieu commentedon Mar 4, 2022
It seems that the key question here is whether the responsibility for ensuring that
([a, b], [c])and([a], [b, c])have different hashes resides in theHashimpl or theHasherimpl.Hashis responsible then it must hash a length or terminator separately. This is already the case today with theHashimpl of slices hashing the length of the slice and theHashimpl ofstrhashing an extra0xffbyte (which can't be part of the string since it is not valid UTF-8).Hasheris responsible then it must hash the length of the byte slice passed towrite. A non-secure hasher like FxHash does not need to do this since it doesn't guarantee protection against adversarial hash values. This would then allow us to remove the length hash from[T]and the terminator hash fromstr.If we were to choose the first option then nothing needs to change except some documentation.
If we were to choose the second option then all hashers that guarantee HashDoS resistance will be required to hash the length of each slice passed to
write. We would then be able to remove the length hash from[T]and the terminator hash fromstr. Note that the trick instrwith0xffcannot be used in this case: it only works withstrbecause this is not a valid UTF-8 sequence, but theHasherhas no knowledge of the constraints on the contents of the byte slice.I personally favor the second option since it would allow for faster hashing in cases where HashDoS resistance is not required (FxHash).
There might be some performance loss on SipHash when hashing strings since a full 8-byte length needs to be hashed instead of a single terminator character, but there is a way around this. We could add an unstable
write_strmethod toHasherwhich defaults to callingwrite. Hashers could then choose to use a specialized implementation which takes the UTF-8-ness of the bytes into account.Incidentally I noticed an optimization for SipHash that would apply no matter which approach is taken. SipHash hashes the low 8 bits of the total length but this doesn't actually guarantee prefix-freedom: playground. This should be removed in favor of a full length hash (either in
HashorHasher).scottmcm commentedon Mar 4, 2022
I've made a proposal partway between those two approaches as PR #94598
I think just leaving it to
Hasher::writeisn't great, since something likeVecDequecan't use that, and it would be nice for aColor's hash to be able to just call write with the 3-byte array, and not get a length prefix even in a dos-resistant hasher since it doesn't need it. (Notably not via the array or slice hash, since that is forced to have the length prefix because ofBorrow.) That implies to me there should be a method that aHashimplementation can call, and which aHashercan then make do nothing if it wants to.Edit: I think the method
@bjorn3hypothesizes below is what I calledwrite_length_prefixin the PR.bjorn3 commentedon Mar 4, 2022
On the other hand this would require siphasher (used for hash tables by default) to hash the length, which would slow it down as lengths now get hashed even if not necessary for disambiguation. Maybe we could have a third option where the Hash impl is responsible for calling a new method with the disambiguation data and the Hasher can then decide to hash it or not depending on if it needs HashDoS protection.
Edit: race with
@scottmcmtczajka commentedon Mar 4, 2022
@Amanieu
You're misreading that code.
SipHashcurrently merges allwritecalls, so this is not a collision, this is working as expected.SipHashmakes sure these sequences behave exactly same.This idea is basically to skip over some relevant information when hashing. To me this seems to defeat the whole purpose of hashing. It's like only hashing the first half of a (i32, i32) pair for performance. Is this really such a performance win? It could be a big performance loss due to avoidable collisions.
Rollup merge of rust-lang#96828 - scottmcm:clarify-hasher-write, r=Am…