Gathering the Pieces

Recall that the BLS signature of message is the point on defined as where is the secret key. We also established in the previous section that is given by where the 's are the bits of Hence, the signature of can be expressed as In other words, is a formal linear combination (with known coefficients) of secret points As we are given 256 signatures, we should be able to forge a new signature with some linear algebra.

In the following, we let be the message for which we want to forge a signature, and denote the output of the BLAKE2s hash function applied to seen as a vector of bits. Hence, the signature that we want to forge is Similarly, let denote the 256 messages whose signature is given in the puzzle data and let denote the hash of with BLAKE2s. Then the signature of message is

Assume that we can write as a linear combination of vectors i.e., we can find a vector such that Then we can compute as How do we compute ? Letting denote the matrix whose rows are then Eq. (25.4.1) is equivalent to Hence, we need to solve a linear system.

Implementing the Attack

To perform the linear algebra and compute we will use Sage. For this, we first write and as arrays in a file sage/data.sage that we will load in Sage later on. Note that we must write the individual bits of the outputs of BLAKE2s. For this, we use the bytes_to_bits function from the pedersen module which returns a vector of booleans. Trying to write this vector yields an array of true and false strings which is not what we want, so we first need to convert these booleans into bytes.

use bls_pedersen::bls::verify;
use bls_pedersen::data::puzzle_data;
use bls_pedersen::PUZZLE_DESCRIPTION;
use prompt::{puzzle, welcome};

// additional items brought into scope for puzzle solving
use bls_pedersen::hash::hash_to_curve;
use ark_crypto_primitives::crh::pedersen::bytes_to_bits;
use ark_ff::{PrimeField, Zero};
use ark_ec::{AffineCurve, ProjectiveCurve, msm::VariableBaseMSM};
use ark_bls12_381::{Fr, G1Affine, G1Projective};
use std::fs;
use std::fs::File;
use std::io::Write;

fn main() {
    welcome();
    puzzle(PUZZLE_DESCRIPTION);
    let (pk, ms, sigs) = puzzle_data();
    for (m, sig) in ms.iter().zip(sigs.iter()) {
        verify(pk, m, *sig);
    }

    // --snip--

    let m = b"your_username";

    let mut data_file = File::create("sage/data.sage").unwrap();

    // write matrix M to be passed to SAGE
    data_file.write_all("M = [".as_bytes()).unwrap();
    for m in ms {
        let (hash, _) = hash_to_curve(&m);
        let bits = bytes_to_bits(&hash);
        let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
        let line = format!("{:?}, ", bits);
        data_file.write_all(line.as_bytes()).unwrap();
    }
    data_file.write_all("]\n".as_bytes()).unwrap();

    // write vector h to be passed to SAGE
    data_file.write_all("h = ".as_bytes()).unwrap();
    let (hash, _) = hash_to_curve(m);
    let bits = bytes_to_bits(&hash);
    let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
    let line = format!("{:?}", bits);
    data_file.write_all(line.as_bytes()).unwrap();

    // --snip--

    // read solution in coeffs.txt and cast these strings (one per line) into scalar field Fr elements
    let mut coeffs = Vec::new();
    for line in fs::read_to_string("sage/coeffs.txt").unwrap().lines() {
        // let c = Fr::from_le_bytes_mod_order(line.as_bytes()); // doesn't work
        let c: Fr = line.parse().unwrap();
        coeffs.push(c);
    }

    // --snip--

    // compute forgery using affine coordinates
    let mut aff_forge = G1Affine::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        aff_forge = aff_forge + sig.mul(*c).into();
    }

    // compute forgery using projective coordinates
    let mut proj_forge = G1Projective::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        proj_forge += sig.mul(*c);
    }

    // compute forgery using multi-scalar multiplication
    let coeffs: Vec<<Fr as PrimeField>::BigInt> = coeffs.iter().map(|c| (*c).into_repr()).collect();
    let msm_forge = VariableBaseMSM::multi_scalar_mul(&sigs, &coeffs);

    /* Your solution here! */

    verify(pk, m, aff_forge);
    verify(pk, m, proj_forge.into_affine());
    verify(pk, m, msm_forge.into_affine());
    println!("Puzzle solved!");
}

Then we move to Sage. We write a script sage/lin_algebra.sage that reads matrix and vector from file sage/data.sage and then we use the solve_left method to solve the linear system. Note that we must work over the scalar field of BLS12-381 and explicitly declare that and are defined over After that, we write coefficients of solution returned by Sage in file sage/coeffs.txt, one coefficient per line. Note that is invertible and the system has a unique solution (which was not a given). Here is the content of the sage/lin_algebra.sage file (the size of the scalar field of BLS12-381 can be found for example here):

r = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001
Fr = FiniteField(r)
load('sage/data.sage')
M = Matrix(Fr, M)
h = vector(Fr, h)
c = M.solve_left(h)
file = open('sage/coeffs.txt', 'w')
for coeff in c:
    file.write(str(coeff) + '\n')
file.close()

We simply run it with

$ sage ./sage/lin_algebra.sage

It remains to import the coefficients in our Rust function and compute For this, we read file sage/coeffs.txt line by line, obtaining strings that we must convert into elements of the scalar field. Hence, we bring BLS12-381 scalar field into scope with use ark_bls12_381::Fr and look for how to do the conversion. Initially, I tried to use functions from_be_bytes_mod_order and from_le_bytes_mod_order of the PrimeField trait (after converting strings into slices of bytes using as_bytes): the code compiles but the forgery does not verify... A simpler solution uses the fact that the PrimeField trait has supertrait FromStr, meaning one can directly convert strings into the Fr type using the parse method.

use bls_pedersen::bls::verify;
use bls_pedersen::data::puzzle_data;
use bls_pedersen::PUZZLE_DESCRIPTION;
use prompt::{puzzle, welcome};

// additional items brought into scope for puzzle solving
use bls_pedersen::hash::hash_to_curve;
use ark_crypto_primitives::crh::pedersen::bytes_to_bits;
use ark_ff::{PrimeField, Zero};
use ark_ec::{AffineCurve, ProjectiveCurve, msm::VariableBaseMSM};
use ark_bls12_381::{Fr, G1Affine, G1Projective};
use std::fs;
use std::fs::File;
use std::io::Write;

fn main() {
    welcome();
    puzzle(PUZZLE_DESCRIPTION);
    let (pk, ms, sigs) = puzzle_data();
    for (m, sig) in ms.iter().zip(sigs.iter()) {
        verify(pk, m, *sig);
    }

    // --snip--

    let m = b"your_username";

    let mut data_file = File::create("sage/data.sage").unwrap();

    // write matrix M to be passed to SAGE
    data_file.write_all("M = [".as_bytes()).unwrap();
    for m in ms {
        let (hash, _) = hash_to_curve(&m);
        let bits = bytes_to_bits(&hash);
        let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
        let line = format!("{:?}, ", bits);
        data_file.write_all(line.as_bytes()).unwrap();
    }
    data_file.write_all("]\n".as_bytes()).unwrap();

    // write vector h to be passed to SAGE
    data_file.write_all("h = ".as_bytes()).unwrap();
    let (hash, _) = hash_to_curve(m);
    let bits = bytes_to_bits(&hash);
    let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
    let line = format!("{:?}", bits);
    data_file.write_all(line.as_bytes()).unwrap();

    // --snip--

    // read solution in coeffs.txt and cast these strings (one per line) into scalar field Fr elements
    let mut coeffs = Vec::new();
    for line in fs::read_to_string("sage/coeffs.txt").unwrap().lines() {
        // let c = Fr::from_le_bytes_mod_order(line.as_bytes()); // doesn't work
        let c: Fr = line.parse().unwrap();
        coeffs.push(c);
    }

    // --snip--

    // compute forgery using affine coordinates
    let mut aff_forge = G1Affine::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        aff_forge = aff_forge + sig.mul(*c).into();
    }

    // compute forgery using projective coordinates
    let mut proj_forge = G1Projective::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        proj_forge += sig.mul(*c);
    }

    // compute forgery using multi-scalar multiplication
    let coeffs: Vec<<Fr as PrimeField>::BigInt> = coeffs.iter().map(|c| (*c).into_repr()).collect();
    let msm_forge = VariableBaseMSM::multi_scalar_mul(&sigs, &coeffs);

    /* Your solution here! */

    verify(pk, m, aff_forge);
    verify(pk, m, proj_forge.into_affine());
    verify(pk, m, msm_forge.into_affine());
    println!("Puzzle solved!");
}

We are now ready to compute the forgery. Scalar multiplication is performed using the mul method. One can choose to work with affine or projective coordinates. One thing to note is that mul applied to a point in affine coordinates returns a point in projective coordinates. In order to add it to an affine point afterwards, it must be converted back into affine form using method into. There is also the option to use the multi_scalar_mul function (replaced by msm in version 0.4.0 of the library) implementing multi-scalar multiplication directly. However, the scalars in vector coeffs must first be cast into their "big integer" representation using the into_repr method. The code below uses the three possibilities. Note that the += operator does not work for affine representation.

use bls_pedersen::bls::verify;
use bls_pedersen::data::puzzle_data;
use bls_pedersen::PUZZLE_DESCRIPTION;
use prompt::{puzzle, welcome};

// additional items brought into scope for puzzle solving
use bls_pedersen::hash::hash_to_curve;
use ark_crypto_primitives::crh::pedersen::bytes_to_bits;
use ark_ff::{PrimeField, Zero};
use ark_ec::{AffineCurve, ProjectiveCurve, msm::VariableBaseMSM};
use ark_bls12_381::{Fr, G1Affine, G1Projective};
use std::fs;
use std::fs::File;
use std::io::Write;

fn main() {
    welcome();
    puzzle(PUZZLE_DESCRIPTION);
    let (pk, ms, sigs) = puzzle_data();
    for (m, sig) in ms.iter().zip(sigs.iter()) {
        verify(pk, m, *sig);
    }

    // --snip--

    let m = b"your_username";

    let mut data_file = File::create("sage/data.sage").unwrap();

    // write matrix M to be passed to SAGE
    data_file.write_all("M = [".as_bytes()).unwrap();
    for m in ms {
        let (hash, _) = hash_to_curve(&m);
        let bits = bytes_to_bits(&hash);
        let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
        let line = format!("{:?}, ", bits);
        data_file.write_all(line.as_bytes()).unwrap();
    }
    data_file.write_all("]\n".as_bytes()).unwrap();

    // write vector h to be passed to SAGE
    data_file.write_all("h = ".as_bytes()).unwrap();
    let (hash, _) = hash_to_curve(m);
    let bits = bytes_to_bits(&hash);
    let bits: Vec<u8> = bits.into_iter().map(|x| x as u8).collect();
    let line = format!("{:?}", bits);
    data_file.write_all(line.as_bytes()).unwrap();

    // --snip--

    // read solution in coeffs.txt and cast these strings (one per line) into scalar field Fr elements
    let mut coeffs = Vec::new();
    for line in fs::read_to_string("sage/coeffs.txt").unwrap().lines() {
        // let c = Fr::from_le_bytes_mod_order(line.as_bytes()); // doesn't work
        let c: Fr = line.parse().unwrap();
        coeffs.push(c);
    }

    // --snip--

    // compute forgery using affine coordinates
    let mut aff_forge = G1Affine::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        aff_forge = aff_forge + sig.mul(*c).into();
    }

    // compute forgery using projective coordinates
    let mut proj_forge = G1Projective::zero();
    for (c, sig) in coeffs.iter().zip(sigs.iter()) {
        proj_forge += sig.mul(*c);
    }

    // compute forgery using multi-scalar multiplication
    let coeffs: Vec<<Fr as PrimeField>::BigInt> = coeffs.iter().map(|c| (*c).into_repr()).collect();
    let msm_forge = VariableBaseMSM::multi_scalar_mul(&sigs, &coeffs);

    /* Your solution here! */

    verify(pk, m, aff_forge);
    verify(pk, m, proj_forge.into_affine());
    verify(pk, m, msm_forge.into_affine());
    println!("Puzzle solved!");
}

The attack requires to run the Rust binary to write the file sage/data.sage, then the Sage script, and then the Rust binary again to read sage/coeffs.txt. Not great, but I have no idea how to call the Sage script from the Rust main function. Feel free to improve this, for example with a bash script.

Conclusion

The key takeaway of this puzzle is that Pedersen hashing does not behave as a random oracle. Although it is provably collision-resistant assuming the discrete logarithm problem is hard, it has a rich algebraic structure which makes it unsuitable for cases where a hash function behaving as a random oracle is required, such as BLS signatures.

Which hash-to-curve function should be used to make BLS secure then? The easy solution is to hash the message together with a counter into the base field of the elliptic curve until the result is the x-coordinate of a point on the curve.1 The drawback is that it is not possible to implement it in constant time and that security of this construction is not known to hold in the strong sense of being indifferentiable from a random oracle [BCI+10]. For the specific case of BLS12-381, an efficient solution based on isogenies was recently proposed by Wahby and Boneh [WB19]. See also RFC 9380 specifying various hash-to-curve constructions.


1: Note that hashing into the scalar field to get and letting is completely insecure: one single signature on some message reveals which allows to forge a signature on any other message by computing