add bip374 dleq proof implementation and unit test

This commit is contained in:
Craig Raw 2025-11-13 13:19:54 +02:00
parent 8dd8b9efc0
commit cc1b434f3d
2 changed files with 308 additions and 0 deletions

View File

@ -0,0 +1,218 @@
package com.sparrowwallet.drongo.crypto;
import com.sparrowwallet.drongo.Utils;
import org.bouncycastle.math.ec.ECPoint;
import java.math.BigInteger;
import java.nio.ByteBuffer;
/**
* Implementation of BIP-374 Discrete Log Equality Proofs.
*
* This class provides methods to generate and verify zero-knowledge DLEQ proofs
* that prove knowledge of a scalar a such that A = aG and C = aB without
* revealing the value of a.
*/
public class DLEQProof {
private static final String DLEQ_TAG_AUX = "BIP0374/aux";
private static final String DLEQ_TAG_NONCE = "BIP0374/nonce";
private static final String DLEQ_TAG_CHALLENGE = "BIP0374/challenge";
/**
* Generate a DLEQ proof according to BIP-374.
*
* @param a The secret key (256-bit unsigned integer)
* @param B The public key point on the curve
* @param r Auxiliary random data (32 bytes)
* @param G The generator point (if null, uses secp256k1 generator)
* @param m Optional message (32 bytes or null)
* @return The proof (64 bytes) or null if generation fails
* @throws IllegalArgumentException if r is not 32 bytes or m is not 32 bytes (when provided)
*/
public static byte[] generateProof(BigInteger a, ECKey B, byte[] r, ECKey G, byte[] m) {
if(r.length != 32) {
throw new IllegalArgumentException("Auxiliary random data must be 32 bytes");
}
// Fail if a = 0 or a >= n
if(a.equals(BigInteger.ZERO) || a.compareTo(ECKey.CURVE.getN()) >= 0) {
return null;
}
// Fail if is_infinite(B)
if(B.getPubKeyPoint().isInfinity()) {
return null;
}
if(m != null && m.length != 32) {
throw new IllegalArgumentException("Message must be 32 bytes");
}
// Use secp256k1 generator if G is null
if(G == null) {
G = ECKey.fromPublicOnly(ECKey.CURVE.getG(), true);
}
// Let A = aG
ECKey A = G.multiply(a, true);
// Let C = aB
ECKey C = B.multiply(a, true);
// Let t be the byte-wise xor of bytes(32, a) and hash_BIP0374/aux(r)
byte[] aBytes = Utils.bigIntegerToBytes(a, 32);
byte[] auxHash = Utils.taggedHash(DLEQ_TAG_AUX, r);
byte[] t = Utils.xor(aBytes, auxHash);
// Let m' = m if m is provided, otherwise an empty byte array
byte[] mPrime = (m == null) ? new byte[0] : m;
// Let rand = hash_BIP0374/nonce(t || cbytes(A) || cbytes(C) || m')
ByteBuffer nonceBuffer = ByteBuffer.allocate(t.length + 33 + 33 + mPrime.length);
nonceBuffer.put(t);
nonceBuffer.put(A.getPubKey());
nonceBuffer.put(C.getPubKey());
nonceBuffer.put(mPrime);
byte[] rand = Utils.taggedHash(DLEQ_TAG_NONCE, nonceBuffer.array());
// Let k = int(rand) mod n
BigInteger k = new BigInteger(1, rand).mod(ECKey.CURVE.getN());
// Fail if k = 0
if(k.equals(BigInteger.ZERO)) {
return null;
}
// Let R1 = kG
ECKey R1 = G.multiply(k, true);
// Let R2 = kB
ECKey R2 = B.multiply(k, true);
// Let e = int(hash_BIP0374/challenge(...))
BigInteger e = dleqChallenge(A, B, C, R1, R2, m, G);
// Let s = (k + ea) mod n
BigInteger s = k.add(e.multiply(a)).mod(ECKey.CURVE.getN());
// Let proof = bytes(32, e) || bytes(32, s)
byte[] proof = new byte[64];
byte[] eBytes = Utils.bigIntegerToBytes(e, 32);
byte[] sBytes = Utils.bigIntegerToBytes(s, 32);
System.arraycopy(eBytes, 0, proof, 0, 32);
System.arraycopy(sBytes, 0, proof, 32, 32);
// If VerifyProof fails, abort
if(!verifyProof(A, B, C, proof, G, m)) {
return null;
}
return proof;
}
/**
* Verify a DLEQ proof according to BIP-374.
*
* @param A The public key of the secret key used in proof generation
* @param B The public key used in proof generation
* @param C The result of multiplying the secret and public keys (aB)
* @param proof The proof (64 bytes)
* @param G The generator point (if null, uses secp256k1 generator)
* @param m Optional message (32 bytes or null)
* @return true if the proof is valid, false otherwise
* @throws IllegalArgumentException if m is not 32 bytes (when provided)
*/
public static boolean verifyProof(ECKey A, ECKey B, ECKey C, byte[] proof, ECKey G, byte[] m) {
// Fail if any of is_infinite(A), is_infinite(B), is_infinite(C), is_infinite(G)
if(A.getPubKeyPoint().isInfinity() || B.getPubKeyPoint().isInfinity() ||
C.getPubKeyPoint().isInfinity()) {
return false;
}
if(proof.length != 64) {
return false;
}
if(m != null && m.length != 32) {
throw new IllegalArgumentException("Message must be 32 bytes");
}
// Use secp256k1 generator if G is null
if(G == null) {
G = ECKey.fromPublicOnly(ECKey.CURVE.getG(), true);
}
if(G.getPubKeyPoint().isInfinity()) {
return false;
}
// Let e = int(proof[0:32])
byte[] eBytes = new byte[32];
System.arraycopy(proof, 0, eBytes, 0, 32);
BigInteger e = new BigInteger(1, eBytes);
// Let s = int(proof[32:64]); fail if s >= n
byte[] sBytes = new byte[32];
System.arraycopy(proof, 32, sBytes, 0, 32);
BigInteger s = new BigInteger(1, sBytes);
if(s.compareTo(ECKey.CURVE.getN()) >= 0) {
return false;
}
// Let R1 = sG - eA
ECPoint R1Point = G.getPubKeyPoint().multiply(s).add(A.getPubKeyPoint().multiply(e).negate()).normalize();
// Fail if is_infinite(R1)
if(R1Point.isInfinity()) {
return false;
}
ECKey R1 = ECKey.fromPublicOnly(R1Point, true);
// Let R2 = sB - eC
ECPoint R2Point = B.getPubKeyPoint().multiply(s).add(C.getPubKeyPoint().multiply(e).negate()).normalize();
// Fail if is_infinite(R2)
if(R2Point.isInfinity()) {
return false;
}
ECKey R2 = ECKey.fromPublicOnly(R2Point, true);
// Fail if e int(hash_BIP0374/challenge(...))
BigInteger eExpected = dleqChallenge(A, B, C, R1, R2, m, G);
if(!e.equals(eExpected)) {
return false;
}
return true;
}
/**
* Calculate the DLEQ challenge hash according to BIP-374.
*
* @param A The public key A = aG
* @param B The public key B
* @param C The shared secret C = aB
* @param R1 The first commitment R1 = kG
* @param R2 The second commitment R2 = kB
* @param m Optional message (32 bytes or null)
* @param G The generator point
* @return The challenge value e
*/
private static BigInteger dleqChallenge(ECKey A, ECKey B, ECKey C, ECKey R1, ECKey R2, byte[] m, ECKey G) {
byte[] mPrime = (m == null) ? new byte[0] : m;
ByteBuffer challengeBuffer = ByteBuffer.allocate(33 + 33 + 33 + 33 + 33 + 33 + mPrime.length);
challengeBuffer.put(A.getPubKey());
challengeBuffer.put(B.getPubKey());
challengeBuffer.put(C.getPubKey());
challengeBuffer.put(G.getPubKey());
challengeBuffer.put(R1.getPubKey());
challengeBuffer.put(R2.getPubKey());
challengeBuffer.put(mPrime);
byte[] hash = Utils.taggedHash(DLEQ_TAG_CHALLENGE, challengeBuffer.array());
return new BigInteger(1, hash);
}
}

View File

@ -0,0 +1,90 @@
package com.sparrowwallet.drongo.crypto;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.Random;
public class DLEQProofTest {
@Test
public void testDleq() {
// Use a fixed seed for reproducibility (similar to reference implementation)
long seed = System.currentTimeMillis();
Random random = new Random(seed);
SecureRandom secureRandom = new SecureRandom();
secureRandom.setSeed(seed);
for(int i = 0; i < 10; i++) {
// Generate random keypairs for both parties
BigInteger a = generateRandomPrivateKey(random);
ECKey A = ECKey.fromPrivate(a, true);
BigInteger b = generateRandomPrivateKey(random);
ECKey B = ECKey.fromPrivate(b, true);
// Create shared secret C = a * B
ECKey C = B.multiply(a, true);
// Create DLEQ proof
byte[] randAux = new byte[32];
random.nextBytes(randAux);
byte[] proof = DLEQProof.generateProof(a, ECKey.fromPublicOnly(B), randAux, null, null);
Assertions.assertNotNull(proof, "Proof generation should succeed");
// Verify DLEQ proof
boolean success = DLEQProof.verifyProof(A, ECKey.fromPublicOnly(B), C, proof, null, null);
Assertions.assertTrue(success, "Proof verification should succeed");
// Flip a random bit in the DLEQ proof and check that verification fails
for(int j = 0; j < 5; j++) {
byte[] proofDamaged = proof.clone();
int byteIndex = random.nextInt(proofDamaged.length);
int bitIndex = random.nextInt(8);
proofDamaged[byteIndex] ^= (1 << bitIndex);
success = DLEQProof.verifyProof(A, ECKey.fromPublicOnly(B), C, proofDamaged, null, null);
Assertions.assertFalse(success, "Damaged proof verification should fail");
}
// Create the same DLEQ proof with a message
byte[] message = new byte[32];
random.nextBytes(message);
proof = DLEQProof.generateProof(a, ECKey.fromPublicOnly(B), randAux, null, message);
Assertions.assertNotNull(proof, "Proof generation with message should succeed");
// Verify DLEQ proof with a message
success = DLEQProof.verifyProof(A, ECKey.fromPublicOnly(B), C, proof, null, message);
Assertions.assertTrue(success, "Proof verification with message should succeed");
// Flip a random bit in the DLEQ proof and check that verification fails
for(int j = 0; j < 5; j++) {
byte[] proofDamaged = proof.clone();
int byteIndex = random.nextInt(proofDamaged.length);
int bitIndex = random.nextInt(8);
proofDamaged[byteIndex] ^= (1 << bitIndex);
success = DLEQProof.verifyProof(A, ECKey.fromPublicOnly(B), C, proofDamaged, null, message);
Assertions.assertFalse(success, "Damaged proof with message verification should fail");
}
}
}
/**
* Generate a random private key in the valid range [1, n-1] where n is the curve order.
*/
private BigInteger generateRandomPrivateKey(Random random) {
BigInteger n = ECKey.CURVE.getN();
BigInteger privateKey;
do {
// Generate a random BigInteger with the same bit length as n
privateKey = new BigInteger(n.bitLength(), random);
} while(privateKey.equals(BigInteger.ZERO) || privateKey.compareTo(n) >= 0);
return privateKey;
}
}