Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide ASIC resistant PoW scheme for BSQ swaps #5858

Merged
merged 15 commits into from
Dec 7, 2021

Conversation

stejbac
Copy link
Contributor

@stejbac stejbac commented Nov 23, 2021

This PR adds an implementation of the Equihash memory-hard Proof of Work scheme for DoS/spam attack protection, to replace the present simple Hashcash scheme. It also adds a simple versioning system to make it easier to migrate from one algorithm to another in case of failure to provide adequate DoS attach protection, that is, if an attacker spamming the BSQ swap offer book has too much of a speed/efficiency advantage over an honest user, e.g. due to use of FPGAs, GPUs, etc.

The Equihash scheme is asymmetric, meaning that producing a PoW by solving a hashing puzzle is slow and consumes up to MBs of memory, but verification is instant. The puzzle solutions are larger than that of the present Hashcash scheme (where it is just a 64-bit counter/nonce) but still fairly small - 10's of bytes to a few kB in size, depending on the puzzle parameters. So they shouldn't bloat the BSQ swap offer payloads significantly.

--

Note that one of the commits (5da8df2) touches the DAO packages (VoteResultService), although the code change is trivial.

Also, the last commit (e0595aa) makes a breaking change the proto serialisation, by changing the Filter/PoW difficulty field to a linear-scale floating point -- that commit isn't essential to the PR, though it would cause less disruption to include it before the 1.8.0 release.

Remove (possible draft) 'ProofOfWorkService(Test)', which is a near
duplicate of the class 'HashCashService' but is currently unused.
Replace 'BiFunction<T, U, Boolean>' with the primitive specialisation
'BiPredicate<T, U>' in HashCashService & FilterManager.

As part of this, replace similar predicate constructs found elsewhere.
NOTE: This touches the DAO packages (trivially @ VoteResultService).
Implement the Equihash (https://eprint.iacr.org/2015/946.pdf) algorithm
for solving/verifying memory-hard client-puzzles/proof-of-work problems
for ASIC-resistant DoS attack protection. The scheme is asymmetric, so
that even though solving a puzzle is slow and memory-intensive, needing
100's of kB to MB's of memory, the solution verification is instant.

Instead of a single 64-bit counter/nonce, as in the case of Hashcash,
Equihash solutions are larger objects ranging from 10's of bytes to a
few kB, depending on the puzzle parameters used. These need to be
stored in entirety, in the proof-of-work field of each offer payload.

Include logic for fine-grained difficulty control in Equihash with a
double-precision floating point number. This is based on lexicographic
comparison with a target hash, like in Bitcoin, instead of just
counting the number of leading zeros of a hash.

The code is unused at present. Also add some simple unit tests.
Provide a (vastly cut down) drop-in replacement for the Guava multimap
instance 'indexMultimap', of type 'ListMultimap<Integer, Integer>', used
to map table row indices to block values, to detect collisions at a
given block position (that is, in a given table column).

The replacement stores (multi-)mappings from ints to ints in a flat int-
array, only spilling over to a ListMultimap if there are more than 4
values added for a given key. This vastly reduces the amount of boxing
and memory usage when running 'Equihash::findCollisions' to build up the
next table as part of Wagner's algorithm.
Manually iterate over colliding table rows using a while- loop and a
custom 'PrimitiveIterator.OfInt' implementation, instead of a foreach
lambda called on an IntStream, in 'Equihash::findCollisions'. Profiling
shows that this results in a slight speedup.
Run the initial XorTable fillup in 'Equihash::computeAllHashes' in
parallel, using a parallel stream, to get an easy speed up. (The solver
spends about half its time computing BLAKE2b hashes before iteratively
building tables of partial collisions using 'Equihash::findCollisions'.)

As part of this, replace the use of 'java.nio.ByteBuffer' array wrapping
in 'Utilities::(bytesToIntsBE|intsToBytesBE)' with manual for-loops, as
profiling reveals an unexpected bottleneck in the former when used in a
multithreaded setting. (Lock contention somewhere in unsafe code?)
Add a numeric version field to the 'ProofOfWork' protobuf object, along
with a list of allowed version numbers, 'enabled_pow_versions', to the
filter. The versions are taken to be in order of preference from most to
least preferred when creating a PoW, with an empty list signifying use
of the default algorithm only (that is, version 0: Hashcash).

An explicit list is used instead of an upper & lower version bound, in
case a new PoW algorithm (or changed algorithm params) turns out to
provide worse resistance than an earlier version.

(The fields are unused for now, to be enabled in a later commit.)
Provide a utility method, 'Equihash::adjustDifficulty', to linearise and
normalise the expected time taken to solve a puzzle, as a function of
the provided difficulty, by taking into account the fact that there
could be 0, 1, 2 or more puzzle solutions for any given nonce. (Wagner's
algorithm is supposed to give 2 solutions on average, but the observed
number is fewer, possibly due to duplicate removal.) For tractability,
assume that the solution count has a Poisson distribution, which seems
to have good agreement with the tests.

Also add some (disabled) benchmarks to EquihashTest. These reveal an
Equihash-90-5 solution time of ~146ms per puzzle per unit difficulty on
a Core i3 laptop, with a verification time of ~50 microseconds.
Add an abstract base class, 'ProofOfWorkService', for the existing PoW
implementation 'HashCashService' and a new 'EquihashProofOfWorkService'
PoW implementation based on Equihash-90-5 (which has 72 byte solutions &
5-10 MB peak memory usage). Since the current 'ProofOfWork' protobuf
object only provides a 64-bit counter field to hold the puzzle solution
(as that is all Hashcash requires), repurpose the 'payload' field to
hold the Equihash puzzle solution bytes, with the 'challenge' field
equal to the puzzle seed: the SHA256 hash of the offerId & makerAddress.

Use a difficulty scale factor of 3e-5 (derived from benchmarking) to try
to make the average Hashcash & Equihash puzzle solution times roughly
equal for any given log-difficulty/numLeadingZeros integer chosen in the
filter.

NOTE: An empty enabled-version-list in the filter defaults to Hashcash
(= version 0) only. The new Equihash-90-5 PoW scheme is version 1.
Change the type of the 'difficulty' field in the Filter & ProofOfWork
proto objects from int32/bytes to double and make it use a linear scale,
in place of the original logarithmic scale which counts the (effective)
number of required zeros.

This allows fine-grained difficulty control for Equihash, though for
Hashcash it simply rounds up to the nearest power of 2 internally.

NOTE: This is a breaking change to PoW & filter serialisation (unlike
the earlier PR commits), as the proto field version nums aren't updated.
@stejbac stejbac marked this pull request as ready for review November 26, 2021 04:38
@stejbac stejbac changed the title [WIP] Provide ASIC resistant PoW scheme for BSQ swaps Provide ASIC resistant PoW scheme for BSQ swaps Nov 26, 2021
@chimp1984
Copy link
Contributor

@stejbac
I think the predicates in FilterManager and HashCashService are just complicating and bloating the APIs. The intention was that they might be useful for testing but at the end they are unnecessary. I would prefer to inline them.
I created 2 commits which I will post here... Feel free to add them if you agree to the change. I think its easier that way as that I make a PR to you repo (can do that as well if you prefer).

@chimp1984
Copy link
Contributor

chimp1984 commented Nov 26, 2021

Index: common/src/main/java/bisq/common/crypto/ProofOfWorkService.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java b/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java
--- a/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java	(revision e0595aa284ce18ab7602f1126d23014da336ef69)
+++ b/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java	(revision e0e70bd804fec36844987bc1a29051a24e3cbdc4)
@@ -19,9 +19,9 @@
 
 import com.google.common.base.Preconditions;
 
+import java.util.Arrays;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
-import java.util.function.BiPredicate;
 
 import lombok.Getter;
 
@@ -58,15 +58,13 @@
     public boolean verify(ProofOfWork proofOfWork,
                           String itemId,
                           String ownerId,
-                          double controlDifficulty,
-                          BiPredicate<byte[], byte[]> challengeValidation,
-                          BiPredicate<Double, Double> difficultyValidation) {
+                          double controlDifficulty) {
 
         Preconditions.checkArgument(proofOfWork.getVersion() == version);
 
         byte[] controlChallenge = getChallenge(itemId, ownerId);
-        return challengeValidation.test(proofOfWork.getChallenge(), controlChallenge) &&
-                difficultyValidation.test(proofOfWork.getDifficulty(), controlDifficulty) &&
+        return Arrays.equals(proofOfWork.getChallenge(), controlChallenge) &&
+                proofOfWork.getDifficulty() >= controlDifficulty &&
                 verify(proofOfWork);
     }
 }
Index: core/src/main/java/bisq/core/filter/FilterManager.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/core/src/main/java/bisq/core/filter/FilterManager.java b/core/src/main/java/bisq/core/filter/FilterManager.java
--- a/core/src/main/java/bisq/core/filter/FilterManager.java	(revision e0595aa284ce18ab7602f1126d23014da336ef69)
+++ b/core/src/main/java/bisq/core/filter/FilterManager.java	(revision e0e70bd804fec36844987bc1a29051a24e3cbdc4)
@@ -37,9 +37,9 @@
 import bisq.common.app.Version;
 import bisq.common.config.Config;
 import bisq.common.config.ConfigFileEditor;
-import bisq.common.crypto.ProofOfWorkService;
 import bisq.common.crypto.KeyRing;
 import bisq.common.crypto.ProofOfWork;
+import bisq.common.crypto.ProofOfWorkService;
 
 import org.bitcoinj.core.ECKey;
 import org.bitcoinj.core.Sha256Hash;
@@ -58,7 +58,6 @@
 
 import java.math.BigInteger;
 
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
@@ -66,7 +65,6 @@
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.function.BiPredicate;
 import java.util.function.Consumer;
 
 import java.lang.reflect.Method;
@@ -88,12 +86,6 @@
     private static final String BANNED_SEED_NODES = "bannedSeedNodes";
     private static final String BANNED_BTC_NODES = "bannedBtcNodes";
 
-    private final BiPredicate<byte[], byte[]> challengeValidation = Arrays::equals;
-    // We only require a new pow if difficulty has increased
-    private final BiPredicate<Double, Double> difficultyValidation =
-            (value, controlValue) -> value - controlValue >= 0;
-
-
     ///////////////////////////////////////////////////////////////////////////////////////////
     // Listener
     ///////////////////////////////////////////////////////////////////////////////////////////
@@ -501,9 +493,7 @@
         }
         return service.get().verify(offer.getBsqSwapOfferPayload().get().getProofOfWork(),
                 offer.getId(), offer.getOwnerNodeAddress().toString(),
-                filter.getPowDifficulty(),
-                challengeValidation,
-                difficultyValidation);
+                filter.getPowDifficulty());
     }
 
     public List<Integer> getEnabledPowVersions() {

@chimp1984
Copy link
Contributor

Index: common/src/main/java/bisq/common/crypto/HashCashService.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/common/src/main/java/bisq/common/crypto/HashCashService.java b/common/src/main/java/bisq/common/crypto/HashCashService.java
--- a/common/src/main/java/bisq/common/crypto/HashCashService.java	(revision e0e70bd804fec36844987bc1a29051a24e3cbdc4)
+++ b/common/src/main/java/bisq/common/crypto/HashCashService.java	(revision d54f1aafc04601ec350b083d06b24b4774a08d6b)
@@ -22,24 +22,18 @@
 
 import java.nio.charset.StandardCharsets;
 
-import java.util.Arrays;
 import java.util.concurrent.CompletableFuture;
-import java.util.function.BiPredicate;
 
 import lombok.extern.slf4j.Slf4j;
 
 /**
  * HashCash implementation for proof of work
- * It doubles required work by difficulty increase (adding one leading zero).
+ * It doubles required work by log2Difficulty increase (adding one leading zero).
  *
  * See https://www.hashcash.org/papers/hashcash.pdf
  */
 @Slf4j
 public class HashCashService extends ProofOfWorkService {
-    // Default validations. Custom implementations might use tolerance.
-    private static final BiPredicate<byte[], byte[]> isChallengeValid = Arrays::equals;
-    private static final BiPredicate<Integer, Integer> isDifficultyValid = Integer::equals;
-
     HashCashService() {
         super(0);
     }
@@ -50,105 +44,35 @@
         return mint(payload, challenge, difficulty);
     }
 
-    @Override
-    public byte[] getChallenge(String itemId, String ownerId) {
-        return getBytes(itemId + ownerId);
-    }
-
-    static CompletableFuture<ProofOfWork> mint(byte[] payload,
+    public CompletableFuture<ProofOfWork> mint(byte[] payload,
                                                byte[] challenge,
                                                double difficulty) {
-        return HashCashService.mint(payload,
-                challenge,
-                difficulty,
-                HashCashService::testDifficulty);
-    }
-
-    @Override
-    boolean verify(ProofOfWork proofOfWork) {
-        return verify(proofOfWork,
-                proofOfWork.getChallenge(),
-                toNumLeadingZeros(proofOfWork.getDifficulty()));
-    }
-
-    static boolean verify(ProofOfWork proofOfWork,
-                          byte[] controlChallenge,
-                          int controlLog2Difficulty) {
-        return HashCashService.verify(proofOfWork,
-                controlChallenge,
-                controlLog2Difficulty,
-                HashCashService::testDifficulty);
-    }
-
-    static boolean verify(ProofOfWork proofOfWork,
-                          byte[] controlChallenge,
-                          int controlLog2Difficulty,
-                          BiPredicate<byte[], byte[]> challengeValidation,
-                          BiPredicate<Integer, Integer> difficultyValidation) {
-        return HashCashService.verify(proofOfWork,
-                controlChallenge,
-                controlLog2Difficulty,
-                challengeValidation,
-                difficultyValidation,
-                HashCashService::testDifficulty);
-    }
-
-    private static boolean testDifficulty(byte[] result, int log2Difficulty) {
-        return HashCashService.numberOfLeadingZeros(result) > log2Difficulty;
-    }
-
-
-    ///////////////////////////////////////////////////////////////////////////////////////////
-    // Generic
-    ///////////////////////////////////////////////////////////////////////////////////////////
-
-    static CompletableFuture<ProofOfWork> mint(byte[] payload,
-                                               byte[] challenge,
-                                               double difficulty,
-                                               BiPredicate<byte[], Integer> testDifficulty) {
         return CompletableFuture.supplyAsync(() -> {
             long ts = System.currentTimeMillis();
             int log2Difficulty = toNumLeadingZeros(difficulty);
-            byte[] result;
+            byte[] hash;
             long counter = 0;
             do {
-                result = toSha256Hash(payload, challenge, ++counter);
+                hash = toSha256Hash(payload, challenge, ++counter);
             }
-            while (!testDifficulty.test(result, log2Difficulty));
+            while (numberOfLeadingZeros(hash) <= log2Difficulty);
             ProofOfWork proofOfWork = new ProofOfWork(payload, counter, challenge, difficulty, System.currentTimeMillis() - ts, 0);
             log.info("Completed minting proofOfWork: {}", proofOfWork);
             return proofOfWork;
         });
     }
 
-    static boolean verify(ProofOfWork proofOfWork,
-                          byte[] controlChallenge,
-                          int controlLog2Difficulty,
-                          BiPredicate<byte[], Integer> testDifficulty) {
-        return verify(proofOfWork,
-                controlChallenge,
-                controlLog2Difficulty,
-                HashCashService.isChallengeValid,
-                HashCashService.isDifficultyValid,
-                testDifficulty);
-    }
-
-    static boolean verify(ProofOfWork proofOfWork,
-                          byte[] controlChallenge,
-                          int controlLog2Difficulty,
-                          BiPredicate<byte[], byte[]> challengeValidation,
-                          BiPredicate<Integer, Integer> difficultyValidation,
-                          BiPredicate<byte[], Integer> testDifficulty) {
-        return challengeValidation.test(proofOfWork.getChallenge(), controlChallenge) &&
-                difficultyValidation.test(toNumLeadingZeros(proofOfWork.getDifficulty()), controlLog2Difficulty) &&
-                verify(proofOfWork, testDifficulty);
-    }
-
-    private static boolean verify(ProofOfWork proofOfWork, BiPredicate<byte[], Integer> testDifficulty) {
-        byte[] hash = HashCashService.toSha256Hash(proofOfWork.getPayload(),
+    @Override
+    boolean verify(ProofOfWork proofOfWork) {
+        byte[] hash = toSha256Hash(proofOfWork.getPayload(),
                 proofOfWork.getChallenge(),
                 proofOfWork.getCounter());
-        return testDifficulty.test(hash, toNumLeadingZeros(proofOfWork.getDifficulty()));
+        return numberOfLeadingZeros(hash) > toNumLeadingZeros(proofOfWork.getDifficulty());
+    }
+
+    @Override
+    public byte[] getChallenge(String itemId, String ownerId) {
+        return getBytes(itemId + ownerId);
     }
 
 
Index: common/src/test/java/bisq/common/crypto/HashCashServiceTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java b/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java
--- a/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java	(revision e0e70bd804fec36844987bc1a29051a24e3cbdc4)
+++ b/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java	(revision d54f1aafc04601ec350b083d06b24b4774a08d6b)
@@ -77,7 +77,7 @@
         List<ProofOfWork> tokens = new ArrayList<>();
         for (int i = 0; i < numTokens; i++) {
             byte[] challenge = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8);
-            tokens.add(HashCashService.mint(payload, challenge, difficulty).get());
+            tokens.add(new HashCashService().mint(payload, challenge, difficulty).get());
         }
         double size = tokens.size();
         long ts2 = System.currentTimeMillis();

@chimp1984
Copy link
Contributor

@stejbac
As far as I understand (and tested) the behaviour in case of 2 pow version (e.g. [0,1] or [1,0] set as enabled versions) is that the first is the preferred one and both are valid. So if I had an offer with hashcash pow and we add Equihash as first item I dont need to recreate the pow. But for a new offer Equihash will be used. If we switch from [0] to [1] then all my offers need to recreate the pow.
If difficulty from filter decreases there is no need to recreate the pow of an existing offer, only for increases pow need to be redone.

The difficulty value to be set is 2 pow x, e.g. 2 pow 18 = 262144 means 18 leading zeros for hashcash. It is roughly the number if iterations for hashcash. Values in the range of 300 000-800 000 produce on my machine a pow duration of about 0.5-4 sec.

Could we add some documentation to the Filter so that the Filter maintainer finds quickly some guidance when he sets or changes the values?

Remove all the 'challengeValidation', 'difficultyValidation' and
'testDifficulty' BiPredicate method params from 'HashCashService' &
'ProofOfWorkService', to simplify the API. These were originally
included to aid testing, but turned out to be unnecessary.

Patches committed on behalf of @chimp1984.
@stejbac
Copy link
Contributor Author

stejbac commented Nov 27, 2021

@chimp1984 I've just pushed those two patches now (as a single commit).

Yes, the list of enabled version numbers is in order of preference, so that the first is preferred when creating PoWs and they are both allowed when verifying PoWs. Adding Equihash to the list (in any order) won't cause Hashcash PoWs to be recreated, unless Hashcash is also removed from the list.

Generation times for Hashcash & Equihash are intended to be roughly the same for any given difficulty (at least when it's a power-of-two), with a difficulty of 2 pow n meaning the difficulty of finding > n leading zeros for Hashcash (or equivalently finding a hash with exactly n leading zeros), so 2 * (2 pow n) Hashcash iterations on average, i.e. 524288 average iterations for a difficulty of 2 pow 18 = 262144.

I can attempt to update the BSQ Swaps wiki, as information there about the difficulty would be out of date with this PR.

Fix a trivial bug in the iterator returned by 'IntListMultimap::get',
caused by mistaken use of the iterator index in place of the key when
doing lookups into the overspill map. This was causing puzzle solutions
to be invalid about 3% of the time, as well as substantially reducing
the average number of solutions found per nonce.

As the fix increases the mean solution count per nonce to the correct
value of 2.0 predicted by the paper (regardless of puzzle params k & n),
inline the affected constants to simplify 'Equihash::adjustDifficulty'.
@stejbac
Copy link
Contributor Author

stejbac commented Nov 30, 2021

I just discovered a trivial bug in the iterator returned by Equihash.IntListMultimap::get, which was causing puzzle solutions (and hence Equihash proofs-of-work) to be invalid about 3% of the time. I've committed a fix (0ee6175), pushed above.

proto/src/main/proto/pb.proto Show resolved Hide resolved
proto/src/main/proto/pb.proto Outdated Show resolved Hide resolved
core/src/main/java/bisq/core/filter/FilterManager.java Outdated Show resolved Hide resolved
proto/src/main/proto/pb.proto Show resolved Hide resolved
@chimp1984
Copy link
Contributor

@stejbac Is there a commit outstanding or is the PR ready for merge?

1. Reorder the PoW fields in the 'Filter' proto by field index, instead
 of contextually.
2. Deduplicate expression for 'pow' & replace if-block with boolean op
 to simplify 'FilterManager::isProofOfWorkValid'.
3. Avoid slightly confusing use of null char as a separator to prevent
 hashing collisions in 'EquihashProofOfWorkService::getChallenge'. Use
 comma separator and escape the 'itemId' & 'ownerId' arguments instead.

(based on PR bisq-network#5858 review comments)
Avoid repurposing the 'ProofOfWork.payload' field for Equihash puzzle
solutions, as that may be of later use in interactive PoW schemes such
as P2P network DoS protection (where the challenge may be a random nonce
instead of derived from the offer ID). Instead, make the payload the
UTF-8 bytes of the offer ID, just as with Hashcash.

Also, make the puzzle seed the SHA-256 hash of the payload concatenated
with the challenge, instead of just the 256-bit challenge on its own, so
that the PoW is tied to a particular payload and cannot be reused for
other payloads in the case of future randomly chosen challenges.
Copy link
Contributor

@chimp1984 chimp1984 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK

@ripcurlx ripcurlx added this to the v1.8.0 milestone Dec 7, 2021
Copy link
Contributor

@ripcurlx ripcurlx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK - based on #5858 (review)

@ripcurlx ripcurlx merged commit 0a31bf8 into bisq-network:master Dec 7, 2021
ripcurlx pushed a commit that referenced this pull request Dec 7, 2021
1. Reorder the PoW fields in the 'Filter' proto by field index, instead
 of contextually.
2. Deduplicate expression for 'pow' & replace if-block with boolean op
 to simplify 'FilterManager::isProofOfWorkValid'.
3. Avoid slightly confusing use of null char as a separator to prevent
 hashing collisions in 'EquihashProofOfWorkService::getChallenge'. Use
 comma separator and escape the 'itemId' & 'ownerId' arguments instead.

(based on PR #5858 review comments)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants