Verify by digest update + StreamVerifier (#735)

* Replace recompute_R with a separate RCompute

This struct can be use to implement verifiers with incremental updates

* Add raw_sign_byupdate and raw_verify_byupdate

These allow signing/verifying a non-prehashed message
but don't require the whole message to be provided at once.

* Tests for raw_sign_byupdate, raw_verify_byupdate

* Add StreamVerifier

* Make StreamVerifier use RCompute

This allows it to use the same implementation as non-stream signature
verification.

* Guard StreamVerifier behind hazmat feature

* docs: disambiguate unsafety

Co-authored-by: Tony Arcieri <bascule@gmail.com>

* chore: relax F bounds on raw_verify_byupdate

* chore: remove raw_sign_byupdate and raw_verify_byupdate

* chore: address clippy lints within new code

* docs: fixup changelog

* test: invert new chunked test

* chore: revert raw_sign

---------

Co-authored-by: Matt Johnston <matt@ucc.asn.au>
Co-authored-by: Tony Arcieri <bascule@gmail.com>
This commit is contained in:
Rob Ede 2025-06-02 23:30:57 +01:00 committed by GitHub
parent dcd39743ea
commit 6dc7a1c7c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 203 additions and 56 deletions

View file

@ -8,6 +8,8 @@ Entries are listed in reverse chronological order per undeprecated major series.
# Unreleased
* Add `SigningKey::verify_stream()`, and `VerifyingKey::verify_stream()`
# 2.x series
## 2.1.1

View file

@ -39,6 +39,8 @@ use signature::DigestSigner;
#[cfg(feature = "zeroize")]
use zeroize::{Zeroize, ZeroizeOnDrop};
#[cfg(feature = "hazmat")]
use crate::verifying::StreamVerifier;
use crate::{
constants::{KEYPAIR_LENGTH, SECRET_KEY_LENGTH},
errors::{InternalError, SignatureError},
@ -483,6 +485,17 @@ impl SigningKey {
self.verifying_key.verify_strict(message, signature)
}
/// Constructs stream verifier with candidate `signature`.
///
/// See [`VerifyingKey::verify_stream()`] for more details.
#[cfg(feature = "hazmat")]
pub fn verify_stream(
&self,
signature: &ed25519::Signature,
) -> Result<StreamVerifier, SignatureError> {
self.verifying_key.verify_stream(signature)
}
/// Convert this signing key into a byte representation of an unreduced, unclamped Curve25519
/// scalar. This is NOT the same thing as `self.to_scalar().to_bytes()`, since `to_scalar()`
/// performs a clamping step, which changes the value of the resulting scalar.

View file

@ -42,6 +42,11 @@ use crate::{
signing::SigningKey,
};
#[cfg(feature = "hazmat")]
mod stream;
#[cfg(feature = "hazmat")]
pub use self::stream::StreamVerifier;
/// An ed25519 public key.
///
/// # Note
@ -186,58 +191,8 @@ impl VerifyingKey {
self.point.is_small_order()
}
// A helper function that computes `H(R || A || M)` where `H` is the 512-bit hash function
// given by `CtxDigest` (this is SHA-512 in spec-compliant Ed25519). If `context.is_some()`,
// this does the prehashed variant of the computation using its contents.
#[allow(non_snake_case)]
fn compute_challenge<CtxDigest>(
context: Option<&[u8]>,
R: &CompressedEdwardsY,
A: &CompressedEdwardsY,
M: &[u8],
) -> Scalar
where
CtxDigest: Digest<OutputSize = U64>,
{
let mut h = CtxDigest::new();
if let Some(c) = context {
h.update(b"SigEd25519 no Ed25519 collisions");
h.update([1]); // Ed25519ph
h.update([c.len() as u8]);
h.update(c);
}
h.update(R.as_bytes());
h.update(A.as_bytes());
h.update(M);
Scalar::from_hash(h)
}
// Helper function for verification. Computes the _expected_ R component of the signature. The
// caller compares this to the real R component. If `context.is_some()`, this does the
// prehashed variant of the computation using its contents.
// Note that this returns the compressed form of R and the caller does a byte comparison. This
// means that all our verification functions do not accept non-canonically encoded R values.
// See the validation criteria blog post for more details:
// https://hdevalence.ca/blog/2020-10-04-its-25519am
#[allow(non_snake_case)]
fn recompute_R<CtxDigest>(
&self,
context: Option<&[u8]>,
signature: &InternalSignature,
M: &[u8],
) -> CompressedEdwardsY
where
CtxDigest: Digest<OutputSize = U64>,
{
let k = Self::compute_challenge::<CtxDigest>(context, &signature.R, &self.compressed, M);
let minus_A: EdwardsPoint = -self.point;
// Recall the (non-batched) verification equation: -[k]A + [s]B = R
EdwardsPoint::vartime_double_scalar_mul_basepoint(&k, &(minus_A), &signature.s).compress()
}
/// The ordinary non-batched Ed25519 verification check, rejecting non-canonical R values. (see
/// [`Self::recompute_R`]). `CtxDigest` is the digest used to calculate the pseudorandomness
/// [`Self::RCompute`]). `CtxDigest` is the digest used to calculate the pseudorandomness
/// needed for signing. According to the spec, `CtxDigest = Sha512`.
///
/// This definition is loose in its parameters so that end-users of the `hazmat` module can
@ -253,7 +208,7 @@ impl VerifyingKey {
{
let signature = InternalSignature::try_from(signature)?;
let expected_R = self.recompute_R::<CtxDigest>(None, &signature, message);
let expected_R = RCompute::<CtxDigest>::compute(self, signature, None, message);
if expected_R == signature.R {
Ok(())
} else {
@ -289,7 +244,8 @@ impl VerifyingKey {
);
let message = prehashed_message.finalize();
let expected_R = self.recompute_R::<CtxDigest>(Some(ctx), &signature, &message);
let expected_R = RCompute::<CtxDigest>::compute(self, signature, Some(ctx), &message);
if expected_R == signature.R {
Ok(())
@ -415,7 +371,7 @@ impl VerifyingKey {
return Err(InternalError::Verify.into());
}
let expected_R = self.recompute_R::<Sha512>(None, &signature, message);
let expected_R = RCompute::<Sha512>::compute(self, signature, None, message);
if expected_R == signature.R {
Ok(())
} else {
@ -423,8 +379,22 @@ impl VerifyingKey {
}
}
/// Constructs stream verifier with candidate `signature`.
///
/// Useful for cases where the whole message is not available all at once, allowing the
/// internal signature state to be updated incrementally and verified at the end. In some cases,
/// this will reduce the need for additional allocations.
#[cfg(feature = "hazmat")]
pub fn verify_stream(
&self,
signature: &ed25519::Signature,
) -> Result<StreamVerifier, SignatureError> {
let signature = InternalSignature::try_from(signature)?;
Ok(StreamVerifier::new(*self, signature))
}
/// Verify a `signature` on a `prehashed_message` using the Ed25519ph algorithm,
/// using strict signture checking as defined by [`Self::verify_strict`].
/// using strict signature checking as defined by [`Self::verify_strict`].
///
/// # Inputs
///
@ -477,7 +447,7 @@ impl VerifyingKey {
}
let message = prehashed_message.finalize();
let expected_R = self.recompute_R::<Sha512>(Some(ctx), &signature, &message);
let expected_R = RCompute::<Sha512>::compute(self, signature, Some(ctx), &message);
if expected_R == signature.R {
Ok(())
@ -511,6 +481,79 @@ impl VerifyingKey {
}
}
/// Helper for verification. Computes the _expected_ R component of the signature. The
/// caller compares this to the real R component.
/// This computes `H(R || A || M)` where `H` is the 512-bit hash function
/// given by `CtxDigest` (this is SHA-512 in spec-compliant Ed25519).
///
/// For pre-hashed variants a `h` with the context already included can be provided.
/// Note that this returns the compressed form of R and the caller does a byte comparison. This
/// means that all our verification functions do not accept non-canonically encoded R values.
/// See the validation criteria blog post for more details:
/// https://hdevalence.ca/blog/2020-10-04-its-25519am
pub(crate) struct RCompute<CtxDigest> {
key: VerifyingKey,
signature: InternalSignature,
h: CtxDigest,
}
#[allow(non_snake_case)]
impl<CtxDigest> RCompute<CtxDigest>
where
CtxDigest: Digest<OutputSize = U64>,
{
/// If `prehash_ctx.is_some()`, this does the prehashed variant of the computation using its
/// contents.
pub(crate) fn compute(
key: &VerifyingKey,
signature: InternalSignature,
prehash_ctx: Option<&[u8]>,
message: &[u8],
) -> CompressedEdwardsY {
let mut c = Self::new(key, signature, prehash_ctx);
c.update(message);
c.finish()
}
pub(crate) fn new(
key: &VerifyingKey,
signature: InternalSignature,
prehash_ctx: Option<&[u8]>,
) -> Self {
let R = &signature.R;
let A = &key.compressed;
let mut h = CtxDigest::new();
if let Some(c) = prehash_ctx {
h.update(b"SigEd25519 no Ed25519 collisions");
h.update([1]); // Ed25519ph
h.update([c.len() as u8]);
h.update(c);
}
h.update(R.as_bytes());
h.update(A.as_bytes());
Self {
key: *key,
signature,
h,
}
}
pub(crate) fn update(&mut self, m: &[u8]) {
self.h.update(m)
}
pub(crate) fn finish(self) -> CompressedEdwardsY {
let k = Scalar::from_hash(self.h);
let minus_A: EdwardsPoint = -self.key.point;
// Recall the (non-batched) verification equation: -[k]A + [s]B = R
EdwardsPoint::vartime_double_scalar_mul_basepoint(&k, &(minus_A), &self.signature.s)
.compress()
}
}
impl Verifier<ed25519::Signature> for VerifyingKey {
/// Verify a signature on a message with this keypair's public key.
///

View file

@ -0,0 +1,45 @@
use curve25519_dalek::edwards::CompressedEdwardsY;
use sha2::Sha512;
use crate::verifying::RCompute;
use crate::{signature::InternalSignature, InternalError, SignatureError, VerifyingKey};
/// An IUF verifier for ed25519.
///
/// Created with [`VerifyingKey::verify_stream()`] or [`SigningKey::verify_stream()`].
///
/// [`SigningKey::verify_stream()`]: super::SigningKey::verify_stream()
#[allow(non_snake_case)]
pub struct StreamVerifier {
cr: RCompute<Sha512>,
sig_R: CompressedEdwardsY,
}
impl StreamVerifier {
/// Constructs new stream verifier.
///
/// Seeds hash state with public key and signature components.
pub(crate) fn new(public_key: VerifyingKey, signature: InternalSignature) -> Self {
Self {
cr: RCompute::new(&public_key, signature, None),
sig_R: signature.R,
}
}
/// Digest message chunk.
pub fn update(&mut self, chunk: impl AsRef<[u8]>) {
self.cr.update(chunk.as_ref());
}
/// Finalize verifier and check against candidate signature.
#[allow(non_snake_case)]
pub fn finalize_and_verify(self) -> Result<(), SignatureError> {
let expected_R = self.cr.finish();
if expected_R == self.sig_R {
Ok(())
} else {
Err(InternalError::Verify.into())
}
}
}

View file

@ -334,6 +334,50 @@ mod integrations {
);
}
#[cfg(feature = "digest")]
#[test]
fn sign_verify_digest_equivalence() {
// TestSignVerify
let mut csprng = OsRng {};
let good: &[u8] = "test message".as_bytes();
let bad: &[u8] = "wrong message".as_bytes();
let keypair: SigningKey = SigningKey::generate(&mut csprng);
let good_sig: Signature = keypair.sign(good);
let bad_sig: Signature = keypair.sign(bad);
let mut verifier = keypair.verify_stream(&good_sig).unwrap();
verifier.update(good);
assert!(
verifier.finalize_and_verify().is_ok(),
"Verification of a valid signature failed!"
);
let mut verifier = keypair.verify_stream(&bad_sig).unwrap();
verifier.update(good);
assert!(
verifier.finalize_and_verify().is_err(),
"Verification of a signature on a different message passed!"
);
let mut verifier = keypair.verify_stream(&good_sig).unwrap();
verifier.update("test ");
verifier.update("message");
assert!(
verifier.finalize_and_verify().is_ok(),
"Verification of a valid signature failed!"
);
let mut verifier = keypair.verify_stream(&good_sig).unwrap();
verifier.update(bad);
assert!(
verifier.finalize_and_verify().is_err(),
"Verification of a signature on a different message passed!"
);
}
#[cfg(feature = "digest")]
#[test]
fn ed25519ph_sign_verify() {