11 Комити 265b4478b6 ... 4861abd581

Аутор SHA1 Порука Датум
  Michele Orrù 4861abd581 test: derive rate-limit epoch bases with RFC 9380 пре 3 недеља
  Michele Orrù c3a5b0fe18 feat!: derive CMZ generator A automatically пре 3 недеља
  Michele Orrù 1ab9c869ab chore: patch sigma-compiler, sigma-proofs and spongefish to their mu/draft-v3 branches пре 4 недеља
  Michele Orrù cc3eba13dc test: issue the credential before showing it пре 3 недеља
  Michele Orrù aac154912b fix: omit the client proof when the statement has no content пре 4 недеља
  Michele Orrù edfbfd68ec refactor: build the basepoints only on the call that loads them пре 1 месец
  Michele Orrù 25e60c1529 fix: key the basepoints map on the group type пре 1 месец
  Michele Orrù 265b4478b6 chore: patch sigma-compiler, sigma-proofs and spongefish to their mu/draft-v3 branches пре 4 недеља
  Michele Orrù 0d8eecf2e7 fix: omit the client proof when the statement has no content пре 4 недеља
  Michele Orrù db5a2b8552 refactor: build the basepoints only on the call that loads them пре 1 месец
  Michele Orrù edda31039e fix: key the basepoints map on the group type пре 1 месец
11 измењених фајлова са 211 додато и 88 уклоњено
  1. 16 0
      CHANGELOG.md
  2. 43 5
      Cargo.lock
  3. 13 2
      Cargo.toml
  4. 17 3
      README.md
  5. 59 34
      src/lib.rs
  6. 23 8
      tests/basic.rs
  7. 0 7
      tests/priv_serialize.rs
  8. 17 5
      tests/rate_limiting.rs
  9. 0 4
      tests/submodule.rs
  10. 23 17
      tests/two_groups.rs
  11. 0 3
      tests/wallet.rs

+ 16 - 0
CHANGELOG.md

@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Added
+
+- Derive the CMZ generator `A` automatically via a RFC 9380 hash-to-group of the generator.
+  Applications no longer need to call `cmz_group_init` at startup.
+- Add `CMZGroup`, `cmz_generator_a`, and curve features for the automatic
+  derivation. Ristretto support is enabled by default; P-256 and secp256k1 are
+  available through the `p256` and `k256` features.
+
+### Changes
+
+- CMZ credential point types must now implement
+  `group::prime::PrimeGroup + group::GroupEncoding`, and
+  `hash2group::FromHash<hash2group::rfc9380::ExpandMsgXmd<sha2::Sha512>>`.
+
 ## [0.3.0] - 2026-08-04
 
 ### Changes

+ 43 - 5
Cargo.lock

@@ -159,6 +159,12 @@ version = "3.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
 
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
 [[package]]
 name = "byteorder"
 version = "1.5.0"
@@ -245,6 +251,7 @@ dependencies = [
  "ff",
  "generic_static",
  "group",
+ "hash2group",
  "hex",
  "lazy_static",
  "p256",
@@ -253,7 +260,7 @@ dependencies = [
  "serde",
  "serde_bytes",
  "serde_with",
- "sha2",
+ "sha2 0.11.0",
  "sigma-compiler",
  "thiserror",
 ]
@@ -302,6 +309,12 @@ version = "0.9.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
 
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -372,7 +385,6 @@ dependencies = [
  "cfg-if",
  "cpufeatures 0.2.17",
  "curve25519-dalek-derive",
- "digest 0.10.7",
  "fiat-crypto",
  "group",
  "rand_core",
@@ -498,7 +510,7 @@ version = "0.7.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
 dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
  "pem-rfc7468",
  "zeroize",
 ]
@@ -519,7 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
 dependencies = [
  "block-buffer 0.10.4",
- "const-oid",
+ "const-oid 0.9.6",
  "crypto-common 0.1.6",
  "subtle",
 ]
@@ -531,6 +543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
 dependencies = [
  "block-buffer 0.12.1",
+ "const-oid 0.10.2",
  "crypto-common 0.2.2",
  "zeroize",
 ]
@@ -711,6 +724,20 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "hash2group"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d84bb44683b8eecf4a6a2ea1788588995028b2acc0437439f78d330ffaa3474f"
+dependencies = [
+ "bytemuck",
+ "curve25519-dalek",
+ "digest 0.11.3",
+ "elliptic-curve",
+ "k256",
+ "p256",
+]
+
 [[package]]
 name = "hash32"
 version = "0.2.1"
@@ -1007,7 +1034,7 @@ dependencies = [
  "ecdsa",
  "elliptic-curve",
  "primeorder",
- "sha2",
+ "sha2 0.10.9",
 ]
 
 [[package]]
@@ -1349,6 +1376,17 @@ dependencies = [
  "digest 0.10.7",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
 [[package]]
 name = "shake"
 version = "0.1.0"

+ 13 - 2
Cargo.toml

@@ -11,6 +11,7 @@ cmz-derive = "=0.3.0"
 ff = "0.13"
 generic_static = "0.2"
 group = "0.13"
+hash2group = { version = "0.1.1", features = [ "rfc9380" ] }
 hex = { version = "0.4", features = [ "serde" ] }
 lazy_static = "1"
 postcard = { version = "1", features = [ "alloc" ] }
@@ -18,15 +19,17 @@ rand = "0.8.5"
 serde = { version = "1", features = [ "derive" ] }
 serde_bytes = "0.11"
 serde_with = "3"
+sha2 = "0.11"
 sigma-compiler = "0.2.3"
 thiserror = "2"
 
 [dev-dependencies]
 chrono = "0.4"
-curve25519-dalek = { version = "4", features = [ "group", "rand_core", "digest" ] }
+curve25519-dalek = { version = "4", features = [ "group", "rand_core" ] }
 # Used for testing co-habitating with a second PrimeGroup.
 p256 = { version = "0.13", features = [ "arithmetic" ] }
-sha2 = "0.10"
+# Enable both test group implementations regardless of the cmz feature set.
+hash2group = { version = "0.1.1", features = [ "curve25519-dalek", "p256" ] }
 
 [patch.crates-io]
 cmz-derive = { path = "cmz-derive" }
@@ -40,6 +43,14 @@ sigma-proofs = { git = "https://github.com/sigma-rs/sigma-proofs", branch = "mu/
 spongefish = { git = "https://github.com/arkworks-rs/spongefish", branch = "mu/draft-v3" }
 
 [features]
+# Hash-to-group implementations available to the automatic CMZ generator
+# derivation. Ristretto remains the default group used by the examples and
+# downstream applications; other groups are opt-in to avoid pulling every
+# curve implementation into every build.
+default = [ "curve25519-dalek" ]
+curve25519-dalek = [ "hash2group/curve25519-dalek" ]
+k256 = [ "hash2group/k256" ]
+p256 = [ "hash2group/p256" ]
 # Dump (to stdout or to a string) the value of the instance on both the
 # prover's and verifier's side.  They should match.
 dump = [ "sigma-compiler/dump" ]

+ 17 - 3
README.md

@@ -17,10 +17,24 @@ to create the credential.)
 Your application can have multiple different kinds of credentials, each
 with its own set of attributes.  All of the credentials in your
 application should use the _same_ `Scalar` and `Point` types.  You get
-these from a mathematical _group_, which must satisfy the trait
-[group::prime::PrimeGroup](https://docs.rs/group/0.13.0/group/prime/trait.PrimeGroup.html).
-A typical such group would be
+these from a mathematical _group_. The `Point` type must implement all of
+the following bounds:
+
+  - `group::prime::PrimeGroup`
+  - `group::GroupEncoding`
+  - `hash2group::FromHash<hash2group::rfc9380::ExpandMsgXmd<sha2::Sha512>>`
+
+`CMZGroup` is a blanket marker trait for exactly this combination of bounds.
+A typical point type satisfying them is
 [curve25519\_dalek::ristretto::RistrettoPoint](https://docs.rs/curve25519-dalek/4.1.3/curve25519_dalek/ristretto/struct.RistrettoPoint.html).
+The default `curve25519-dalek` feature supplies the hash-to-group
+implementation for `RistrettoPoint`; the optional `p256` and `k256` features
+supply it for `p256::ProjectivePoint` and `k256::ProjectivePoint`, respectively.
+
+CMZ requires a second generator `A`, which is derived automatically on first use
+by hashing the canonical encoding of `B` and caching it. Callers may override it
+by calling `cmz_group_init` or `cmz_group_init_with` before the first CMZ
+operation.
 
 To declare a credential type, use the `CMZ!` macro at the top level of
 your crate or module (outside of any function):

+ 59 - 34
src/lib.rs

@@ -11,11 +11,13 @@ use group::prime::PrimeGroup;
 use group::{Group, GroupEncoding};
 #[cfg(feature = "wnaf_is_constant_time")]
 use group::{WnafBase, WnafScalar};
+use hash2group::{rfc9380::ExpandMsgXmd, FromHash};
 use lazy_static::lazy_static;
 use rand::RngCore;
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
 pub use serde_with::serde_as;
 use serde_with::{DeserializeAs, SerializeAs};
+use sha2::Sha512;
 use sigma_compiler::*;
 pub use sigma_compiler::{self};
 use thiserror::Error;
@@ -111,6 +113,36 @@ pub struct CMZPubkey<G: PrimeGroup> {
     pub X: Vec<G>,
 }
 
+/// Domain separator for deriving the CMZ generator `A`.
+///
+/// This value is a public protocol parameter, it is the computationally-independent
+/// auxiliary group generator.
+/// Changing it changes `A`, and thus it is a breaking change for existing CMZ keys
+/// and credentials.
+pub const CMZ_GENERATOR_A_DST: &[u8] = b"CMZ_GENERATOR_A_XMD:SHA-512_RO_V1_";
+
+/// A prime-order group supporting encoding and hash into the group.
+///
+/// The blanket implementation covers groups supported by [`hash2group`].
+pub trait CMZGroup: PrimeGroup + GroupEncoding + FromHash<ExpandMsgXmd<Sha512>> {}
+
+impl<G> CMZGroup for G where G: PrimeGroup + GroupEncoding + FromHash<ExpandMsgXmd<Sha512>> {}
+
+/// Deterministically derive the CMZ generator `A` from the standard generator
+/// `B` using hash-to-group.
+pub fn cmz_generator_a<G: CMZGroup>() -> G {
+    let generator_b = G::generator();
+    let encoded_b = generator_b.to_bytes();
+    let generator_a =
+        <G as FromHash<ExpandMsgXmd<Sha512>>>::from_hash(CMZ_GENERATOR_A_DST, encoded_b.as_ref());
+
+    assert!(
+        !bool::from(generator_a.is_identity()) && generator_a != generator_b,
+        "hash-to-group produced an invalid CMZ generator A"
+    );
+    generator_a
+}
+
 // The size of the WNAF windows.  Larger sizes take more memory, but
 // result in faster multiplications.
 #[cfg(feature = "wnaf_is_constant_time")]
@@ -255,67 +287,59 @@ fn load_bp_with<G: Group>(init: impl FnOnce() -> CMZBasepoints<G>) -> &'static C
         .unwrap()
 }
 
-/// The already-loaded `CMZBasepoints<G>`; panics if `cmz_group_init` has
-/// not been called for `G`.
-fn load_bp<G: Group>() -> &'static CMZBasepoints<G> {
-    load_bp_with::<G>(|| panic!("basepoints uninitialized"))
+/// Return the basepoints for `G`, deriving and memoizing `A` on first use.
+fn load_bp<G: CMZGroup>() -> &'static CMZBasepoints<G> {
+    load_bp_with::<G>(|| CMZBasepoints::init(cmz_generator_a::<G>()))
 }
 
-/// Initialize the required second generator for a `PrimeGroup`.
+/// Override the automatically derived second generator for a `PrimeGroup`.
 ///
 /// CMZ credentials require two generators, `A` and `B`.  `B` is the
 /// "standard" generator.  A can be any other generator (that is, any
 /// other non-identity point in a prime-order group), but it is required
 /// that no one know the discrete log between `A` and `B`.  So you can't
 /// generate `A` by multiplying `B` by some scalar, for example.  If your
-/// group has a hash_from_bytes function, then you can use that to generate
-/// `A`. For example, if your group is a curve25519 group, you can
+/// group has a hash-to-group function, then you can use that to generate
+/// `A`. For the standard CMZ derivation, use [`cmz_generator_a`]:
 ///
 /// ```
-/// use curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT as B;
+/// use cmz::cmz_generator_a;
 /// use curve25519_dalek::ristretto::RistrettoPoint as G;
-/// use sha2::Sha512;
-/// let A = G::hash_from_bytes::<Sha512>(b"CMZ Generator A");
-/// assert_ne!(A, B);
+/// use group::Group;
+/// let A = cmz_generator_a::<G>();
+/// assert_ne!(A, G::generator());
 /// ```
 ///
 /// Otherwise, you're possibly on your own to generate an appropriate
-/// generator `A`.  Everyone who uses a given credential type with a
-/// given group will need to use the same `A`.  You need to call this
-/// before doing any operations with a credential.
+/// generator `A`. Everyone who uses a given credential type with a given
+/// group will need to use the same `A`.
+///
+/// CMZ automatically derives `A` using [`cmz_generator_a`], so normal callers
+/// do not need this function. This override exists for compatibility with
+/// deployments using older or application-specific public parameters. It must
+/// be called before the first CMZ operation for `G`; later calls retain the
+/// already-loaded value.
 pub fn cmz_group_init<G: PrimeGroup>(generator_A: G) {
     load_bp_with(|| CMZBasepoints::<G>::init(generator_A));
 }
 
-/// [`cmz_group_init`], deferring construction of `generator_A` to the call
-/// that actually initializes the group.
+/// [`cmz_group_init`], deferring construction of a custom `generator_A` to the
+/// call that actually initializes the group.
 ///
-/// The basepoints are memoized, so calling `cmz_group_init` once per request
-/// is harmless -- except that its argument is evaluated first, every time.
-/// Callers typically derive `A` by hashing to the curve, which costs far more
-/// than the initialization it feeds:
+/// This is only needed for compatibility with custom public parameters. The
+/// default generator is derived automatically by [`cmz_generator_a`].
 ///
-/// ```no_run
-/// # use cmz::{cmz_group_init, cmz_group_init_with};
-/// # use curve25519_dalek::ristretto::RistrettoPoint as G;
-/// # use sha2::Sha512;
-/// // ~4.7us of hash-to-curve on every call, thrown away after the first:
-/// cmz_group_init(G::hash_from_bytes::<Sha512>(b"CMZ Generator A"));
-///
-/// // hashed once, on the call that initializes:
-/// cmz_group_init_with(|| G::hash_from_bytes::<Sha512>(b"CMZ Generator A"));
-/// ```
 pub fn cmz_group_init_with<G: PrimeGroup>(generator_A: impl FnOnce() -> G) {
     load_bp_with(|| CMZBasepoints::<G>::init(generator_A()));
 }
 
 /// Get the loaded CMZBasepoints for the given group
-pub fn cmz_basepoints<G: PrimeGroup>() -> &'static CMZBasepoints<G> {
+pub fn cmz_basepoints<G: CMZGroup>() -> &'static CMZBasepoints<G> {
     load_bp()
 }
 
 /// Compute a public key from a private key
-pub fn cmz_privkey_to_pubkey<G: PrimeGroup>(privkey: &CMZPrivkey<G>) -> CMZPubkey<G> {
+pub fn cmz_privkey_to_pubkey<G: CMZGroup>(privkey: &CMZPrivkey<G>) -> CMZPubkey<G> {
     let bp = load_bp::<G>();
     let X0: Option<G> = if privkey.muCMZ {
         Some(bp.mulB(&privkey.x0))
@@ -340,7 +364,7 @@ where
     type Scalar: PrimeField;
 
     /// The type of the coordinates of the MAC for this credential
-    type Point: PrimeGroup;
+    type Point: CMZGroup;
 
     /// Produce a vector of strings containing the names of the
     /// attributes of this credential.  (The MAC is not included.)
@@ -490,7 +514,8 @@ where
 ///     use curve25519_dalek::ristretto::RistrettoPoint;
 ///     type G = RistrettoPoint;
 ///
-/// The group must implement the trait [`PrimeGroup`](https://docs.rs/group/latest/group/prime/trait.PrimeGroup.html).
+/// The group must implement [`CMZGroup`]. Implementations are available for
+/// supported hash-to-group curves through the corresponding crate features.
 #[macro_export]
 macro_rules! CMZ {
     ( $name: ident < $G: ident > : $( $id: ident ),+ ) => {

+ 23 - 8
tests/basic.rs

@@ -3,13 +3,17 @@ use curve25519_dalek::ristretto::RistrettoPoint;
 use curve25519_dalek::scalar::Scalar;
 use group::Group;
 use rand::{CryptoRng, RngCore};
-use sha2::Sha512;
 
 CMZ! { Basic<RistrettoPoint> :
     attr1,
     attr2
 }
 
+CMZ14Protocol! { basic_issue,
+  ,
+  A: Basic { attr1: S, attr2: S },
+}
+
 CMZ14Protocol! { basic_proto,
 A: Basic {
     attr1: H,
@@ -19,9 +23,6 @@ A: Basic {
 #[test]
 fn test_basic() {
     let mut rng = rand::thread_rng();
-    cmz_group_init(RistrettoPoint::hash_from_bytes::<Sha512>(
-        b"CMZ Generator A",
-    ));
 
     let (privkey, pubkey) = Basic::cmz14_gen_keys(&mut rng);
 
@@ -35,7 +36,24 @@ fn test_basic() {
     assert!(privkey == privkey_serde);
     assert!(pubkey == pubkey_serde);
 
-    let mut basic_cred = Basic::using_privkey(&privkey_serde);
+    // A showing proof needs an issued credential. An unissued credential has
+    // the identity as its MAC, which is rejected as an invalid instance point.
+    let (request, state) =
+        basic_issue::prepare(&mut rng, b"issue_basic", Basic::using_pubkey(&pubkey_serde)).unwrap();
+    let (reply, _) = basic_issue::handle(
+        &mut rng,
+        b"issue_basic",
+        request,
+        |a: &mut Basic| {
+            a.set_privkey(&privkey_serde);
+            a.attr1 = Some(Scalar::ZERO);
+            a.attr2 = Some(Scalar::ONE);
+            Ok(())
+        },
+        |_a: &Basic| Ok(()),
+    )
+    .unwrap();
+    let basic_cred = state.finalize(reply).map_err(|(err, _state)| err).unwrap();
 
     let basic_cred_bytes = postcard::to_allocvec(&basic_cred).unwrap();
 
@@ -43,9 +61,6 @@ fn test_basic() {
 
     println!("{:#?}", basic_cred_bytes);
 
-    basic_cred.attr1 = Some(Scalar::ZERO);
-    basic_cred.attr2 = Some(Scalar::ONE);
-
     let (req, state) = basic_proto::prepare(&mut rng, b"test_basic", &basic_cred).unwrap();
     println!("{req:#?}");
     println!("{state:#?}");

+ 0 - 7
tests/priv_serialize.rs

@@ -3,7 +3,6 @@ use curve25519_dalek::ristretto::RistrettoPoint;
 use curve25519_dalek::scalar::Scalar;
 use group::Group;
 use rand::{CryptoRng, RngCore};
-use sha2::Sha512;
 
 CMZ! { Basic<RistrettoPoint> :
     attr1,
@@ -22,9 +21,6 @@ A: Basic {
 #[test]
 fn test_default_serialize() {
     let mut rng = rand::thread_rng();
-    cmz_group_init(RistrettoPoint::hash_from_bytes::<Sha512>(
-        b"CMZ Generator A",
-    ));
 
     let (privkey, pubkey) = Basic::cmz14_gen_keys(&mut rng);
 
@@ -50,9 +46,6 @@ fn test_default_serialize() {
 #[test]
 fn test_priv_serialize() {
     let mut rng = rand::thread_rng();
-    cmz_group_init(RistrettoPoint::hash_from_bytes::<Sha512>(
-        b"CMZ Generator A",
-    ));
 
     let (privkey, pubkey) = Basic::cmz14_gen_keys(&mut rng);
 

+ 17 - 5
tests/rate_limiting.rs

@@ -4,12 +4,19 @@ use chrono::Utc;
 use cmz::*;
 use curve25519_dalek::ristretto::RistrettoPoint;
 use group::{Group, GroupEncoding};
+use hash2group::{rfc9380::ExpandMsgXmd, FromHash};
 use rand::{CryptoRng, RngCore};
 use sha2::Sha512;
 use std::collections::HashSet;
 
 type G = RistrettoPoint;
 
+const EPOCH_BASE_DST: &[u8] = b"CMZ_RATE_LIMIT_EPOCH_XMD:SHA-512_RO_V1_";
+
+fn epoch_base(epoch: &[u8]) -> G {
+    <G as FromHash<ExpandMsgXmd<Sha512>>>::from_hash(EPOCH_BASE_DST, epoch)
+}
+
 CMZ! { Cred: key }
 CMZ! { PresNum: pres_num }
 
@@ -49,7 +56,7 @@ impl RateLimitClient {
         let mut P = PresNum::using_pubkey(&self.presnum_pubkey);
         P.pres_num = Some(pres_num.into());
         P.fake_MAC(rng);
-        let Epoch_base = RistrettoPoint::hash_from_bytes::<Sha512>(epoch);
+        let Epoch_base = epoch_base(epoch);
         let VRF_output = (self.cred.key.unwrap() + P.pres_num.unwrap()).invert() * Epoch_base;
         let params = pres_cred::Params {
             max_pres: 5u32.into(),
@@ -87,7 +94,7 @@ impl RateLimitServer {
         epoch: &[u8],
         msg: &[u8],
     ) -> Result<(), CMZError> {
-        let Epoch_base = RistrettoPoint::hash_from_bytes::<Sha512>(epoch);
+        let Epoch_base = epoch_base(epoch);
         // Separate the message into the VRF output and the request
         let VRF_output = G::from_bytes(&msg[..32].try_into().unwrap()).unwrap();
 
@@ -125,9 +132,6 @@ impl RateLimitServer {
 #[test]
 fn test_rate_limiting() -> Result<(), CMZError> {
     let mut rng = rand::thread_rng();
-    cmz_group_init(RistrettoPoint::hash_from_bytes::<Sha512>(
-        b"CMZ Generator A",
-    ));
 
     let (privkey, pubkey) = Cred::mucmz_gen_keys(&mut rng);
 
@@ -191,3 +195,11 @@ fn test_rate_limiting() -> Result<(), CMZError> {
 
     Ok(())
 }
+
+#[test]
+fn epoch_base_derivation_is_stable() {
+    assert_eq!(
+        hex::encode(epoch_base(b"Epoch 2026-08-16").to_bytes()),
+        "cabdff610ae6898b12cc86be131d101913b807180f8ffe741319a065d3c2d576"
+    );
+}

+ 0 - 4
tests/submodule.rs

@@ -5,7 +5,6 @@ use curve25519_dalek::ristretto::RistrettoPoint;
 use curve25519_dalek::scalar::Scalar;
 use group::Group;
 use rand::{CryptoRng, RngCore};
-use sha2::Sha512;
 
 pub mod cred {
     use super::*;
@@ -30,9 +29,6 @@ pub mod submod {
     #[test]
     fn test_submodule() -> Result<(), CMZError> {
         let mut rng = rand::thread_rng();
-        cmz_group_init(RistrettoPoint::hash_from_bytes::<Sha512>(
-            b"CMZ Generator A",
-        ));
 
         let (privkey, pubkey) = Basic::cmz14_gen_keys(&mut rng);
 

+ 23 - 17
tests/two_groups.rs

@@ -1,28 +1,34 @@
-//! The basepoints map is keyed by group type, so two groups must be able
-//! to coexist in one process.
-//!
-//! Before the key was `G`, `StaticTypeMap::call_once` was handed
-//! `Box<dyn CMZbp>` — the same `TypeId` for every group — so the map held a
-//! single shared slot. The second group's `cmz_group_init` was silently
-//! ignored and the following `cmz_basepoints` read back the *first* group's
-//! basepoints, failing the downcast and panicking.
+//! Basepoint derivation is keyed by group type, so two groups can coexist.
 
-use cmz::{cmz_basepoints, cmz_group_init};
+use cmz::{cmz_basepoints, cmz_generator_a, cmz_group_init};
 use curve25519_dalek::ristretto::RistrettoPoint;
-use group::Group;
-use sha2::Sha512;
+use group::{Group, GroupEncoding};
+
+const RISTRETTO_A_ENCODING: &[u8] = &[
+    0x8e, 0x65, 0xa3, 0x1d, 0xc2, 0x60, 0x08, 0xdf, 0xbc, 0xd9, 0xf6, 0xef, 0xbd, 0x57, 0xf7, 0xcb,
+    0xb2, 0xb3, 0x98, 0x72, 0xf6, 0xfd, 0x32, 0x73, 0x6a, 0x00, 0x7a, 0x08, 0xc2, 0x1e, 0x47, 0x18,
+];
+const P256_A_ENCODING: &[u8] = &[
+    0x02, 0x73, 0xe5, 0x71, 0x95, 0x24, 0x34, 0xf0, 0xd5, 0xac, 0xaa, 0xe0, 0xfc, 0xe7, 0x35, 0x7c,
+    0x45, 0xb2, 0x73, 0xbd, 0x8e, 0x30, 0x4e, 0xe1, 0xac, 0xd9, 0xf2, 0x43, 0xf3, 0x0b, 0x96, 0x18,
+    0x9e,
+];
 
 #[test]
 fn two_groups_coexist() {
-    let ristretto_a = RistrettoPoint::hash_from_bytes::<Sha512>(b"CMZ Generator A");
-    let p256_a = p256::ProjectivePoint::generator() * p256::Scalar::from(42u64);
+    let ristretto_a = cmz_generator_a::<RistrettoPoint>();
+    let p256_a = cmz_generator_a::<p256::ProjectivePoint>();
 
-    cmz_group_init::<RistrettoPoint>(ristretto_a);
-    cmz_group_init::<p256::ProjectivePoint>(p256_a);
+    assert_eq!(&ristretto_a.to_bytes()[..], RISTRETTO_A_ENCODING);
+    assert_eq!(&p256_a.to_bytes()[..], P256_A_ENCODING);
 
-    // Each group must read back its own basepoints, not the other's.
+    // Each group is initialized automatically and reads back its own
+    // basepoints, not the other's.
     assert_eq!(cmz_basepoints::<RistrettoPoint>().A(), ristretto_a);
-    assert_eq!(cmz_basepoints::<RistrettoPoint>().B(), RistrettoPoint::generator());
+    assert_eq!(
+        cmz_basepoints::<RistrettoPoint>().B(),
+        RistrettoPoint::generator()
+    );
     assert_eq!(cmz_basepoints::<p256::ProjectivePoint>().A(), p256_a);
     assert_eq!(
         cmz_basepoints::<p256::ProjectivePoint>().B(),

+ 0 - 3
tests/wallet.rs

@@ -6,7 +6,6 @@ use cmz::*;
 use curve25519_dalek::ristretto::RistrettoPoint as G;
 use group::Group;
 use rand::{CryptoRng, RngCore};
-use sha2::Sha512;
 
 CMZ! { Wallet: randid, balance }
 CMZ! { Item: serialno, price }
@@ -107,9 +106,7 @@ macro_rules! protos_def {
                 }
             }
 
-            // Initialization
             let mut rng = rand::thread_rng();
-            cmz_group_init(G::hash_from_bytes::<Sha512>(b"CMZ Generator A"));
 
             // Issuer: generate private and public keys for each type of
             // credential.  (The client gets a copy of the public keys.)