The 3rd ZK HACK IV puzzle puzzle is introduced as follows:
Bob designed a new one time scheme, that's based on the tried and true method of encrypt + sign. He combined ElGamal encryption with BLS signatures in a clever way, such that you use pairings to verify the encrypted message was not tampered with. Alice, then, figured out a way to reveal the plaintexts...
Let's dive in!
Assume we are given a group
-
Key generation: Each party samples a secret key
$sk \in \mathbb{F}$ and publishes the public key$PK := sk \times G \in \mathbb{G}$ . -
Encryption: Given a message
$M \in \mathbb{M} \subset \mathbb{G}$ and the receiver's public key$PK_r \in \mathbb{G}$ , we sample a random$x \in \mathbb{F}$ and output:$C_1 := x \times G \in \mathbb{G}$ $C_2 := x \times PK_r + M \in \mathbb{G}$
The key insight is that the term
Therefore, the reciever can recover the message as
Let's call
ElGamal encryption is semantically secure under the Decisional Diffie-Hellman (DDH) Assumption.
Informally, semantic security means that even if an eavesdropper can guess the message, the ciphertext does not help him or her to decide whether this message was encrypted.
The DDH assumption states that given a tuple
Let's look into why the scheme is broken if the DDH assumpion does not hold.
For simplicity, let's a assume that the message space
- Compute a candidate shared secret
$SEC := C_2 - M$ . If$M$ was indeed the message, this value would be equal to$(x + sk_r) \times G$ ; otherwise, it will be equal to some other value. - Test whether
$(PK_r, C_1, SEC)$ is a a DDH triplet. If yes,$M$ is the encrypted message!
A pairing is a function
Now, if
If
BLS signatures require two groups
The signature scheme works as follows:
- The signer samples a secret key
$sk \in \mathbb{F}$ and publishes the public key$PK := sk \times G_1 \in \mathbb{G}$ - Given a message
$m \in \mathbb{M}_s$ , the signature is computed as$SIG := sk \times h(m) \in \mathbb{G}_2$ - The verifier can check the signature by verifying that
$e(PK, h(m)) = e(G_1, SIG)$
Bob wants to combine ElGamal encryption with BLS signatures, using the signature scheme to sign the ElGamal cyphertext.
Now, notice that if we're working over pairing friendly groups
- Each party generates a secret key
$sk \in \mathbb{F}$ and public key$PK = sk \times G_1 \in \mathbb{G}_1$ . This key pair will be used in both the encryption and signature scheme! - The message
$M \in \mathbb{M} \subset \mathbb{G}_1$ is then ElGamal encrypted to$C = (C_1, C_2)$ using the sender's secret key as randomness:$C_1 := sk_s \times G = PK_s \in \mathbb{G}_1$ $C_2 := sk_s \times PK_r + M \in \mathbb{G}_1$
- The BLS signing algorithm is applied to the cyphertext (i.e. $\mathbb{M}_s = (\mathbb{G}_1, \mathbb{G}_1)$):
$SIG := sk_s \times h(C) \in \mathbb{G}_2$
Now, implemented like this, this let's us break the DDH assumption!
Suppose we have guessed the message and computed our candidate shared secret
Note: Another problem with this scheme is that the randomness used in the ElGamal encryption is always the same. This means that if two messages
In code, the implementation of Bob's scheme looks like this:
impl Sender {
pub fn send(&self, m: Message, r: &Receiver) -> ElGamal {
let c_2: G1Affine = (r.pk.mul(&self.sk) + m.0).into_affine();
ElGamal(self.pk, c_2)
}
pub fn authenticate(&self, c: &ElGamal) -> G2Affine {
let hash_c = c.hash_to_curve();
hash_c.mul(&self.sk).into_affine()
}
}
The main()
function loads an encrypted message into the Blob
data structure:
#[derive(CanonicalSerialize, CanonicalDeserialize)]
pub struct Blob {
pub sender_pk: G1Affine,
pub c: ElGamal,
pub s: G2Affine,
pub rec_pk: G1Affine,
}
pub fn main() {
welcome();
puzzle(PUZZLE_DESCRIPTION);
let messages = generate_message_space();
let mut file = File::open("blob.bin").unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
let blob = Blob::deserialize_uncompressed(data.as_slice()).unwrap();
// ensure that blob is correct
assert!(Auditor::check_auth(blob.sender_pk, &blob.c, blob.s));
// Exploit goes here!
}
The Message space generate_message_space()
) only consists of 10 possible messages. That means we can just try all of them and test them one-by-one!
for (i, m) in messages.iter().enumerate() {
// `blob.c.1` was computed as `(r.pk.mul(&self.sk) + m.0).into_affine()`, so if m
// was the message, the following recovers the shared secret
let candidate_shared_secret: G1Projective = blob.c.1 - m.0;
// Compute hash(C)
let hashed_ciphertext: G2Affine = blob.c.hash_to_curve();
// If m is the message, this will compute e((sk_s + sk_r) * G_1, hash(C))
let lhs = Bls12_381::pairing(candidate_shared_secret, hashed_ciphertext);
// This computes e(PK_s, SIG)
let rhs = Bls12_381::pairing(blob.rec_pk, blob.s);
// The two sides are equal iff and only if m is the message!
if lhs == rhs {
println!("Found the message index: {i}");
}
}
And, indeed, the check passes for one of the messages:
Found the message index: 3
To fix Bob's scheme, I would do two things:
- Sample a random value
$x$ to compute the secret key in the ElGamal encryption. This would prevent this specific attack if the attacker has neither$sk_r \times P \in \mathbb{G}_2$ or$x \times P \in \mathbb{G}_2$ (for any point$P \in \mathbb{G}_2$ ). As mentioned above, it would also prevent the two-time pad attack. - Use different keys for the encryption and signature scheme. Re-using keys has the risk of the eavesdropper obtaining
$sk_r \times P \in \mathbb{G}_2$ , for example if the receiver ever signs a message!