Skip to content

Fast Verification of Decryption in TLE#3315

Open
guruvamsi-policharla wants to merge 2 commits intomainfrom
fast-tle
Open

Fast Verification of Decryption in TLE#3315
guruvamsi-policharla wants to merge 2 commits intomainfrom
fast-tle

Conversation

@guruvamsi-policharla
Copy link
Collaborator

Scheme Description

Implements the CPA secure version where the committee additionally holds shares of a random value $\gamma$ and $\gamma^{-1}$ and publish:

  • $\gamma \cdot \text{pk}$
  • ${\gamma^{-1}} \cdot H(\text{id})$ for all possible $\text{id}$ that users will encrypt to. Note that this doubles the total communication from the committee as they publish signatures on $H(\text{id})$ anyway.

We can avoid this at the cost of moving base group MSMs to the target group.

Summary

Adds a batch timelock encryption (TLE) module for BLS12-381 that encrypts messages from a small message space ${0, \ldots, 2^k - 1}$ and supports efficient batch verification of decryptions.

  • Encrypt/Decrypt: Each ciphertext is $(u = \alpha \cdot G,; c = (\alpha + m) \cdot ({\gamma^{-1}} \cdot H(\text{id}))$. Decryption uses 2 pairings and a precomputed discrete-log lookup table (HashMap<[u8; 32], u64>) that maps hashed $\mathbb{G}_T$ elements to messages.
  • Batch verification: Verifies $n$ claimed decryptions with 2 MSMs + 2 pairings (constant regardless of $n$), compared to $2n$ pairings for individual decryption. Uses random linear combination with challenge scalars.
  • $\mathbb{G}_T$ operations: Added mul, one, is_one, and scalar_mul (square-and-multiply with cyclotomic squaring) to the GT type in group.rs.
  • Lookup table: build_table precomputes $\text{SHA256}(\text{pk} \circ (m\cdot H(\text{id}))) \to m$ for $m \in {0, \ldots, 2^k - 1}$. Table keys are 32-byte hashes instead of full 576-byte $\mathbb{G}_T$ elements.

The scheme assumes a VRF committee publishes $\text{pk}^\gamma$ and $H(\text{id})^{\gamma^{-1}}$ for each target identity. All public functions accept these pre-processed values as inputs.

Encryption

Given a message $m \in {0, \ldots, 2^k - 1}$ and the committee-provided $H(\text{id})^{\gamma^{-1}}$:

  • Sample $\alpha \leftarrow \mathbb{F}$
  • $\text{ct}_0 = (\alpha + m) \cdot ({\gamma^{-1}} \cdot H(\text{id}))$
  • $\text{ct}_1 = \alpha \cdot [1]_1$

Decryption

Given the BLS signature $\sigma_{\text{id}}$ over the target identity:

  • Compute $\text{ct}'_0 = \text{pk}^\gamma \circ \text{ct}_0$ (pairing)
  • Recover $m$ as the discrete log of $\text{ct}'_0 - \text{ct}1 \circ \sigma{\text{id}}$ with respect to $\text{pk} \circ H(\text{id})$

For small message spaces, the discrete log is recovered via a precomputed lookup table mapping $\text{SHA256}(\text{base}^m) \to m$.

Batch Verification

To verify $B$ claimed decryptions ${m_i}$ for ciphertexts encrypted to the same target, sample random challenges $r_i$ and check:

$$(\gamma \cdot \text{pk}) \circ \left(\sum_i r_i \cdot \text{ct}_0^i - \left(\sum_i r_i \cdot m_i\right) \cdot ({\gamma^{-1}} \cdot H(\text{id}))\right) - \left(\sum_i r_i \cdot \text{ct}_1^i\right) \circ \sigma_{\text{id}} \stackrel{?}{=} 0$$

This requires one $\mathbb{G}_2$-MSM of size $B$, one $\mathbb{G}_1$-MSM of size $B$, and two pairings (constant). Following [FGHP09], the challenge scalars can be sampled from a smaller range $[0, 2^\ell)$ to further reduce MSM cost, with soundness error at most $2^{-\ell}$.

Benchmark results (single-threaded)

Ran on a 2019 Intel MacBook Pro

$n$ $k$ Individual Batch Speedup $\mathbb{G}_2$-MSM $\mathbb{G}_1$-MSM Scalar Pairing
100 1 489.82ms 43.77ms 11.2x 27.58ms 9.70ms 691.46us 4.87ms
100 8 489.83ms 43.78ms 11.2x 27.27ms 9.59ms 684.74us 4.88ms
100 16 490.42ms 43.33ms 11.3x 27.27ms 9.59ms 684.74us 4.88ms
1,000 1 4.57s 226.30ms 20.2x 158.06ms 55.31ms 785.93us 4.44ms
1,000 8 4.19s 205.94ms 20.3x 143.43ms 50.57ms 716.41us 4.07ms
1,000 16 3.77s 189.81ms 19.9x 132.16ms 46.71ms 658.46us 3.76ms
10,000 1 28.11s 872.10ms 32.2x 610.14ms 216.04ms 1.27ms 2.42ms
10,000 8 20.97s 667.02ms 31.4x 466.03ms 165.27ms 983.63us 1.88ms
10,000 16 16.54s 559.52ms 29.6x 391.42ms 138.09ms 827.81us 1.56ms
100,000 1 209.49s 5.74s 36.5x 3.95s 1.42s 8.10ms 2.03ms
100,000 8 182.64s 5.52s 33.1x 3.81s 1.36s 7.78ms 1.95ms
100,000 16 161.38s 4.01s 40.3x 2.76s 983.29ms 5.71ms 1.43ms

@cloudflare-workers-and-pages
Copy link

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
commonware-mcp 0fa8d5e Mar 04 2026, 11:34 PM

@guruvamsi-policharla guruvamsi-policharla marked this pull request as ready for review March 5, 2026 20:54
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Debug timing and println left in production code
    • Removed all timing instrumentation and the stdout println! from verify_decryption so it performs only verification logic.
  • ✅ Fixed: Unused public method GT::scalar_mul has no callers
    • Deleted the unused GT::scalar_mul method and its now-unused BLST import to eliminate dead public code.

Create PR

Or push these changes by commenting:

@cursor push 13291663df
Preview (13291663df)
diff --git a/cryptography/src/bls12381/batch_tle.rs b/cryptography/src/bls12381/batch_tle.rs
--- a/cryptography/src/bls12381/batch_tle.rs
+++ b/cryptography/src/bls12381/batch_tle.rs
@@ -144,25 +144,17 @@
 
     let n = ciphertexts.len();
 
-    // Sample random challenge scalars
-    let t0 = std::time::Instant::now();
     let challenges: Vec<Scalar> = (0..n).map(|_| Scalar::random(&mut *rng)).collect();
-    let t_challenges = t0.elapsed();
 
     // V::Signature MSM: Σ c_i * r_i
-    let t0 = std::time::Instant::now();
     let c_points: Vec<V::Signature> = ciphertexts.iter().map(|ct| ct.c).collect();
     let c_agg = V::Signature::msm(&c_points, &challenges, &commonware_parallel::Sequential);
-    let t_sig_msm = t0.elapsed();
 
     // V::Public MSM: Σ u_i * r_i
-    let t0 = std::time::Instant::now();
     let u_points: Vec<V::Public> = ciphertexts.iter().map(|ct| ct.u).collect();
     let u_agg = V::Public::msm(&u_points, &challenges, &commonware_parallel::Sequential);
-    let t_pub_msm = t0.elapsed();
 
     // Scalar sum: s = Σ r_i * m_i
-    let t0 = std::time::Instant::now();
     let mut msg_scalar = Scalar::zero();
     for (r, &m) in challenges.iter().zip(messages.iter()) {
         let m_scalar = Scalar::from_u64(m);
@@ -173,20 +165,11 @@
     let mut msg_term = *h_id_gamma;
     msg_term *= &(-msg_scalar);
     let sig_term = c_agg + &msg_term;
-    let t_scalar = t0.elapsed();
 
     // Check: e(pk^gamma, sig_term) * e(-u_agg, sig_id) == 1
-    let t0 = std::time::Instant::now();
     let lhs = V::pairing(pk_gamma, &sig_term);
     let rhs = V::pairing(&u_agg.neg(), signature);
-    let result = lhs.mul(&rhs).is_one();
-    let t_pairing = t0.elapsed();
-
-    println!(
-        "  verify_decryption(n={n}): challenges={t_challenges:.2?}, sig_msm={t_sig_msm:.2?}, pub_msm={t_pub_msm:.2?}, scalar={t_scalar:.2?}, pairing={t_pairing:.2?}",
-    );
-
-    result
+    lhs.mul(&rhs).is_one()
 }
 
 #[cfg(test)]

diff --git a/cryptography/src/bls12381/primitives/group.rs b/cryptography/src/bls12381/primitives/group.rs
--- a/cryptography/src/bls12381/primitives/group.rs
+++ b/cryptography/src/bls12381/primitives/group.rs
@@ -16,15 +16,15 @@
 use alloc::{vec, vec::Vec};
 use blst::{
     blst_bendian_from_fp12, blst_bendian_from_scalar, blst_expand_message_xmd, blst_fp12,
-    blst_fp12_cyclotomic_sqr, blst_fp12_is_one, blst_fp12_mul, blst_fp12_one, blst_fr,
-    blst_fr_add, blst_fr_cneg, blst_fr_from_scalar, blst_fr_from_uint64, blst_fr_inverse,
-    blst_fr_mul, blst_fr_rshift, blst_fr_sub, blst_hash_to_g1, blst_hash_to_g2, blst_keygen,
-    blst_p1, blst_p1_add_or_double, blst_p1_affine, blst_p1_cneg, blst_p1_compress, blst_p1_double,
-    blst_p1_from_affine, blst_p1_in_g1, blst_p1_is_inf, blst_p1_mult, blst_p1_to_affine,
-    blst_p1_uncompress, blst_p1s_mult_pippenger, blst_p1s_mult_pippenger_scratch_sizeof,
-    blst_p1s_tile_pippenger, blst_p1s_to_affine, blst_p2, blst_p2_add_or_double, blst_p2_affine,
-    blst_p2_cneg, blst_p2_compress, blst_p2_double, blst_p2_from_affine, blst_p2_in_g2,
-    blst_p2_is_inf, blst_p2_mult, blst_p2_to_affine, blst_p2_uncompress, blst_p2s_mult_pippenger,
+    blst_fp12_is_one, blst_fp12_mul, blst_fp12_one, blst_fr, blst_fr_add, blst_fr_cneg,
+    blst_fr_from_scalar, blst_fr_from_uint64, blst_fr_inverse, blst_fr_mul, blst_fr_rshift,
+    blst_fr_sub, blst_hash_to_g1, blst_hash_to_g2, blst_keygen, blst_p1, blst_p1_add_or_double,
+    blst_p1_affine, blst_p1_cneg, blst_p1_compress, blst_p1_double, blst_p1_from_affine,
+    blst_p1_in_g1, blst_p1_is_inf, blst_p1_mult, blst_p1_to_affine, blst_p1_uncompress,
+    blst_p1s_mult_pippenger, blst_p1s_mult_pippenger_scratch_sizeof, blst_p1s_tile_pippenger,
+    blst_p1s_to_affine, blst_p2, blst_p2_add_or_double, blst_p2_affine, blst_p2_cneg,
+    blst_p2_compress, blst_p2_double, blst_p2_from_affine, blst_p2_in_g2, blst_p2_is_inf,
+    blst_p2_mult, blst_p2_to_affine, blst_p2_uncompress, blst_p2s_mult_pippenger,
     blst_p2s_mult_pippenger_scratch_sizeof, blst_p2s_tile_pippenger, blst_p2s_to_affine,
     blst_scalar, blst_scalar_from_be_bytes, blst_scalar_from_bendian, blst_scalar_from_fr,
     blst_sk_check, Pairing, BLS12_381_G1, BLS12_381_G2, BLST_ERROR,
@@ -456,51 +456,6 @@
         unsafe { blst_fp12_is_one(&self.0) }
     }
 
-    /// Computes self^scalar using square-and-multiply.
-    ///
-    /// Uses cyclotomic squaring, which is faster than general fp12
-    /// squaring for elements in the cyclotomic subgroup (i.e. pairing outputs).
-    pub fn scalar_mul(&self, scalar: &Scalar) -> GT {
-        let s = scalar.as_blst_scalar();
-        // blst_scalar.b is little-endian
-        let bytes = &s.b;
-
-        // Find the highest set bit
-        let mut top_byte = SCALAR_LENGTH - 1;
-        while top_byte > 0 && bytes[top_byte] == 0 {
-            top_byte -= 1;
-        }
-        if bytes[top_byte] == 0 {
-            return GT::one();
-        }
-        let top_bit = 7 - bytes[top_byte].leading_zeros() as usize;
-
-        // Square-and-multiply (MSB to LSB)
-        let mut acc = self.0;
-        let base = &self.0;
-        let mut started = false;
-        for byte_idx in (0..=top_byte).rev() {
-            let b = bytes[byte_idx];
-            let bit_start = if byte_idx == top_byte { top_bit } else { 7 };
-            for bit in (0..=bit_start).rev() {
-                if started {
-                    // SAFETY: blst_fp12_cyclotomic_sqr supports in-place operation.
-                    unsafe { blst_fp12_cyclotomic_sqr(&mut acc, &acc) };
-                }
-                if (b >> bit) & 1 == 1 {
-                    if started {
-                        // SAFETY: blst_fp12_mul supports in-place when ret aliases a.
-                        unsafe { blst_fp12_mul(&mut acc, &acc, base) };
-                    } else {
-                        started = true;
-                    }
-                }
-            }
-        }
-
-        if started { GT(acc) } else { GT::one() }
-    }
-
     /// Converts the GT element to its canonical big-endian byte representation.
     pub fn as_slice(&self) -> [u8; GT_ELEMENT_BYTE_LENGTH] {
         let mut slice = [0u8; GT_ELEMENT_BYTE_LENGTH];
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.


println!(
" verify_decryption(n={n}): challenges={t_challenges:.2?}, sig_msm={t_sig_msm:.2?}, pub_msm={t_pub_msm:.2?}, scalar={t_scalar:.2?}, pairing={t_pairing:.2?}",
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug timing and println left in production code

High Severity

The public verify_decryption function contains std::time::Instant::now() timing instrumentation (five separate timing blocks) and a println! statement that prints detailed timing breakdowns to stdout on every invocation. This is benchmarking/debug code that has no place in a library function — every caller will see unwanted output and pay the cost of timing overhead.

Fix in Cursor Fix in Web

}

if started { GT(acc) } else { GT::one() }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused public method GT::scalar_mul has no callers

Low Severity

GT::scalar_mul is a new pub method with a nontrivial implementation (square-and-multiply with cyclotomic squaring), but a codebase-wide grep reveals it has zero callers — the only match is the definition itself. This is dead code that adds maintenance burden without being exercised by any code path or test.

Fix in Cursor Fix in Web

// V::Signature MSM: Σ c_i * r_i
let t0 = std::time::Instant::now();
let c_points: Vec<V::Signature> = ciphertexts.iter().map(|ct| ct.c).collect();
let c_agg = V::Signature::msm(&c_points, &challenges, &commonware_parallel::Sequential);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect we can make this a good deal faster by accepting parallel here and concurrently running the MSMs


// Sample random challenge scalars
let t0 = std::time::Instant::now();
let challenges: Vec<Scalar> = (0..n).map(|_| Scalar::random(&mut *rng)).collect();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, I think this could also be SmallScalar


// Check: e(pk^gamma, sig_term) * e(-u_agg, sig_id) == 1
let t0 = std::time::Instant::now();
let lhs = V::pairing(pk_gamma, &sig_term);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do pair() on these pairings


/// Encrypted message from a small message space {0, ..., 2^k - 1}.
///
/// The VRF committee samples a random gamma and publishes `pk^gamma` and
Copy link
Contributor

@patrick-ogrady patrick-ogrady Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the very unique property here is that the VRF committee can decide when to publish the target and the solution (creating bounded auctions and auctions with reveal keys only published after some height)

/// Discrete log lookup table mapping hashed GT elements to messages.
///
/// Stores `H(base^m) -> m` for `m in 0..2^k` where
/// `base = e(pk^gamma, H(id)^{gamma^{-1}})`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to elaborate practically on how this shared gamma is derived.

@cronokirby
Copy link
Collaborator

We can afford CPA here because of an assumed ambient context where some other form of integrity is applied, e.g. signing a transaction as a whole, including this ciphertext. What would that look like in general? Could we integrate that directly here? e.g. using batch verified signatures or something

@codecov
Copy link

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 87.31343% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.01%. Comparing base (3b5fe2c) to head (0fa8d5e).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
cryptography/src/bls12381/primitives/group.rs 28.88% 32 Missing ⚠️
cryptography/src/bls12381/batch_tle.rs 99.10% 1 Missing and 1 partial ⚠️
@@            Coverage Diff             @@
##             main    #3315      +/-   ##
==========================================
+ Coverage   93.00%   93.01%   +0.01%     
==========================================
  Files         418      419       +1     
  Lines      142546   143157     +611     
  Branches     3400     3424      +24     
==========================================
+ Hits       132568   133163     +595     
- Misses       8883     8904      +21     
+ Partials     1095     1090       -5     
Files with missing lines Coverage Δ
cryptography/src/bls12381/batch_tle.rs 99.10% <99.10%> (ø)
cryptography/src/bls12381/primitives/group.rs 93.25% <28.88%> (-2.42%) ⬇️

... and 15 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3b5fe2c...0fa8d5e. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

for bit in (0..=bit_start).rev() {
if started {
// SAFETY: blst_fp12_cyclotomic_sqr supports in-place operation.
unsafe { blst_fp12_cyclotomic_sqr(&mut acc, &acc) };
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the using both the mutable and the immutable reference here and in L493 violates rust's aliasing rules since there can only be no other live references when there is a mutable one: https://doc.rust-lang.org/reference/behavior-considered-undefined.html#r-undefined.alias
The other areas in the codebase correctly handle this by using a raw pointer instead which is fine because raw pointers carry no guarantees and LLVM won't emit noalias for them

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants