diff --git a/sinsemilla/CHANGELOG.md b/sinsemilla/CHANGELOG.md index e2faeb20..0271db42 100644 --- a/sinsemilla/CHANGELOG.md +++ b/sinsemilla/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to Rust's notion of ## [Unreleased] +- Added `UncheckedFixedLengthHashDomain::hash_with_first_word` for messages + whose first Sinsemilla word is already decoded and whose suffix remains a + bit iterator. - Prepared the `1.0.0-rc.4` release. - Replaced `lazy_static` (`spin_no_std`) with `once_cell` plus an exactly-once initialization wrapper for the affine generator table, diff --git a/sinsemilla/src/weighted.rs b/sinsemilla/src/weighted.rs index 60e59168..bbf7289d 100644 --- a/sinsemilla/src/weighted.rs +++ b/sinsemilla/src/weighted.rs @@ -313,6 +313,28 @@ impl UncheckedFixedLengthHashDomain { extract(self.hash_words_to_point(words)) } + /// Evaluates a pre-decoded first word followed by a bit-encoded suffix. + /// + /// `first_word` is used directly as the message's first complete [`K`]-bit + /// Sinsemilla word. `remaining_bits` are then decoded into zero-padded + /// [`K`]-bit words. This avoids making callers encode an already-decoded + /// prefix back into bits when the rest of their message is naturally + /// represented as a bit iterator. + /// + /// # Panics + /// + /// Panics if `first_word` is not a valid [`K`]-bit Sinsemilla word, or if + /// the complete message does not contain exactly `N` words after padding. + pub fn hash_with_first_word( + &self, + first_word: u16, + remaining_bits: impl Iterator, + ) -> pallas::Base { + let remaining_words = MessageWords::new(remaining_bits) + .map(|word| u16::try_from(word).expect("a Sinsemilla word fits into u16")); + extract(self.evaluate(core::iter::once(first_word).chain(remaining_words))) + } + /// Evaluates a batch of `N`-word messages position-first and returns their /// extracted Sinsemilla hashes. /// @@ -745,10 +767,55 @@ mod tests { assert!(bool::from(expected.is_some())); let expected = expected.unwrap(); assert_eq!(expected, weighted.hash_words(&words)); + assert_eq!( + expected, + weighted.hash_with_first_word(words[0], words_to_bits(&words[1..]).into_iter()) + ); assert_eq!(expected, weighted.hash(bits.iter().copied())); } } + #[test] + fn first_word_hash_zero_pads_the_remaining_bits() { + let domain = HashDomain::new(MERKLE_DOMAIN); + let weighted = UncheckedFixedLengthHashDomain::<2>::new(&domain); + let first_word = 7; + let second_word = 5; + let remaining_bits = [true, false, true]; + + assert_eq!( + weighted.hash_with_first_word(first_word, remaining_bits.into_iter()), + weighted.hash_words(&[first_word, second_word]), + ); + } + + #[test] + #[should_panic(expected = "invalid Sinsemilla word")] + fn first_word_hash_rejects_invalid_first_word() { + let domain = HashDomain::new(MERKLE_DOMAIN); + let weighted = UncheckedFixedLengthHashDomain::<1>::new(&domain); + + weighted.hash_with_first_word(GENERATOR_COUNT as u16, core::iter::empty()); + } + + #[test] + #[should_panic(expected = "unexpected Sinsemilla word count")] + fn first_word_hash_rejects_too_few_suffix_words() { + let domain = HashDomain::new(MERKLE_DOMAIN); + let weighted = UncheckedFixedLengthHashDomain::<2>::new(&domain); + + weighted.hash_with_first_word(0, core::iter::empty()); + } + + #[test] + #[should_panic(expected = "unexpected Sinsemilla word count")] + fn first_word_hash_rejects_too_many_suffix_words() { + let domain = HashDomain::new(MERKLE_DOMAIN); + let weighted = UncheckedFixedLengthHashDomain::<2>::new(&domain); + + weighted.hash_with_first_word(0, core::iter::repeat_n(false, 2 * K)); + } + #[test] fn batch_matches_individual_word_evaluation() { let domain = HashDomain::new(MERKLE_DOMAIN);