- Author: Nicolas IOOSS
- Date: 2024-01-24
- Puzzle: https://zkhack.dev/zkhackIV/puzzleF2.html, https://github.com/ZK-Hack/puzzle-supervillain
Bob has been designing a new optimized signature scheme for his L1 based on BLS signatures.
Specifically, he wanted to be able to use the most efficient form of BLS signature aggregation,
where you just add the signatures together rather than having to delinearize them. In order to do
that, he designed a proof-of-possession scheme based on the B-KEA assumption he found in the the
Sapling security analysis paper by Mary Maller [1]. Based the reasoning in the Power of
Proofs-of-Possession paper [2], he concluded that his scheme would be secure. After he deployed the
protocol, he found it was attacked and there was a malicious block entered the system, fooling all
the light nodes...
[1] https://github.com/zcash/sapling-security-analysis/blob/master/MaryMallerUpdated.pdf
[2] https://rist.tech.cornell.edu/papers/pkreg.pdf
This subject involves many notions which could be hard to understand: BLS signatures, B-KEA assumption... Let's start from the Rust code to understand what Bob's signature scheme does.
Bob's signature scheme was implemented in a Rust containing a single source file, src/main.rs
.
Function main
starts by loading something from a file (line 71):
let public_keys: Vec<(G1Affine, G2Affine)> = from_file("public_keys.bin");
Types G1Affine
and G2Affine
are imported from ark_bls12_381
.
This Rust crate documents:
This library implements the BLS12-381 curve generated by Sean Bowe. The name denotes that it is a Barreto-Lynn-Scott curve of embedding degree 12, defined over a 381-bit (prime) field.
People familiar with ZK Hack already encountered this curve several times in past editions, including in the very first puzzle.
This write-up will focus on the properties of BLS12-381 which are actually used in the puzzle:
- There are actually two curves in BLS12-381, named
$G_1$ and$G_2$ . They are generated by two points,$g_1$ and$g_2$ , which share the same prime order$r$ = 52435875175126190479447740508185965837690552500527637822603658699938581184513. The curves use the same scalar field$\mathbb{F}_r$ . - Points on these curves can use several coordinate systems.
In Rust, the types
G1Affine
,G1Projective
,G2Affine
andG2Projective
represent points on the curves using affine coordinates and projective coordinates. - There exists a bilinear pairing function
$e: G_1 \times G_2 \rightarrow G_T$ which combines points on$G_1$ and$G_2$ into an element of group$G_T$ . In Rust, functionpairing
from traitark_ec::pairing::Pairing
provides this function and functionmulti_pairing
is used to compute several pairings in an efficient way.
As Arkworks is using the additive notation for groups, this write-up will also use them: the product of a point
A signature scheme on curves such as BLS12-381 can be designed with the pubclic key being either in bls_sign
and bls_verify
are doing (lines 48-58):
fn bls_sign(sk: Fr, msg: &[u8]) -> G2Affine {
hasher().hash(msg).unwrap().mul(sk).into_affine()
}
fn bls_verify(pk: G1Affine, sig: G2Affine, msg: &[u8]) {
assert!(Bls12_381::multi_pairing(
&[pk, G1Affine::generator()],
&[hasher().hash(msg).unwrap().neg(), sig]
)
.is_zero());
}
These functions read as follow:
- To sign a message
$msg$ with the secret key$sk \in \mathbb{F}_r$ ,bls_sign
first maps it to a point on curve$G_2$ with a hash-to-point function. The signature is$sig = [sk]Hash(msg) \in G_2$ . - To verify the signature
$sig$ of message$msg$ with the public key$pk \in G_1$ ,bls_verify
verifies that$e(pk, -Hash(msg)) + e(g_1, sig) = 0_{G_T}$ . This is equivalent to verifying that$e(pk, Hash(msg)) = e(g_1, sig)$ .
This works if the secret key
After function main
loads some public keys, it calls pok_verify
on each of them (lines 73-76):
public_keys
.iter()
.enumerate()
.for_each(|(i, (pk, proof))| pok_verify(*pk, i, *proof));
The verification function is (lines 30-36):
fn pok_verify(pk: G1Affine, i: usize, proof: G2Affine) {
assert!(Bls12_381::multi_pairing(
&[pk, G1Affine::generator()],
&[derive_point_for_pok(i).neg(), proof]
)
.is_zero());
}
This function verifies that
-
$R_i \in G_2$ being the result ofderive_point_for_pok(i)
, -
$(Pk_i, \pi) \in G_1 \times G_2$ being Rust variables namedpk
andproof
for each item ofpublic_keys
.
Using the bilinearity property of
Why is this function verifying this?
The subject contains a link to the Sapling security analysis paper by Mary Maller.
This analysis defined the B-KEA assumption (Bilinear Knowledge-of-Exponent Assumptions) in a way which helps understanding the equation.
If
This is a possible way for the party
What is
Function derive_point_for_pok
is defined as (lines 21-24):
fn derive_point_for_pok(i: usize) -> G2Affine {
let rng = &mut ark_std::rand::rngs::StdRng::seed_from_u64(20399u64);
G2Affine::rand(rng).mul(Fr::from(i as u64 + 1)).into()
}
This function generates a deterministic random point Distribution<Affine<P>>::sample
).
This construction ensures that no one knows the discrete logarithm of
To summarize, function main
starts by loading some pairs
After loading the public keys, function main
lets the attacker define their own public key new_key
and proof new_proof
, verifies the proof with function pok_verify
, computes an aggregation public key (named aggregate_key
) and verifies that the attacker can forge a valid signature for an arbitrary message with this public key.
To translate this into mathematics, let's introduce more notations:
-
$n$ is the number of public keys which were loaded. In Rust, this isnew_key_index = public_keys.len()
. -
$Pk_n$ and$\pi_n$ are variablesnew_key
andnew_proof
. -
$Apk$ is the aggregation public key. -
$msg$ is a message to be signed and$sig$ its signature verifiable with$Apk$ .
Function main
:
- calls
pok_verify
to verify$\pi_n = [sk_n(n + 1)]R$ with some$sk_n$ such that$Pk_n = [sk_n]g_1$ , - computes:
$$Apk = \sum_{i=0}^n Pk_i$$ - calls
bls_verify
to verify$sig = [ask]Hash(msg)$ with$ask \in \mathbb{F}_r$ the aggregation secret key such that$Apk = [ask]g_1$ .
Can we compute
Therefore (with Elliptic-Curve Discrete Logarithm property):
Computing this secret key requires the cooperation of all signers, which is unlikely. Nonetheless, the equation of the signature becomes:
This signature is the sum of the signature of the message using each key! It can be computed by making each party sign the message and adding each signature.
This is a BLS signature aggregation scheme: signing a message with the aggregation key should require all parties to perform the signature with their own key
The previous sections presented a BLS signature aggregation scheme. In this scheme, the aggregation public key is the sum of the public keys of earch party. Can the last party cheat by adding a key which would "cancel" the other keys in some way? Yes, by using:
Doing so, the sum of all
To make the attack less visible (and more difficult to detect and defend against), an attacker can choose a value
This secret key cannot be computed because the attacker does not know the other
Nevertheless, their public key is:
As all other public keys are known, this can be computed.
The proof of knowledge of
This seems impossible to compute without knowing
Therefore:
The attacker can compute a proof
To finally prove that the attacker can sign any message, the aggregation secret key
Here is a Rust implementation of this attack.
// Add to the top of src/main.rs: use ark_ff::Field;
// In function main:
/* Enter solution here */
let alpha = Fr::from(0xcafe_u64);
let new_key = public_keys
.iter()
.fold(G1Affine::generator().mul(alpha), |acc, (pk, _)| acc - pk)
.into_affine();
let new_proof = public_keys
.iter()
.enumerate()
.map(|(i, (_, proof))| {
proof
.mul(Fr::from(i as u64 + 1).inverse().unwrap() * Fr::from(new_key_index as u64 + 1))
})
.fold(
derive_point_for_pok(new_key_index).mul(alpha),
|acc, term| acc - term,
)
.into_affine();
let aggregate_signature = bls_sign(alpha, message);
/* End of solution */
pok_verify(new_key, new_key_index, new_proof);
let aggregate_key = public_keys
.iter()
.fold(G1Projective::from(new_key), |acc, (pk, _)| acc + pk)
.into_affine();
bls_verify(aggregate_key, aggregate_signature, message)
The puzzle implements a scheme to aggregate signatures of a message with several public keys. However, the last party is able to craft a rogue key enabling to fully control the aggregation secret key and to sign messages with it as if there were signed by all parties.
This is possible because of a vulnerability in the proof-of-possession scheme.
Each party