pakery-crypto 0.2.1

Concrete cryptographic implementations for PAKE protocols
Documentation
//! Ristretto255 implementations of CpaceGroup and DhGroup traits.

use alloc::vec::Vec;
use curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT;
use curve25519_dalek::ristretto::CompressedRistretto;
use curve25519_dalek::{RistrettoPoint, Scalar};
use pakery_core::crypto::dh::DhGroup;
use pakery_core::crypto::group::CpaceGroup;
use pakery_core::PakeError;
use rand_core::CryptoRng;
use zeroize::{Zeroize, Zeroizing};

/// Ristretto255 group element for CPace.
#[derive(Clone, PartialEq)]
pub struct Ristretto255Group {
    point: RistrettoPoint,
}

impl CpaceGroup for Ristretto255Group {
    type Scalar = Scalar;

    fn scalar_mul(&self, scalar: &Scalar) -> Self {
        Self {
            point: self.point * scalar,
        }
    }

    fn is_identity(&self) -> bool {
        use curve25519_dalek::traits::Identity;
        use subtle::ConstantTimeEq;
        // ctgrind: the identity-rejection outcome is a public protocol abort.
        pakery_core::ct::declassify_choice(self.point.ct_eq(&RistrettoPoint::identity()))
    }

    fn to_bytes(&self) -> Vec<u8> {
        self.point.compress().to_bytes().to_vec()
    }

    fn from_bytes(bytes: &[u8]) -> Result<Self, PakeError> {
        let arr: [u8; 32] = bytes.try_into().map_err(|_| PakeError::InvalidPoint)?;
        let point = CompressedRistretto(arr)
            .decompress()
            .ok_or(PakeError::InvalidPoint)?;
        Ok(Self { point })
    }

    fn from_uniform_bytes(bytes: &[u8]) -> Result<Self, PakeError> {
        let arr: &[u8; 64] = bytes
            .try_into()
            .map_err(|_| PakeError::InvalidInput("from_uniform_bytes requires 64 bytes"))?;
        Ok(Self {
            point: RistrettoPoint::from_uniform_bytes(arr),
        })
    }

    fn random_scalar(rng: &mut impl CryptoRng) -> Scalar {
        // Sample 64 wide bytes and reduce mod the group order. Avoids
        // `Scalar::random` from curve25519-dalek 4.1, which is tied to
        // rand_core 0.6 and incompatible with our 0.10 RNG bound.
        let mut wide = Zeroizing::new([0u8; 64]);
        rng.fill_bytes(&mut *wide);
        // ctgrind: freshly sampled scalar material is secret; the wide
        // reduction below is branch-free, so taint flows into the scalar
        // and through dalek's constant-time group ops.
        pakery_core::ct::mark_secret(&*wide);
        Scalar::from_bytes_mod_order_wide(&wide)
    }

    fn add(&self, other: &Self) -> Self {
        Self {
            point: self.point + other.point,
        }
    }

    fn negate(&self) -> Self {
        Self { point: -self.point }
    }

    fn basepoint_mul(scalar: &Scalar) -> Self {
        Self {
            point: scalar * RISTRETTO_BASEPOINT_POINT,
        }
    }

    fn scalar_from_wide_bytes(bytes: &[u8]) -> Result<Scalar, PakeError> {
        let arr: [u8; 64] = bytes
            .try_into()
            .map_err(|_| PakeError::InvalidInput("scalar_from_wide_bytes requires 64 bytes"))?;
        Ok(Scalar::from_bytes_mod_order_wide(&arr))
    }

    fn scalar_to_bytes(scalar: &Scalar) -> Vec<u8> {
        scalar.to_bytes().to_vec()
    }
}

/// Ristretto255 Diffie-Hellman group (byte-level operations).
///
/// **Note:** [`derive_keypair`](DhGroup::derive_keypair) uses the
/// `"OPAQUE-DeriveDiffieHellmanKeyPair"` info label, making this
/// implementation OPAQUE-specific. Using it outside OPAQUE will produce
/// keys scoped to the OPAQUE domain separator.
pub struct Ristretto255Dh;

impl DhGroup for Ristretto255Dh {
    fn diffie_hellman(sk: &[u8], pk: &[u8]) -> Result<Zeroizing<Vec<u8>>, PakeError> {
        use curve25519_dalek::traits::Identity;
        use subtle::ConstantTimeEq;
        let sk_bytes: [u8; 32] = sk
            .try_into()
            .map_err(|_| PakeError::InvalidInput("invalid secret key length"))?;
        // ctgrind: launder the canonicity check — `from_canonical_bytes`
        // branches on a CtOption discriminant inside dalek, and canonicity of
        // an honestly generated secret key is public. `sk_bytes` is a local
        // copy; the caller's slice stays marked. The DH result is re-marked
        // secret below so taint stays end-to-end.
        let sk_bytes = Zeroizing::new(sk_bytes);
        pakery_core::ct::declassify(&*sk_bytes);
        let scalar = Scalar::from_canonical_bytes(*sk_bytes)
            .into_option()
            .ok_or(PakeError::InvalidInput("invalid scalar"))?;

        let pk_bytes: [u8; 32] = pk
            .try_into()
            .map_err(|_| PakeError::InvalidInput("invalid public key length"))?;
        let pk_point = CompressedRistretto(pk_bytes)
            .decompress()
            .ok_or(PakeError::InvalidInput("invalid public key point"))?;

        let result = scalar * pk_point;
        // ctgrind: the identity-rejection outcome is a public protocol abort.
        if pakery_core::ct::declassify_choice(result.ct_eq(&RistrettoPoint::identity())) {
            return Err(PakeError::IdentityPoint);
        }
        let out = result.compress().to_bytes().to_vec();
        // ctgrind: the DH output is secret key material (re-mark after the
        // laundered scalar parse above).
        pakery_core::ct::mark_secret(&out);
        Ok(Zeroizing::new(out))
    }

    fn derive_keypair(seed: &[u8]) -> Result<(Zeroizing<Vec<u8>>, Vec<u8>), PakeError> {
        use crate::oprf_ristretto::Ristretto255Oprf;
        use pakery_core::crypto::oprf::Oprf;

        let sk_bytes = Ristretto255Oprf::derive_key(seed, b"OPAQUE-DeriveDiffieHellmanKeyPair")?;
        let sk_arr: [u8; 32] = sk_bytes
            .as_slice()
            .try_into()
            .map_err(|_| PakeError::ProtocolError("invalid key size"))?;
        // ctgrind: launder the canonicity check (public for an honestly
        // derived key) on a local copy; `sk_bytes` itself stays marked. The
        // derived public key is public by definition, so no re-mark.
        let sk_arr = Zeroizing::new(sk_arr);
        pakery_core::ct::declassify(&*sk_arr);
        let scalar =
            Scalar::from_canonical_bytes(*sk_arr)
                .into_option()
                .ok_or(PakeError::ProtocolError(
                    "invalid scalar from DeriveKeyPair",
                ))?;

        let pk = (scalar * RISTRETTO_BASEPOINT_POINT)
            .compress()
            .to_bytes()
            .to_vec();
        Ok((sk_bytes, pk))
    }

    fn generate_keypair(
        rng: &mut impl CryptoRng,
    ) -> Result<(Zeroizing<Vec<u8>>, Vec<u8>), PakeError> {
        let mut seed = [0u8; 32];
        rng.fill_bytes(&mut seed);
        // ctgrind: the keypair seed is fresh secret material.
        pakery_core::ct::mark_secret(&seed);
        let result = Self::derive_keypair(&seed);
        seed.zeroize();
        result
    }

    fn public_key_from_private(sk: &[u8]) -> Result<Vec<u8>, PakeError> {
        let sk_bytes: [u8; 32] = sk
            .try_into()
            .map_err(|_| PakeError::InvalidInput("invalid secret key length"))?;
        // ctgrind: launder the canonicity check (public for an honestly
        // generated key) on a local copy; the resulting public key is public.
        let sk_bytes = Zeroizing::new(sk_bytes);
        pakery_core::ct::declassify(&*sk_bytes);
        let scalar = Scalar::from_canonical_bytes(*sk_bytes)
            .into_option()
            .ok_or(PakeError::InvalidInput("invalid scalar"))?;
        let pk = (scalar * RISTRETTO_BASEPOINT_POINT)
            .compress()
            .to_bytes()
            .to_vec();
        Ok(pk)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pakery_core::crypto::CpaceGroup;

    /// `scalar_to_bytes` must produce the canonical 32-byte little-endian
    /// encoding (roadmap item 8: CPace itself never serializes scalars, so
    /// only a direct contract test constrains this trait method).
    #[test]
    fn scalar_to_bytes_roundtrips_canonical_encoding() {
        let mut wide = [0u8; 64];
        wide[0] = 5; // 5 < group order: reduction is the identity
        let scalar = <Ristretto255Group as CpaceGroup>::scalar_from_wide_bytes(&wide).unwrap();
        let bytes = <Ristretto255Group as CpaceGroup>::scalar_to_bytes(&scalar);
        let mut expected = [0u8; 32];
        expected[0] = 5;
        assert_eq!(bytes, expected);
    }

    /// `public_key_from_private` must reproduce the public key of
    /// `derive_keypair` (roadmap item 8: the P-256 twin has this test in
    /// pakery-tests, the ristretto255 side was unconstrained).
    #[test]
    fn public_key_from_private_matches_derive_keypair() {
        use pakery_core::crypto::DhGroup;
        let (sk, pk) = Ristretto255Dh::derive_keypair(&[7u8; 32]).unwrap();
        let pk2 = Ristretto255Dh::public_key_from_private(&sk).unwrap();
        assert_eq!(pk, pk2);
        assert_eq!(pk.len(), 32);
        assert!(Ristretto255Dh::public_key_from_private(&[0u8; 31]).is_err());
    }
}