diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/DLEQProof.java b/src/main/java/com/sparrowwallet/drongo/crypto/DLEQProof.java new file mode 100644 index 0000000..7472d36 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/DLEQProof.java @@ -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 = a⋅G and C = a⋅B 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 = a⋅G + ECKey A = G.multiply(a, true); + + // Let C = a⋅B + 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 = k⋅G + ECKey R1 = G.multiply(k, true); + + // Let R2 = k⋅B + 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 + e⋅a) 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 (a⋅B) + * @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 = s⋅G - e⋅A + 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 = s⋅B - e⋅C + 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 = a⋅G + * @param B The public key B + * @param C The shared secret C = a⋅B + * @param R1 The first commitment R1 = k⋅G + * @param R2 The second commitment R2 = k⋅B + * @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); + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/crypto/DLEQProofTest.java b/src/test/java/com/sparrowwallet/drongo/crypto/DLEQProofTest.java new file mode 100644 index 0000000..9b6a355 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/crypto/DLEQProofTest.java @@ -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; + } +}