diff --git a/discovery/build.gradle b/discovery/build.gradle new file mode 100644 index 000000000..d2618488f --- /dev/null +++ b/discovery/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation project(':types') + implementation project(':util') + implementation project(':db:core') + implementation project(':chain') + + implementation 'com.google.guava:guava' + implementation 'io.projectreactor:reactor-core' + implementation 'io.netty:netty-all' + implementation 'org.apache.logging.log4j:log4j-core' + implementation 'org.web3j:core' +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManager.java b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManager.java new file mode 100644 index 000000000..3e0c96823 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManager.java @@ -0,0 +1,35 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.discovery.enr.NodeRecord; + +import java.util.concurrent.CompletableFuture; + +/** + * Discovery Manager, top interface for peer discovery mechanism as described at https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md + */ +public interface DiscoveryManager { + + void start(); + + void stop(); + + /** + * Initiates FINDNODE with node `nodeRecord` + * + * @param nodeRecord Ethereum Node record + * @param distance Distance to search for + * @return Future which is fired when reply is received or fails in timeout/not successful + * handshake/bad message exchange. + */ + CompletableFuture findNodes(NodeRecord nodeRecord, int distance); + + /** + * Initiates PING with node `nodeRecord` + * + * @param nodeRecord Ethereum Node record + * @return Future which is fired when reply is received or fails in timeout/not successful + * handshake/bad message exchange. + */ + CompletableFuture ping(NodeRecord nodeRecord); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManagerImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManagerImpl.java new file mode 100644 index 000000000..293318833 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryManagerImpl.java @@ -0,0 +1,156 @@ +package org.ethereum.beacon.discovery; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.network.DiscoveryClient; +import org.ethereum.beacon.discovery.network.NettyDiscoveryClientImpl; +import org.ethereum.beacon.discovery.network.NettyDiscoveryServer; +import org.ethereum.beacon.discovery.network.NettyDiscoveryServerImpl; +import org.ethereum.beacon.discovery.network.NetworkParcel; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.pipeline.PipelineImpl; +import org.ethereum.beacon.discovery.pipeline.handler.AuthHeaderMessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.BadPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.IncomingDataPacker; +import org.ethereum.beacon.discovery.pipeline.handler.MessageHandler; +import org.ethereum.beacon.discovery.pipeline.handler.MessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NewTaskHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NextTaskHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NodeIdToSession; +import org.ethereum.beacon.discovery.pipeline.handler.NodeSessionRequestHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NotExpectedIncomingPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.OutgoingParcelHandler; +import org.ethereum.beacon.discovery.pipeline.handler.UnknownPacketTagToSender; +import org.ethereum.beacon.discovery.pipeline.handler.UnknownPacketTypeByStatus; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouAttempt; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouSessionResolver; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTable; +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; +import org.ethereum.beacon.schedulers.Scheduler; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.ReplayProcessor; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class DiscoveryManagerImpl implements DiscoveryManager { + private static final Logger logger = LogManager.getLogger(DiscoveryManagerImpl.class); + private final ReplayProcessor outgoingMessages = ReplayProcessor.cacheLast(); + private final FluxSink outgoingSink = outgoingMessages.sink(); + private final NettyDiscoveryServer discoveryServer; + private final Scheduler scheduler; + private final Pipeline incomingPipeline = new PipelineImpl(); + private final Pipeline outgoingPipeline = new PipelineImpl(); + private final NodeRecordFactory nodeRecordFactory; + private DiscoveryClient discoveryClient; + private CountDownLatch clientStarted = new CountDownLatch(1); + + public DiscoveryManagerImpl( + NodeTable nodeTable, + NodeBucketStorage nodeBucketStorage, + NodeRecord homeNode, + BytesValue homeNodePrivateKey, + NodeRecordFactory nodeRecordFactory, + Scheduler serverScheduler, + Scheduler taskScheduler) { + AuthTagRepository authTagRepo = new AuthTagRepository(); + this.scheduler = serverScheduler; + this.nodeRecordFactory = nodeRecordFactory; + this.discoveryServer = + new NettyDiscoveryServerImpl( + ((Bytes4) homeNode.get(EnrField.IP_V4)), (int) homeNode.get(EnrField.UDP_V4)); + discoveryServer.useDatagramChannel( + channel -> { + discoveryClient = new NettyDiscoveryClientImpl(outgoingMessages, channel); + clientStarted.countDown(); + }); + NodeIdToSession nodeIdToSession = + new NodeIdToSession( + homeNode, + homeNodePrivateKey, + nodeBucketStorage, + authTagRepo, + nodeTable, + outgoingPipeline); + incomingPipeline + .addHandler(new IncomingDataPacker()) + .addHandler(new WhoAreYouAttempt(homeNode.getNodeId())) + .addHandler(new WhoAreYouSessionResolver(authTagRepo)) + .addHandler(new UnknownPacketTagToSender(homeNode)) + .addHandler(nodeIdToSession) + .addHandler(new UnknownPacketTypeByStatus()) + .addHandler(new NotExpectedIncomingPacketHandler()) + .addHandler(new WhoAreYouPacketHandler(outgoingPipeline, taskScheduler)) + .addHandler( + new AuthHeaderMessagePacketHandler(outgoingPipeline, taskScheduler, nodeRecordFactory)) + .addHandler(new MessagePacketHandler()) + .addHandler(new MessageHandler(nodeRecordFactory)) + .addHandler(new BadPacketHandler()); + outgoingPipeline + .addHandler(new OutgoingParcelHandler(outgoingSink)) + .addHandler(new NodeSessionRequestHandler()) + .addHandler(nodeIdToSession) + .addHandler(new NewTaskHandler()) + .addHandler(new NextTaskHandler(outgoingPipeline, taskScheduler)); + } + + @Override + public void start() { + incomingPipeline.build(); + outgoingPipeline.build(); + Flux.from(discoveryServer.getIncomingPackets()).subscribe(incomingPipeline::push); + discoveryServer.start(scheduler); + try { + clientStarted.await(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to start client", e); + } + } + + @Override + public void stop() { + discoveryServer.stop(); + } + + private CompletableFuture executeTaskImpl( + NodeRecord nodeRecord, TaskType taskType, TaskOptions taskOptions) { + Envelope envelope = new Envelope(); + envelope.put(Field.NODE, nodeRecord); + CompletableFuture future = new CompletableFuture<>(); + envelope.put(Field.TASK, taskType); + envelope.put(Field.FUTURE, future); + envelope.put(Field.TASK_OPTIONS, taskOptions); + outgoingPipeline.push(envelope); + return future; + } + + @Override + public CompletableFuture findNodes(NodeRecord nodeRecord, int distance) { + return executeTaskImpl(nodeRecord, TaskType.FINDNODE, new TaskOptions(true, distance)); + } + + @Override + public CompletableFuture ping(NodeRecord nodeRecord) { + return executeTaskImpl(nodeRecord, TaskType.PING, new TaskOptions(true)); + } + + @VisibleForTesting + Publisher getOutgoingMessages() { + return outgoingMessages; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryMessageProcessor.java b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryMessageProcessor.java new file mode 100644 index 000000000..c23238b6a --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryMessageProcessor.java @@ -0,0 +1,10 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.discovery.message.DiscoveryMessage; + +/** Handles discovery messages of several types */ +public interface DiscoveryMessageProcessor { + Protocol getSupportedIdentity(); + + void handleMessage(M message, NodeSession session); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryV5MessageProcessor.java b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryV5MessageProcessor.java new file mode 100644 index 000000000..f4d8c0c83 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/DiscoveryV5MessageProcessor.java @@ -0,0 +1,49 @@ +package org.ethereum.beacon.discovery; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.ethereum.beacon.discovery.message.MessageCode; +import org.ethereum.beacon.discovery.message.handler.FindNodeHandler; +import org.ethereum.beacon.discovery.message.handler.MessageHandler; +import org.ethereum.beacon.discovery.message.handler.NodesHandler; +import org.ethereum.beacon.discovery.message.handler.PingHandler; +import org.ethereum.beacon.discovery.message.handler.PongHandler; + +import java.util.HashMap; +import java.util.Map; + +/** + * {@link DiscoveryV5Message} v5 messages processor. Uses several handlers, one fo each type of v5 + * message to handle appropriate message. + */ +public class DiscoveryV5MessageProcessor implements DiscoveryMessageProcessor { + private static final Logger logger = LogManager.getLogger(DiscoveryV5MessageProcessor.class); + private final Map messageHandlers = new HashMap<>(); + private final NodeRecordFactory nodeRecordFactory; + + public DiscoveryV5MessageProcessor(NodeRecordFactory nodeRecordFactory) { + messageHandlers.put(MessageCode.PING, new PingHandler()); + messageHandlers.put(MessageCode.PONG, new PongHandler()); + messageHandlers.put(MessageCode.FINDNODE, new FindNodeHandler()); + messageHandlers.put(MessageCode.NODES, new NodesHandler()); + this.nodeRecordFactory = nodeRecordFactory; + } + + @Override + public Protocol getSupportedIdentity() { + return Protocol.V5; + } + + @Override + public void handleMessage(DiscoveryV5Message message, NodeSession session) { + MessageCode code = message.getCode(); + MessageHandler messageHandler = messageHandlers.get(code); + logger.trace(() -> String.format("Handling message %s in session %s", message, session)); + if (messageHandler == null) { + throw new RuntimeException("Not implemented yet"); + } + messageHandler.handle(message.create(nodeRecordFactory), session); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/Functions.java b/discovery/src/main/java/org/ethereum/beacon/discovery/Functions.java new file mode 100644 index 000000000..f60ee7c46 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/Functions.java @@ -0,0 +1,296 @@ +package org.ethereum.beacon.discovery; + +import com.google.common.base.Objects; +import org.bouncycastle.crypto.Digest; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.Arrays; +import org.ethereum.beacon.crypto.Hashes; +import org.ethereum.beacon.util.Utils; +import org.web3j.crypto.ECDSASignature; +import org.web3j.crypto.ECKeyPair; +import org.web3j.crypto.Hash; +import org.web3j.crypto.Sign; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.Random; + +import static org.ethereum.beacon.util.Utils.extractBytesFromUnsignedBigInt; +import static org.web3j.crypto.Sign.CURVE_PARAMS; + +/** Set of cryptography and utilities functions used in discovery */ +public class Functions { + public static final ECDomainParameters SECP256K1_CURVE = + new ECDomainParameters( + CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), CURVE_PARAMS.getH()); + public static final int PUBKEY_SIZE = 64; + private static final int RECIPIENT_KEY_LENGTH = 16; + private static final int INITIATOR_KEY_LENGTH = 16; + private static final int AUTH_RESP_KEY_LENGTH = 16; + private static final int MS_IN_SECOND = 1000; + + /** SHA2 (SHA256) */ + public static Bytes32 hash(BytesValue value) { + return Hashes.sha256(value); + } + + /** SHA3 (Keccak256) */ + public static Bytes32 hashKeccak(BytesValue value) { + return Bytes32.wrap(Hash.sha3(value.extractArray())); + } + + /** + * Creates a signature of message `x` using the given key. + * + * @param key private key + * @param x message, hashed + * @return ECDSA signature with properties merged together: r || s + */ + public static BytesValue sign(BytesValue key, BytesValue x) { + Sign.SignatureData signatureData = + Sign.signMessage(x.extractArray(), ECKeyPair.create(key.extractArray()), false); + Bytes32 r = Bytes32.wrap(signatureData.getR()); + Bytes32 s = Bytes32.wrap(signatureData.getS()); + return r.concat(s); + } + + /** + * Verifies that signature is made by signer + * + * @param signature Signature, ECDSA + * @param x message, hashed + * @param pubKey Public key of supposed signer, compressed, 33 bytes + * @return whether `signature` reflects message `x` signed with `pubkey` + */ + public static boolean verifyECDSASignature( + BytesValue signature, BytesValue x, BytesValue pubKey) { + assert pubKey.size() == 33; + ECPoint ecPoint = Functions.publicKeyToPoint(pubKey); + BytesValue pubKeyUncompressed = BytesValue.wrap(ecPoint.getEncoded(false)).slice(1); + ECDSASignature ecdsaSignature = + new ECDSASignature( + new BigInteger(1, signature.slice(0, 32).extractArray()), + new BigInteger(1, signature.slice(32).extractArray())); + for (int recId = 0; recId < 4; ++recId) { + BigInteger calculatedPubKey = + Sign.recoverFromSignature(recId, ecdsaSignature, x.extractArray()); + if (calculatedPubKey == null) { + continue; + } + if (Arrays.areEqual( + pubKeyUncompressed.extractArray(), + extractBytesFromUnsignedBigInt(calculatedPubKey, PUBKEY_SIZE))) { + return true; + } + } + return false; + } + + /** + * AES-GCM encryption/authentication with the given `key`, `nonce` and additional authenticated + * data `ad`. Size of `key` is 16 bytes (AES-128), size of `nonce` 12 bytes. + */ + public static BytesValue aesgcm_encrypt( + BytesValue privateKey, BytesValue nonce, BytesValue message, BytesValue aad) { + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(privateKey.extractArray(), "AES"), + new GCMParameterSpec(128, nonce.extractArray())); + cipher.updateAAD(aad.extractArray()); + return BytesValue.wrap(cipher.doFinal(message.extractArray())); + } catch (Exception e) { + throw new RuntimeException("No AES/GCM cipher provider", e); + } + } + + /** + * AES-GCM decryption of `encoded` data with the given `key`, `nonce` and additional authenticated + * data `ad`. Size of `key` is 16 bytes (AES-128), size of `nonce` 12 bytes. + */ + public static BytesValue aesgcm_decrypt( + BytesValue privateKey, BytesValue nonce, BytesValue encoded, BytesValue aad) { + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(privateKey.extractArray(), "AES"), + new GCMParameterSpec(128, nonce.extractArray())); + cipher.updateAAD(aad.extractArray()); + return BytesValue.wrap(cipher.doFinal(encoded.extractArray())); + } catch (Exception e) { + throw new RuntimeException("No AES/GCM cipher provider", e); + } + } + + /** Maps public key to point on {@link #SECP256K1_CURVE} */ + public static ECPoint publicKeyToPoint(BytesValue pkey) { + byte[] destPubPointBytes; + if (pkey.size() == 64) { // uncompressed + destPubPointBytes = new byte[pkey.size() + 1]; + destPubPointBytes[0] = 0x04; // default prefix + System.arraycopy(pkey.extractArray(), 0, destPubPointBytes, 1, pkey.size()); + } else { + destPubPointBytes = pkey.extractArray(); + } + return SECP256K1_CURVE.getCurve().decodePoint(destPubPointBytes); + } + + /** Derives public key in SECP256K1, compressed */ + public static BytesValue derivePublicKeyFromPrivate(BytesValue privateKey) { + ECKeyPair ecKeyPair = ECKeyPair.create(privateKey.extractArray()); + final BytesValue pubKey = + BytesValue.wrap( + Utils.extractBytesFromUnsignedBigInt(ecKeyPair.getPublicKey(), PUBKEY_SIZE)); + ECPoint ecPoint = Functions.publicKeyToPoint(pubKey); + return BytesValue.wrap(ecPoint.getEncoded(true)); + } + + /** Derives key agreement ECDH by multiplying private key by public */ + public static BytesValue deriveECDHKeyAgreement(BytesValue srcPrivKey, BytesValue destPubKey) { + ECPoint pudDestPoint = publicKeyToPoint(destPubKey); + ECPoint mult = pudDestPoint.multiply(new BigInteger(1, srcPrivKey.extractArray())); + return BytesValue.wrap(mult.getEncoded(true)); + } + + /** + * The ephemeral key is used to perform Diffie-Hellman key agreement with B's static public key + * and the session keys are derived from it using the HKDF key derivation function. + * + *

+ * ephemeral-key = random private key + * ephemeral-pubkey = public key corresponding to ephemeral-key + * dest-pubkey = public key of B + * secret = agree(ephemeral-key, dest-pubkey) + * info = "discovery v5 key agreement" || node-id-A || node-id-B + * prk = HKDF-Extract(secret, id-nonce) + * initiator-key, recipient-key, auth-resp-key = HKDF-Expand(prk, info) + */ + public static HKDFKeys hkdf_expand( + BytesValue srcNodeId, + BytesValue destNodeId, + BytesValue srcPrivKey, + BytesValue destPubKey, + BytesValue idNonce) { + BytesValue keyAgreement = deriveECDHKeyAgreement(srcPrivKey, destPubKey); + return hkdf_expand(srcNodeId, destNodeId, keyAgreement, idNonce); + } + + /** + * {@link #hkdf_expand(BytesValue, BytesValue, BytesValue, BytesValue, BytesValue)} but with + * keyAgreement already derived by {@link #deriveECDHKeyAgreement(BytesValue, BytesValue)} + */ + public static HKDFKeys hkdf_expand( + BytesValue srcNodeId, BytesValue destNodeId, BytesValue keyAgreement, BytesValue idNonce) { + try { + BytesValue info = + BytesValue.wrap("discovery v5 key agreement".getBytes()) + .concat(srcNodeId) + .concat(destNodeId); + HKDFParameters hkdfParameters = + new HKDFParameters( + keyAgreement.extractArray(), idNonce.extractArray(), info.extractArray()); + Digest digest = new SHA256Digest(); + HKDFBytesGenerator hkdfBytesGenerator = new HKDFBytesGenerator(digest); + hkdfBytesGenerator.init(hkdfParameters); + // initiator-key || recipient-key || auth-resp-key + byte[] hkdfOutputBytes = + new byte[INITIATOR_KEY_LENGTH + RECIPIENT_KEY_LENGTH + AUTH_RESP_KEY_LENGTH]; + hkdfBytesGenerator.generateBytes( + hkdfOutputBytes, 0, INITIATOR_KEY_LENGTH + RECIPIENT_KEY_LENGTH + AUTH_RESP_KEY_LENGTH); + BytesValue hkdfOutput = BytesValue.wrap(hkdfOutputBytes); + BytesValue initiatorKey = hkdfOutput.slice(0, INITIATOR_KEY_LENGTH); + BytesValue recipientKey = hkdfOutput.slice(INITIATOR_KEY_LENGTH, RECIPIENT_KEY_LENGTH); + BytesValue authRespKey = hkdfOutput.slice(INITIATOR_KEY_LENGTH + RECIPIENT_KEY_LENGTH); + return new HKDFKeys(initiatorKey, recipientKey, authRespKey); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + /** Current time in seconds */ + public static long getTime() { + return System.currentTimeMillis() / MS_IN_SECOND; + } + + /** Random provider */ + public static Random getRandom() { + return new SecureRandom(); + } + + /** + * The 'distance' between two node IDs is the bitwise XOR of the IDs, taken as the number. + * + *

distance(n₁, n₂) = n₁ XOR n₂ + * + *

LogDistance is reverse of length of common prefix in bits (length - number of leftmost zeros + * in XOR) + */ + public static int logDistance(Bytes32 nodeId1, Bytes32 nodeId2) { + BytesValue distance = Bytes32s.xor(nodeId1, nodeId2); + int logDistance = Byte.SIZE * distance.size(); // 256 + final int maxLogDistance = logDistance; + for (int i = 0; i < maxLogDistance; ++i) { + if (distance.getHighBit(i)) { + break; + } else { + logDistance--; + } + } + return logDistance; + } + + /** + * Stores set of keys derived by simple key derivation function (KDF) based on a hash-based + * message authentication code (HMAC) + */ + public static class HKDFKeys { + private final BytesValue initiatorKey; + private final BytesValue recipientKey; + private final BytesValue authResponseKey; + + public HKDFKeys(BytesValue initiatorKey, BytesValue recipientKey, BytesValue authResponseKey) { + this.initiatorKey = initiatorKey; + this.recipientKey = recipientKey; + this.authResponseKey = authResponseKey; + } + + public BytesValue getInitiatorKey() { + return initiatorKey; + } + + public BytesValue getRecipientKey() { + return recipientKey; + } + + public BytesValue getAuthResponseKey() { + return authResponseKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HKDFKeys hkdfKeys = (HKDFKeys) o; + return Objects.equal(initiatorKey, hkdfKeys.initiatorKey) + && Objects.equal(recipientKey, hkdfKeys.recipientKey) + && Objects.equal(authResponseKey, hkdfKeys.authResponseKey); + } + + @Override + public int hashCode() { + return Objects.hashCode(initiatorKey, recipientKey, authResponseKey); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/MessageProcessor.java b/discovery/src/main/java/org/ethereum/beacon/discovery/MessageProcessor.java new file mode 100644 index 000000000..16a7bd3f7 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/MessageProcessor.java @@ -0,0 +1,37 @@ +package org.ethereum.beacon.discovery; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.message.DiscoveryMessage; + +import java.util.HashMap; +import java.util.Map; + +/** + * Highest level processor which knows several processors for different versions of {@link + * DiscoveryMessage}'s. + */ +public class MessageProcessor { + private static final Logger logger = LogManager.getLogger(MessageProcessor.class); + private final Map messageProcessors = new HashMap<>(); + + public MessageProcessor(DiscoveryMessageProcessor... messageProcessors) { + for (int i = 0; i < messageProcessors.length; ++i) { + this.messageProcessors.put(messageProcessors[i].getSupportedIdentity(), messageProcessors[i]); + } + } + + public void handleIncoming(DiscoveryMessage message, NodeSession session) { + Protocol protocol = message.getProtocol(); + DiscoveryMessageProcessor messageHandler = messageProcessors.get(protocol); + if (messageHandler == null) { + String error = + String.format( + "Message %s with identity %s received in session %s is not supported", + message, protocol, session); + logger.error(error); + throw new RuntimeException(error); + } + messageHandler.handleMessage(message, session); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/NodeRecordInfo.java b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeRecordInfo.java new file mode 100644 index 000000000..5ec4fd91a --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeRecordInfo.java @@ -0,0 +1,101 @@ +package org.ethereum.beacon.discovery; + +import com.google.common.base.Objects; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.ArrayList; +import java.util.List; + +/** + * Container for {@link NodeRecord}. Also saves all necessary data about presence of this node and + * last test of its availability + */ +public class NodeRecordInfo { + private final NodeRecord node; + private final Long lastRetry; + private final NodeStatus status; + private final Integer retry; + + public NodeRecordInfo(NodeRecord node, Long lastRetry, NodeStatus status, Integer retry) { + this.node = node; + this.lastRetry = lastRetry; + this.status = status; + this.retry = retry; + } + + public static NodeRecordInfo createDefault(NodeRecord nodeRecord) { + return new NodeRecordInfo(nodeRecord, -1L, NodeStatus.ACTIVE, 0); + } + + public static NodeRecordInfo fromRlpBytes(BytesValue bytes, NodeRecordFactory nodeRecordFactory) { + RlpList internalList = (RlpList) RlpDecoder.decode(bytes.extractArray()).getValues().get(0); + return new NodeRecordInfo( + nodeRecordFactory.fromBytes(((RlpString) internalList.getValues().get(0)).getBytes()), + ((RlpString) internalList.getValues().get(1)).asPositiveBigInteger().longValue(), + NodeStatus.fromNumber(((RlpString) internalList.getValues().get(2)).getBytes()[0]), + ((RlpString) internalList.getValues().get(1)).asPositiveBigInteger().intValue()); + } + + public BytesValue toRlpBytes() { + List values = new ArrayList<>(); + values.add(RlpString.create(getNode().serialize().extractArray())); + values.add(RlpString.create(getLastRetry())); + values.add(RlpString.create(getStatus().byteCode())); + values.add(RlpString.create(getRetry())); + byte[] bytes = RlpEncoder.encode(new RlpList(values)); + return BytesValue.wrap(bytes); + } + + public NodeRecord getNode() { + return (NodeRecord) node; + } + + public Long getLastRetry() { + return lastRetry; + } + + public NodeStatus getStatus() { + return status; + } + + public Integer getRetry() { + return retry; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NodeRecordInfo that = (NodeRecordInfo) o; + return Objects.equal(node, that.node) + && Objects.equal(lastRetry, that.lastRetry) + && status == that.status + && Objects.equal(retry, that.retry); + } + + @Override + public int hashCode() { + return Objects.hashCode(node, lastRetry, status, retry); + } + + @Override + public String toString() { + return "NodeRecordInfo{" + + "node=" + + node + + ", lastRetry=" + + lastRetry + + ", status=" + + status + + ", retry=" + + retry + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/NodeSession.java b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeSession.java new file mode 100644 index 000000000..b1328e165 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeSession.java @@ -0,0 +1,332 @@ +package org.ethereum.beacon.discovery; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.Packet; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfo; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfoFactory; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; +import org.ethereum.beacon.discovery.storage.NodeBucket; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTable; +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; +import org.ethereum.beacon.util.ExpirationScheduler; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.ethereum.beacon.discovery.task.TaskStatus.AWAIT; + +/** + * Stores session status and all keys for discovery message exchange between us, `homeNode` and the + * other `node` + */ +public class NodeSession { + public static final int NONCE_SIZE = 12; + public static final int REQUEST_ID_SIZE = 8; + private static final Logger logger = LogManager.getLogger(NodeSession.class); + private static final int CLEANUP_DELAY_SECONDS = 60; + private final NodeRecord homeNodeRecord; + private final Bytes32 homeNodeId; + private final AuthTagRepository authTagRepo; + private final NodeTable nodeTable; + private final NodeBucketStorage nodeBucketStorage; + private final Consumer outgoing; + private final Random rnd; + private NodeRecord nodeRecord; + private SessionStatus status = SessionStatus.INITIAL; + private Bytes32 idNonce; + private BytesValue initiatorKey; + private BytesValue recipientKey; + private Map requestIdStatuses = new ConcurrentHashMap<>(); + private ExpirationScheduler requestExpirationScheduler = + new ExpirationScheduler<>(CLEANUP_DELAY_SECONDS, TimeUnit.SECONDS); + private CompletableFuture completableFuture = null; + private BytesValue staticNodeKey; + + public NodeSession( + NodeRecord nodeRecord, + NodeRecord homeNodeRecord, + BytesValue staticNodeKey, + NodeTable nodeTable, + NodeBucketStorage nodeBucketStorage, + AuthTagRepository authTagRepo, + Consumer outgoing, + Random rnd) { + this.nodeRecord = nodeRecord; + this.outgoing = outgoing; + this.authTagRepo = authTagRepo; + this.nodeTable = nodeTable; + this.nodeBucketStorage = nodeBucketStorage; + this.homeNodeRecord = homeNodeRecord; + this.staticNodeKey = staticNodeKey; + this.homeNodeId = homeNodeRecord.getNodeId(); + this.rnd = rnd; + } + + public NodeRecord getNodeRecord() { + return nodeRecord; + } + + public synchronized void updateNodeRecord(NodeRecord nodeRecord) { + logger.trace( + () -> + String.format( + "NodeRecord updated from %s to %s in session %s", + this.nodeRecord, nodeRecord, this)); + this.nodeRecord = nodeRecord; + } + + private void completeConnectFuture() { + if (completableFuture != null) { + completableFuture.complete(null); + completableFuture = null; + } + } + + public void sendOutgoing(Packet packet) { + logger.trace(() -> String.format("Sending outgoing packet %s in session %s", packet, this)); + outgoing.accept(packet); + } + + /** + * Creates object with request information: requestId etc, RequestInfo, designed to maintain + * request status and its changes. Also stores info in session repository to track related + * messages. + * + *

The value selected as request ID must allow for concurrent conversations. Using a timestamp + * can result in parallel conversations with the same id, so this should be avoided. Request IDs + * also prevent replay of responses. Using a simple counter would be fine if the implementation + * could ensure that restarts or even re-installs would increment the counter based on previously + * saved state in all circumstances. The easiest to implement is a random number. + * + * @param taskType Type of task, clarifies starting and reply message types + * @param taskOptions Task options + * @param future Future to be fired when task is successfully completed or exceptionally break + * when its failed + * @return info bundle. + */ + public synchronized RequestInfo createNextRequest( + TaskType taskType, TaskOptions taskOptions, CompletableFuture future) { + byte[] requestId = new byte[REQUEST_ID_SIZE]; + rnd.nextBytes(requestId); + BytesValue wrappedId = BytesValue.wrap(requestId); + if (taskOptions.isLivenessUpdate()) { + future.whenComplete( + (aVoid, throwable) -> { + if (throwable == null) { + updateLiveness(); + } + }); + } + RequestInfo requestInfo = RequestInfoFactory.create(taskType, wrappedId, taskOptions, future); + requestIdStatuses.put(wrappedId, requestInfo); + requestExpirationScheduler.put( + wrappedId, + new Runnable() { + @Override + public void run() { + logger.debug( + () -> + String.format( + "Request %s expired for id %s in session %s: no reply", + requestInfo, wrappedId, this)); + requestIdStatuses.remove(wrappedId); + } + }); + return requestInfo; + } + + /** Updates request info. Thread-safe. */ + public synchronized void updateRequestInfo(BytesValue requestId, RequestInfo newRequestInfo) { + RequestInfo oldRequestInfo = requestIdStatuses.remove(requestId); + if (oldRequestInfo == null) { + logger.debug( + () -> + String.format( + "An attempt to update requestId %s in session %s which does not exist", + requestId, this)); + return; + } + requestIdStatuses.put(requestId, newRequestInfo); + requestExpirationScheduler.put( + requestId, + new Runnable() { + @Override + public void run() { + logger.debug( + String.format( + "Request %s expired for id %s in session %s: no reply", + newRequestInfo, requestId, this)); + requestIdStatuses.remove(requestId); + } + }); + } + + public synchronized void cancelAllRequests(String message) { + logger.debug(() -> String.format("Cancelling all requests in session %s", this)); + Set requestIdsCopy = new HashSet<>(requestIdStatuses.keySet()); + requestIdsCopy.forEach( + requestId -> { + RequestInfo requestInfo = clearRequestId(requestId); + requestInfo + .getFuture() + .completeExceptionally( + new RuntimeException( + String.format( + "Request %s cancelled due to reason: %s", requestInfo, message))); + }); + } + + /** Generates random nonce of {@link #NONCE_SIZE} size */ + public synchronized BytesValue generateNonce() { + byte[] nonce = new byte[NONCE_SIZE]; + rnd.nextBytes(nonce); + return BytesValue.wrap(nonce); + } + + /** If true indicates that handshake is complete */ + public synchronized boolean isAuthenticated() { + return SessionStatus.AUTHENTICATED.equals(status); + } + + /** Resets stored authTags for this session making them obsolete */ + public void cleanup() { + authTagRepo.expire(this); + } + + public Optional getAuthTag() { + return authTagRepo.getTag(this); + } + + public void setAuthTag(BytesValue authTag) { + authTagRepo.put(authTag, this); + } + + public Bytes32 getHomeNodeId() { + return homeNodeId; + } + + /** @return initiator key, also known as write key */ + public BytesValue getInitiatorKey() { + return initiatorKey; + } + + public void setInitiatorKey(BytesValue initiatorKey) { + this.initiatorKey = initiatorKey; + } + + /** @return recipient key, also known as read key */ + public BytesValue getRecipientKey() { + return recipientKey; + } + + public void setRecipientKey(BytesValue recipientKey) { + this.recipientKey = recipientKey; + } + + public synchronized void clearRequestId(BytesValue requestId, TaskType taskType) { + RequestInfo requestInfo = clearRequestId(requestId); + requestInfo.getFuture().complete(null); + assert taskType.equals(requestInfo.getTaskType()); + } + + /** Updates nodeRecord {@link NodeStatus} to ACTIVE of the node associated with this session */ + public synchronized void updateLiveness() { + NodeRecordInfo nodeRecordInfo = + new NodeRecordInfo(getNodeRecord(), Functions.getTime(), NodeStatus.ACTIVE, 0); + nodeTable.save(nodeRecordInfo); + nodeBucketStorage.put(nodeRecordInfo); + } + + private synchronized RequestInfo clearRequestId(BytesValue requestId) { + RequestInfo requestInfo = requestIdStatuses.remove(requestId); + requestExpirationScheduler.cancel(requestId); + return requestInfo; + } + + public synchronized Optional getRequestId(BytesValue requestId) { + RequestInfo requestInfo = requestIdStatuses.get(requestId); + return requestId == null ? Optional.empty() : Optional.of(requestInfo); + } + + /** + * Returns any queued {@link RequestInfo} which was not started because session is not + * authenticated + */ + public synchronized Optional getFirstAwaitRequestInfo() { + return requestIdStatuses.values().stream() + .filter(requestInfo -> AWAIT.equals(requestInfo.getTaskStatus())) + .findFirst(); + } + + public NodeTable getNodeTable() { + return nodeTable; + } + + public void putRecordInBucket(NodeRecordInfo nodeRecordInfo) { + nodeBucketStorage.put(nodeRecordInfo); + } + + public Optional getBucket(int index) { + return nodeBucketStorage.get(index); + } + + public synchronized Bytes32 getIdNonce() { + return idNonce; + } + + public synchronized void setIdNonce(Bytes32 idNonce) { + this.idNonce = idNonce; + } + + public NodeRecord getHomeNodeRecord() { + return homeNodeRecord; + } + + @Override + public String toString() { + return "NodeSession{" + + "nodeRecord=" + + nodeRecord + + ", homeNodeId=" + + homeNodeId + + ", status=" + + status + + '}'; + } + + public synchronized SessionStatus getStatus() { + return status; + } + + public synchronized void setStatus(SessionStatus newStatus) { + logger.debug( + () -> + String.format( + "Switching status of node %s from %s to %s", nodeRecord, status, newStatus)); + this.status = newStatus; + } + + public BytesValue getStaticNodeKey() { + return staticNodeKey; + } + + public enum SessionStatus { + INITIAL, // other side is trying to connect, or we are initiating (before random packet is sent + WHOAREYOU_SENT, // other side is initiator, we've sent whoareyou in response + RANDOM_PACKET_SENT, // our node is initiator, we've sent random packet + AUTHENTICATED + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/NodeStatus.java b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeStatus.java new file mode 100644 index 000000000..c96dd707d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/NodeStatus.java @@ -0,0 +1,33 @@ +package org.ethereum.beacon.discovery; + +import java.util.HashMap; +import java.util.Map; + +/** Status of {@link org.ethereum.beacon.discovery.enr.NodeRecord} */ +public enum NodeStatus { + ACTIVE(0x01), // Alive + SLEEP(0x02), // Didn't answer last time(s) + DEAD(0x03); // Didnt' answer for a long time + + private static final Map codeMap = new HashMap<>(); + + static { + for (NodeStatus type : NodeStatus.values()) { + codeMap.put(type.code, type); + } + } + + private int code; + + NodeStatus(int code) { + this.code = code; + } + + public static NodeStatus fromNumber(int i) { + return codeMap.get(i); + } + + public byte byteCode() { + return (byte) code; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/Protocol.java b/discovery/src/main/java/org/ethereum/beacon/discovery/Protocol.java new file mode 100644 index 000000000..beaf30f9a --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/Protocol.java @@ -0,0 +1,32 @@ +package org.ethereum.beacon.discovery; + +import java.util.HashMap; +import java.util.Map; + +/** Discovery protocol versions */ +public enum Protocol { + V4("v4"), + V5("v5"); + + private static final Map nameMap = new HashMap<>(); + + static { + for (Protocol scheme : Protocol.values()) { + nameMap.put(scheme.name, scheme); + } + } + + private String name; + + private Protocol(String name) { + this.name = name; + } + + public static Protocol fromString(String name) { + return nameMap.get(name); + } + + public String stringName() { + return name; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/RlpUtil.java b/discovery/src/main/java/org/ethereum/beacon/discovery/RlpUtil.java new file mode 100644 index 000000000..b56190ac0 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/RlpUtil.java @@ -0,0 +1,102 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.discovery.enr.IdentitySchema; +import org.javatuples.Pair; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.Bytes8; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.math.BigInteger; +import java.util.function.Function; + +import static org.web3j.rlp.RlpDecoder.OFFSET_LONG_LIST; +import static org.web3j.rlp.RlpDecoder.OFFSET_SHORT_LIST; + +/** + * Handy utilities used for RLP encoding and decoding and not fulfilled by {@link + * org.web3j.rlp.RlpEncoder} and {@link RlpDecoder} + */ +public class RlpUtil { + /** + * Calculates length of list beginning from the start of the data. So, there could everything else + * after first list in data, method helps to cut data in this case. + */ + private static int calcListLen(BytesValue data) { + int prefix = data.get(0) & 0xFF; + int prefixAddon = 1; + if (prefix >= OFFSET_SHORT_LIST && prefix <= OFFSET_LONG_LIST) { + + // 4. the data is a list if the range of the + // first byte is [0xc0, 0xf7], and the concatenation of + // the RLP encodings of all items of the list which the + // total payload is equal to the first byte minus 0xc0 follows the first byte; + + byte listLen = (byte) (prefix - OFFSET_SHORT_LIST); + return listLen & 0xFF + prefixAddon; + } else if (prefix > OFFSET_LONG_LIST) { + + // 5. the data is a list if the range of the + // first byte is [0xf8, 0xff], and the total payload of the + // list which length is equal to the + // first byte minus 0xf7 follows the first byte, + // and the concatenation of the RLP encodings of all items of + // the list follows the total payload of the list; + + int lenOfListLen = (prefix - OFFSET_LONG_LIST) & 0xFF; + prefixAddon += lenOfListLen; + return UInt64.fromBytesBigEndian(Bytes8.leftPad(data.slice(1, lenOfListLen & 0xFF))) + .intValue() + + prefixAddon; + } else { + throw new RuntimeException("Not a start of RLP list!!"); + } + } + + /** + * @return first rlp list in provided data, plus remaining data starting from the end of this list + */ + public static Pair decodeFirstList(BytesValue data) { + int len = RlpUtil.calcListLen(data); + return Pair.with(RlpDecoder.decode(data.slice(0, len).extractArray()), data.slice(len)); + } + + /** + * Encodes object to {@link RlpString}. Supports numbers, {@link BytesValue} etc. + * + * @throws RuntimeException with errorMessageFunction applied with `object` when encoding is not + * possible + */ + public static RlpString encode(Object object, Function errorMessageFunction) { + if (object instanceof BytesValue) { + return fromBytesValue((BytesValue) object); + } else if (object instanceof Number) { + return fromNumber((Number) object); + } else if (object == null) { + return RlpString.create(new byte[0]); + } else if (object instanceof IdentitySchema) { + return RlpString.create(((IdentitySchema) object).stringName()); + } else { + throw new RuntimeException(errorMessageFunction.apply(object)); + } + } + + private static RlpString fromNumber(Number number) { + if (number instanceof BigInteger) { + return RlpString.create((BigInteger) number); + } else if (number instanceof Long) { + return RlpString.create((Long) number); + } else if (number instanceof Integer) { + return RlpString.create((Integer) number); + } else { + throw new RuntimeException( + String.format("Couldn't serialize number %s : no serializer found.", number)); + } + } + + private static RlpString fromBytesValue(BytesValue bytes) { + return RlpString.create(bytes.extractArray()); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrField.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrField.java new file mode 100644 index 000000000..291539445 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrField.java @@ -0,0 +1,21 @@ +package org.ethereum.beacon.discovery.enr; + +/** + * Fields of Ethereum Node Record + */ +public interface EnrField { + // Schema id + String ID = "id"; + // IPv4 address + String IP_V4 = "ip"; + // TCP port, integer + String TCP_V4 = "tcp"; + // UDP port, integer + String UDP_V4 = "udp"; + // IPv6 address + String IP_V6 = "ip6"; + // IPv6-specific TCP port + String TCP_V6 = "tcp6"; + // IPv6-specific UDP port + String UDP_V6 = "udp6"; +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreter.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreter.java new file mode 100644 index 000000000..507e271a6 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreter.java @@ -0,0 +1,13 @@ +package org.ethereum.beacon.discovery.enr; + +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; + +/** + * Encoder/decoder for fields of ethereum node record + */ +public interface EnrFieldInterpreter { + Object decode(String key, RlpString rlpString); + + RlpType encode(String key, Object object); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreterV4.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreterV4.java new file mode 100644 index 000000000..b6b78267f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldInterpreterV4.java @@ -0,0 +1,51 @@ +package org.ethereum.beacon.discovery.enr; + +import org.ethereum.beacon.discovery.RlpUtil; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes16; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class EnrFieldInterpreterV4 implements EnrFieldInterpreter { + public static EnrFieldInterpreterV4 DEFAULT = new EnrFieldInterpreterV4(); + + private Map> fieldDecoders = new HashMap<>(); + + public EnrFieldInterpreterV4() { + fieldDecoders.put( + EnrFieldV4.PKEY_SECP256K1, rlpString -> BytesValue.wrap(rlpString.getBytes())); + fieldDecoders.put( + EnrField.ID, rlpString -> IdentitySchema.fromString(new String(rlpString.getBytes()))); + fieldDecoders.put( + EnrField.IP_V4, rlpString -> Bytes4.wrap(BytesValue.wrap(rlpString.getBytes()), 0)); + fieldDecoders.put(EnrField.TCP_V4, rlpString -> rlpString.asPositiveBigInteger().intValue()); + fieldDecoders.put(EnrField.UDP_V4, rlpString -> rlpString.asPositiveBigInteger().intValue()); + fieldDecoders.put( + EnrField.IP_V6, rlpString -> Bytes16.wrap(BytesValue.wrap(rlpString.getBytes()), 0)); + fieldDecoders.put(EnrField.TCP_V6, rlpString -> rlpString.asPositiveBigInteger().intValue()); + fieldDecoders.put(EnrField.UDP_V6, rlpString -> rlpString.asPositiveBigInteger().intValue()); + } + + @Override + public Object decode(String key, RlpString rlpString) { + Function fieldDecoder = fieldDecoders.get(key); + if (fieldDecoder == null) { + throw new RuntimeException(String.format("No decoder found for field `%s`", key)); + } + return fieldDecoder.apply(rlpString); + } + + @Override + public RlpType encode(String key, Object object) { + return RlpUtil.encode( + object, + o -> + String.format( + "Couldn't encode field %s with value %s: no serializer found.", key, object)); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldV4.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldV4.java new file mode 100644 index 000000000..b216975b7 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/EnrFieldV4.java @@ -0,0 +1,10 @@ +package org.ethereum.beacon.discovery.enr; + +/** + * Fields of Ethereum Node Record V4 as defined by https://eips.ethereum.org/EIPS/eip-778 + */ +public interface EnrFieldV4 extends EnrField { + // Compressed secp256k1 public key, 33 bytes + String PKEY_SECP256K1 = "secp256k1"; +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchema.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchema.java new file mode 100644 index 000000000..5fce90f49 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchema.java @@ -0,0 +1,31 @@ +package org.ethereum.beacon.discovery.enr; + +import java.util.HashMap; +import java.util.Map; + +/** Available identity schemas of Ethereum {@link NodeRecord} signature */ +public enum IdentitySchema { + V4("v4"); + + private static final Map nameMap = new HashMap<>(); + + static { + for (IdentitySchema scheme : IdentitySchema.values()) { + nameMap.put(scheme.name, scheme); + } + } + + private String name; + + private IdentitySchema(String name) { + this.name = name; + } + + public static IdentitySchema fromString(String name) { + return nameMap.get(name); + } + + public String stringName() { + return name; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaInterpreter.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaInterpreter.java new file mode 100644 index 000000000..f1a685b20 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaInterpreter.java @@ -0,0 +1,30 @@ +package org.ethereum.beacon.discovery.enr; + +import tech.pegasys.artemis.util.bytes.Bytes32; + +/** + * Interprets identity schema of ethereum node record: + * + *

    + *
  • derives node id from node record + *
  • >signs node record + *
  • verifies signature of node record + *
+ */ +public interface IdentitySchemaInterpreter { + /** Returns supported scheme */ + IdentitySchema getScheme(); + + /* Signs nodeRecord, modifying it */ + void sign(NodeRecord nodeRecord, Object signOptions); + + /** Verifies that `nodeRecord` is of scheme implementation */ + default void verify(NodeRecord nodeRecord) { + if (!nodeRecord.getIdentityScheme().equals(getScheme())) { + throw new RuntimeException("Interpreter and node record schemes do not match!"); + } + } + + /** Delivers nodeId according to identity scheme scheme */ + Bytes32 getNodeId(NodeRecord nodeRecord); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaV4Interpreter.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaV4Interpreter.java new file mode 100644 index 000000000..b26f5e5c1 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/IdentitySchemaV4Interpreter.java @@ -0,0 +1,50 @@ +package org.ethereum.beacon.discovery.enr; + +import org.bouncycastle.math.ec.ECPoint; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.util.Utils; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +public class IdentitySchemaV4Interpreter implements IdentitySchemaInterpreter { + @Override + public void verify(NodeRecord nodeRecord) { + IdentitySchemaInterpreter.super.verify(nodeRecord); + if (nodeRecord.get(EnrFieldV4.PKEY_SECP256K1) == null) { + throw new RuntimeException( + String.format( + "Field %s not exists but required for scheme %s", + EnrFieldV4.PKEY_SECP256K1, getScheme())); + } + BytesValue pubKey = (BytesValue) nodeRecord.get(EnrFieldV4.PKEY_SECP256K1); // compressed + assert Functions.verifyECDSASignature( + nodeRecord.getSignature(), Functions.hashKeccak(nodeRecord.serializeNoSignature()), pubKey); + } + + @Override + public IdentitySchema getScheme() { + return IdentitySchema.V4; + } + + @Override + public Bytes32 getNodeId(NodeRecord nodeRecord) { + verify(nodeRecord); + BytesValue pkey = (BytesValue) nodeRecord.getKey(EnrFieldV4.PKEY_SECP256K1); + ECPoint pudDestPoint = Functions.publicKeyToPoint(pkey); + BytesValue xPart = + Bytes32.wrap( + Utils.extractBytesFromUnsignedBigInt(pudDestPoint.getXCoord().toBigInteger(), 32)); + BytesValue yPart = + Bytes32.wrap( + Utils.extractBytesFromUnsignedBigInt(pudDestPoint.getYCoord().toBigInteger(), 32)); + return Functions.hashKeccak(xPart.concat(yPart)); + } + + @Override + public void sign(NodeRecord nodeRecord, Object signOptions) { + BytesValue privateKey = (BytesValue) signOptions; + BytesValue signature = + Functions.sign(privateKey, Functions.hashKeccak(nodeRecord.serializeNoSignature())); + nodeRecord.setSignature(signature); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecord.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecord.java new file mode 100644 index 000000000..7ef5c7170 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecord.java @@ -0,0 +1,198 @@ +package org.ethereum.beacon.discovery.enr; + +import com.google.common.base.Objects; +import org.javatuples.Pair; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes96; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Ethereum Node Record V4 + * + *

Node record as described in EIP-778 + */ +public class NodeRecord { + /** + * The canonical encoding of a node record is an RLP list of [signature, seq, k, v, ...]. The + * maximum encoded size of a node record is 300 bytes. Implementations should reject records + * larger than this size. + */ + public static final int MAX_ENCODED_SIZE = 300; + private static final EnrFieldInterpreter enrFieldInterpreter = EnrFieldInterpreterV4.DEFAULT; + private final UInt64 seq; + // Signature + private BytesValue signature; + // optional fields + private Map fields = new HashMap<>(); + private IdentitySchemaInterpreter identitySchemaInterpreter; + + private NodeRecord( + IdentitySchemaInterpreter identitySchemaInterpreter, UInt64 seq, BytesValue signature) { + this.seq = seq; + this.signature = signature; + this.identitySchemaInterpreter = identitySchemaInterpreter; + } + + private NodeRecord( + IdentitySchemaInterpreter identitySchemaInterpreter, UInt64 seq) { + this.seq = seq; + this.signature = Bytes96.ZERO; + this.identitySchemaInterpreter = identitySchemaInterpreter; + } + + public static NodeRecord fromValues( + IdentitySchemaInterpreter identitySchemaInterpreter, + UInt64 seq, + List> fieldKeyPairs) { + NodeRecord nodeRecord = new NodeRecord(identitySchemaInterpreter, seq); + fieldKeyPairs.forEach(objects -> nodeRecord.set(objects.getValue0(), objects.getValue1())); + return nodeRecord; + } + + public static NodeRecord fromRawFields( + IdentitySchemaInterpreter identitySchemaInterpreter, + UInt64 seq, + BytesValue signature, + List rawFields) { + NodeRecord nodeRecord = new NodeRecord(identitySchemaInterpreter, seq, signature); + for (int i = 0; i < rawFields.size(); i += 2) { + String key = new String(((RlpString) rawFields.get(i)).getBytes()); + nodeRecord.set(key, enrFieldInterpreter.decode(key, (RlpString) rawFields.get(i + 1))); + } + return nodeRecord; + } + + public String asBase64() { + return new String(Base64.getUrlEncoder().encode(serialize().extractArray())); + } + + public IdentitySchema getIdentityScheme() { + return identitySchemaInterpreter.getScheme(); + } + + public void set(String key, Object value) { + fields.put(key, value); + } + + public Object get(String key) { + return fields.get(key); + } + + public UInt64 getSeq() { + return seq; + } + + public BytesValue getSignature() { + return signature; + } + + public void setSignature(BytesValue signature) { + this.signature = signature; + } + + public Set getKeys() { + return new HashSet<>(fields.keySet()); + } + + public Object getKey(String key) { + return fields.get(key); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NodeRecord that = (NodeRecord) o; + return Objects.equal(seq, that.seq) + && Objects.equal(signature, that.signature) + && Objects.equal(fields, that.fields); + } + + @Override + public int hashCode() { + return Objects.hashCode(seq, signature, fields); + } + + public void verify() { + identitySchemaInterpreter.verify(this); + } + + public void sign(Object signOptions) { + identitySchemaInterpreter.sign(this, signOptions); + } + + public RlpList asRlp() { + return asRlpImpl(true); + } + + public RlpList asRlpNoSignature() { + return asRlpImpl(false); + } + + private RlpList asRlpImpl(boolean withSignature) { + assert getSeq() != null; + // content = [seq, k, v, ...] + // signature = sign(content) + // record = [signature, seq, k, v, ...] + List values = new ArrayList<>(); + if (withSignature) { + values.add(RlpString.create(getSignature().extractArray())); + } + values.add(RlpString.create(getSeq().toBI())); + List keySortedList = fields.keySet().stream().sorted().collect(Collectors.toList()); + for (String key : keySortedList) { + if (fields.get(key) == null) { + continue; + } + values.add(RlpString.create(key)); + values.add(enrFieldInterpreter.encode(key, fields.get(key))); + } + + return new RlpList(values); + } + + public BytesValue serialize() { + return serializeImpl(true); + } + + public BytesValue serializeNoSignature() { + return serializeImpl(false); + } + + private BytesValue serializeImpl(boolean withSignature) { + RlpType rlpRecord = withSignature ? asRlp() : asRlpNoSignature(); + byte[] bytes = RlpEncoder.encode(rlpRecord); + assert bytes.length <= MAX_ENCODED_SIZE; + return BytesValue.wrap(bytes); + } + + public Bytes32 getNodeId() { + return identitySchemaInterpreter.getNodeId(this); + } + + @Override + public String toString() { + return "NodeRecordV4{" + + "publicKey=" + + fields.get(EnrFieldV4.PKEY_SECP256K1) + + ", ipV4address=" + + fields.get(EnrFieldV4.IP_V4) + + ", udpPort=" + + fields.get(EnrFieldV4.UDP_V4) + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecordFactory.java b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecordFactory.java new file mode 100644 index 000000000..22481955f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/enr/NodeRecordFactory.java @@ -0,0 +1,116 @@ +package org.ethereum.beacon.discovery.enr; + +import org.javatuples.Pair; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes8; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class NodeRecordFactory { + public static final NodeRecordFactory DEFAULT = + new NodeRecordFactory(new IdentitySchemaV4Interpreter()); + Map interpreters = new HashMap<>(); + + public NodeRecordFactory(IdentitySchemaInterpreter... identitySchemaInterpreters) { + for (IdentitySchemaInterpreter identitySchemaInterpreter : identitySchemaInterpreters) { + interpreters.put(identitySchemaInterpreter.getScheme(), identitySchemaInterpreter); + } + } + + @SafeVarargs + public final NodeRecord createFromValues(UInt64 seq, Pair... fieldKeyPairs) { + return createFromValues(seq, Arrays.asList(fieldKeyPairs)); + } + + public NodeRecord createFromValues(UInt64 seq, List> fieldKeyPairs) { + Pair schemePair = null; + for (Pair pair : fieldKeyPairs) { + if (EnrField.ID.equals(pair.getValue0())) { + schemePair = pair; + break; + } + } + if (schemePair == null) { + throw new RuntimeException("ENR scheme (ID) is not defined in key-value pairs"); + } + + IdentitySchemaInterpreter identitySchemaInterpreter = interpreters.get(schemePair.getValue1()); + if (identitySchemaInterpreter == null) { + throw new RuntimeException( + String.format( + "No ethereum record interpreter found for identity scheme %s", + schemePair.getValue1())); + } + + return NodeRecord.fromValues(identitySchemaInterpreter, seq, fieldKeyPairs); + } + + public NodeRecord fromBase64(String enrBase64) { + return fromBytes(Base64.getUrlDecoder().decode(enrBase64)); + } + + public NodeRecord fromBytes(BytesValue bytes) { + return fromBytes(bytes.extractArray()); + } + + public NodeRecord fromRlpList(RlpList rlpList) { + List values = rlpList.getValues(); + if (values.size() < 4) { + throw new RuntimeException( + String.format("Unable to deserialize ENR with less than 4 fields, [%s]", values)); + } + + // TODO: repair as id is not first now + IdentitySchema nodeIdentity = null; + boolean idFound = false; + for (int i = 2; i < values.size(); i += 2) { + RlpString id = (RlpString) values.get(i); + if (!"id".equals(new String(id.getBytes()))) { + continue; + } + + RlpString idVersion = (RlpString) values.get(i + 1); + nodeIdentity = IdentitySchema.fromString(new String(idVersion.getBytes())); + if (nodeIdentity == null) { // no interpreter for such id + throw new RuntimeException( + String.format( + "Unknown node identity scheme '%s', couldn't create node record.", + idVersion.asString())); + } + idFound = true; + break; + } + if (!idFound) { // no `id` key-values + throw new RuntimeException("Unknown node identity scheme, not defined in record "); + } + + IdentitySchemaInterpreter identitySchemaInterpreter = interpreters.get(nodeIdentity); + if (identitySchemaInterpreter == null) { + throw new RuntimeException( + String.format( + "No Ethereum record interpreter found for identity scheme %s", nodeIdentity)); + } + + return NodeRecord.fromRawFields( + identitySchemaInterpreter, + UInt64.fromBytesBigEndian( + Bytes8.leftPad(BytesValue.wrap(((RlpString) values.get(1)).getBytes()))), + BytesValue.wrap(((RlpString) values.get(0)).getBytes()), + values.subList(2, values.size())); + } + + public NodeRecord fromBytes(byte[] bytes) { + // record = [signature, seq, k, v, ...] + RlpList rlpList = (RlpList) RlpDecoder.decode(bytes).getValues().get(0); + return fromRlpList(rlpList); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryMessage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryMessage.java new file mode 100644 index 000000000..b68e278ed --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryMessage.java @@ -0,0 +1,11 @@ +package org.ethereum.beacon.discovery.message; + +import org.ethereum.beacon.discovery.Protocol; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Discovery message */ +public interface DiscoveryMessage { + Protocol getProtocol(); + + BytesValue getBytes(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryV5Message.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryV5Message.java new file mode 100644 index 000000000..3889bc37c --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/DiscoveryV5Message.java @@ -0,0 +1,86 @@ +package org.ethereum.beacon.discovery.message; + +import org.ethereum.beacon.discovery.Protocol; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.List; + +public class DiscoveryV5Message implements DiscoveryMessage { + private final BytesValue bytes; + private List payload = null; + + public DiscoveryV5Message(BytesValue bytes) { + this.bytes = bytes; + } + + public static DiscoveryV5Message from(V5Message v5Message) { + return new DiscoveryV5Message(v5Message.getBytes()); + } + + @Override + public BytesValue getBytes() { + return bytes; + } + + @Override + public Protocol getProtocol() { + return Protocol.V5; + } + + public MessageCode getCode() { + return MessageCode.fromNumber(getBytes().get(0)); + } + + private synchronized void decode() { + if (payload != null) { + return; + } + this.payload = + ((RlpList) RlpDecoder.decode(getBytes().slice(1).extractArray()).getValues().get(0)) + .getValues(); + } + + public BytesValue getRequestId() { + decode(); + return BytesValue.wrap(((RlpString) payload.get(0)).getBytes()); + } + + public V5Message create(NodeRecordFactory nodeRecordFactory) { + decode(); + MessageCode code = MessageCode.fromNumber(getBytes().get(0)); + switch (code) { + case PING: + { + return PingMessage.fromRlp(payload); + } + case PONG: + { + return PongMessage.fromRlp(payload); + } + case FINDNODE: + { + return FindNodeMessage.fromRlp(payload); + } + case NODES: + { + return NodesMessage.fromRlp(payload, nodeRecordFactory); + } + default: + { + throw new RuntimeException( + String.format( + "Creation of discovery V5 messages from code %s is not supported", code)); + } + } + } + + @Override + public String toString() { + return "DiscoveryV5Message{" + "code=" + getCode() + ", bytes=" + getBytes() + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/FindNodeMessage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/FindNodeMessage.java new file mode 100644 index 000000000..7c36db08f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/FindNodeMessage.java @@ -0,0 +1,72 @@ +package org.ethereum.beacon.discovery.message; + +import com.google.common.base.Objects; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes1; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.List; + +/** + * FINDNODE queries for nodes at the given logarithmic distance from the recipient's node ID. The + * node IDs of all nodes in the response must have a shared prefix length of distance with the + * recipient's node ID. A request with distance 0 should return the recipient's current record as + * the only result. + */ +public class FindNodeMessage implements V5Message { + // Unique request id + private final BytesValue requestId; + // The requested log2 distance, a positive integer + private final Integer distance; + + public FindNodeMessage(BytesValue requestId, Integer distance) { + this.requestId = requestId; + this.distance = distance; + } + + public static FindNodeMessage fromRlp(List rlpList) { + return new FindNodeMessage( + BytesValue.wrap(((RlpString) rlpList.get(0)).getBytes()), + ((RlpString) rlpList.get(1)).asPositiveBigInteger().intValueExact()); + } + + @Override + public BytesValue getRequestId() { + return requestId; + } + + public Integer getDistance() { + return distance; + } + + @Override + public BytesValue getBytes() { + return Bytes1.intToBytes1(MessageCode.FINDNODE.byteCode()) + .concat( + BytesValue.wrap( + RlpEncoder.encode( + new RlpList( + RlpString.create(requestId.extractArray()), RlpString.create(distance))))); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FindNodeMessage that = (FindNodeMessage) o; + return Objects.equal(requestId, that.requestId) && Objects.equal(distance, that.distance); + } + + @Override + public int hashCode() { + return Objects.hashCode(requestId, distance); + } + + @Override + public String toString() { + return "FindNodeMessage{" + "requestId=" + requestId + ", distance=" + distance + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/MessageCode.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/MessageCode.java new file mode 100644 index 000000000..6c06118b2 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/MessageCode.java @@ -0,0 +1,75 @@ +package org.ethereum.beacon.discovery.message; + +import java.util.HashMap; +import java.util.Map; + +/** + * Discovery protocol message types as described in https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire.md#protocol-messages + */ +public enum MessageCode { + + /** + * PING checks whether the recipient is alive and informs it about the sender's ENR sequence + * number. + */ + PING(0x01), + + /** PONG is the reply to PING. */ + PONG(0x02), + + /** FINDNODE queries for nodes at the given logarithmic distance from the recipient's node ID. */ + FINDNODE(0x03), + + /** + * NODES is the response to a FINDNODE or TOPICQUERY message. Multiple NODES messages may be sent + * as responses to a single query. + */ + NODES(0x04), + + /** Request for {@link #TICKET} by topic. */ + REQTICKET(0x05), + + /** + * TICKET is the response to REQTICKET. It contains a ticket which can be used to register for the + * requested topic. + */ + TICKET(0x06), + + /** + * REGTOPIC registers the sender for the given topic with a ticket. The ticket must be valid and + * its waiting time must have elapsed before using the ticket. + */ + REGTOPIC(0x07), + + /** REGCONFIRMATION is the response to REGTOPIC. */ + REGCONFIRMATION(0x08), + + /** + * TOPICQUERY requests nodes in the topic queue of the given topic. The response is a NODES + * message containing node records registered for the topic. + */ + TOPICQUERY(0x09); + + private static final Map codeMap = new HashMap<>(); + + static { + for (MessageCode type : MessageCode.values()) { + codeMap.put(type.code, type); + } + } + + private int code; + + MessageCode(int code) { + this.code = code; + } + + public static MessageCode fromNumber(int i) { + return codeMap.get(i); + } + + public byte byteCode() { + return (byte) code; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/NodesMessage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/NodesMessage.java new file mode 100644 index 000000000..faa003767 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/NodesMessage.java @@ -0,0 +1,116 @@ +package org.ethereum.beacon.discovery.message; + +import com.google.common.base.Objects; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes1; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * NODES is the response to a FINDNODE or TOPICQUERY message. Multiple NODES messages may be sent as + * responses to a single query. + */ +public class NodesMessage implements V5Message { + // Unique request id + private final BytesValue requestId; + // Total number of responses to the request + private final Integer total; + // List of nodes upon request + private final Supplier> nodeRecordsSupplier; + // Size of nodes in current response + private final Integer nodeRecordsSize; + private List nodeRecords = null; + + public NodesMessage( + BytesValue requestId, + Integer total, + Supplier> nodeRecordsSupplier, + Integer nodeRecordsSize) { + this.requestId = requestId; + this.total = total; + this.nodeRecordsSupplier = nodeRecordsSupplier; + this.nodeRecordsSize = nodeRecordsSize; + } + + public static NodesMessage fromRlp(List rlpList, NodeRecordFactory nodeRecordFactory) { + RlpList nodeRecords = (RlpList) rlpList.get(2); + return new NodesMessage( + BytesValue.wrap(((RlpString) rlpList.get(0)).getBytes()), + ((RlpString) rlpList.get(1)).asPositiveBigInteger().intValueExact(), + () -> + nodeRecords.getValues().stream() + .map(rl -> nodeRecordFactory.fromRlpList((RlpList) rl)) + .collect(Collectors.toList()), + nodeRecords.getValues().size()); + } + + @Override + public BytesValue getRequestId() { + return requestId; + } + + public Integer getTotal() { + return total; + } + + public synchronized List getNodeRecords() { + if (nodeRecords == null) { + this.nodeRecords = nodeRecordsSupplier.get(); + } + return nodeRecords; + } + + public Integer getNodeRecordsSize() { + return nodeRecordsSize; + } + + @Override + public BytesValue getBytes() { + return Bytes1.intToBytes1(MessageCode.NODES.byteCode()) + .concat( + BytesValue.wrap( + RlpEncoder.encode( + new RlpList( + RlpString.create(requestId.extractArray()), + RlpString.create(total), + new RlpList( + getNodeRecords().stream() + .map(NodeRecord::asRlp) + .collect(Collectors.toList())))))); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NodesMessage that = (NodesMessage) o; + return Objects.equal(requestId, that.requestId) + && Objects.equal(total, that.total) + && Objects.equal(nodeRecordsSize, that.nodeRecordsSize); + } + + @Override + public int hashCode() { + return Objects.hashCode(requestId, total, nodeRecordsSize); + } + + @Override + public String toString() { + return "NodesMessage{" + + "requestId=" + + requestId + + ", total=" + + total + + ", nodeRecordsSize=" + + nodeRecordsSize + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/PingMessage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/PingMessage.java new file mode 100644 index 000000000..13638b3d0 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/PingMessage.java @@ -0,0 +1,73 @@ +package org.ethereum.beacon.discovery.message; + +import com.google.common.base.Objects; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes1; +import tech.pegasys.artemis.util.bytes.Bytes8; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.List; + +/** + * PING checks whether the recipient is alive and informs it about the sender's ENR sequence number. + */ +public class PingMessage implements V5Message { + // Unique request id + private final BytesValue requestId; + // Local ENR sequence number of sender + private final UInt64 enrSeq; + + public PingMessage(BytesValue requestId, UInt64 enrSeq) { + this.requestId = requestId; + this.enrSeq = enrSeq; + } + + public static PingMessage fromRlp(List rlpList) { + return new PingMessage( + BytesValue.wrap(((RlpString) rlpList.get(0)).getBytes()), + UInt64.fromBytesBigEndian( + Bytes8.leftPad(BytesValue.wrap(((RlpString) rlpList.get(1)).getBytes())))); + } + + @Override + public BytesValue getRequestId() { + return requestId; + } + + public UInt64 getEnrSeq() { + return enrSeq; + } + + @Override + public BytesValue getBytes() { + return Bytes1.intToBytes1(MessageCode.PING.byteCode()) + .concat( + BytesValue.wrap( + RlpEncoder.encode( + new RlpList( + RlpString.create(requestId.extractArray()), + RlpString.create(enrSeq.toBI()))))); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PingMessage that = (PingMessage) o; + return Objects.equal(requestId, that.requestId) && Objects.equal(enrSeq, that.enrSeq); + } + + @Override + public int hashCode() { + return Objects.hashCode(requestId, enrSeq); + } + + @Override + public String toString() { + return "PingMessage{" + "requestId=" + requestId + ", enrSeq=" + enrSeq + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/PongMessage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/PongMessage.java new file mode 100644 index 000000000..33c3cf4a7 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/PongMessage.java @@ -0,0 +1,102 @@ +package org.ethereum.beacon.discovery.message; + +import com.google.common.base.Objects; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.util.bytes.Bytes1; +import tech.pegasys.artemis.util.bytes.Bytes8; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.List; + +/** PONG is the reply to PING {@link PingMessage} */ +public class PongMessage implements V5Message { + // Unique request id + private final BytesValue requestId; + // Local ENR sequence number of sender + private final UInt64 enrSeq; + // 16 or 4 byte IP address of the intended recipient + private final BytesValue recipientIp; + // recipient UDP port, a 16-bit integer + private final Integer recipientPort; + + public PongMessage( + BytesValue requestId, UInt64 enrSeq, BytesValue recipientIp, Integer recipientPort) { + this.requestId = requestId; + this.enrSeq = enrSeq; + this.recipientIp = recipientIp; + this.recipientPort = recipientPort; + } + + public static PongMessage fromRlp(List rlpList) { + return new PongMessage( + BytesValue.wrap(((RlpString) rlpList.get(0)).getBytes()), + UInt64.fromBytesBigEndian( + Bytes8.leftPad(BytesValue.wrap(((RlpString) rlpList.get(1)).getBytes()))), + BytesValue.wrap(((RlpString) rlpList.get(2)).getBytes()), + ((RlpString) rlpList.get(3)).asPositiveBigInteger().intValueExact()); + } + + @Override + public BytesValue getRequestId() { + return requestId; + } + + public UInt64 getEnrSeq() { + return enrSeq; + } + + public BytesValue getRecipientIp() { + return recipientIp; + } + + public Integer getRecipientPort() { + return recipientPort; + } + + @Override + public BytesValue getBytes() { + return Bytes1.intToBytes1(MessageCode.PONG.byteCode()) + .concat( + BytesValue.wrap( + RlpEncoder.encode( + new RlpList( + RlpString.create(requestId.extractArray()), + RlpString.create(enrSeq.toBI()), + RlpString.create(recipientIp.extractArray()), + RlpString.create(recipientPort))))); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PongMessage that = (PongMessage) o; + return Objects.equal(requestId, that.requestId) + && Objects.equal(enrSeq, that.enrSeq) + && Objects.equal(recipientIp, that.recipientIp) + && Objects.equal(recipientPort, that.recipientPort); + } + + @Override + public int hashCode() { + return Objects.hashCode(requestId, enrSeq, recipientIp, recipientPort); + } + + @Override + public String toString() { + return "PongMessage{" + + "requestId=" + + requestId + + ", enrSeq=" + + enrSeq + + ", recipientIp=" + + recipientIp + + ", recipientPort=" + + recipientPort + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/V5Message.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/V5Message.java new file mode 100644 index 000000000..9fc7af86d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/V5Message.java @@ -0,0 +1,10 @@ +package org.ethereum.beacon.discovery.message; + +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Message of V5 discovery protocol version */ +public interface V5Message { + BytesValue getRequestId(); + + BytesValue getBytes(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/FindNodeHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/FindNodeHandler.java new file mode 100644 index 000000000..d5878e27f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/FindNodeHandler.java @@ -0,0 +1,78 @@ +package org.ethereum.beacon.discovery.message.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.ethereum.beacon.discovery.message.FindNodeMessage; +import org.ethereum.beacon.discovery.message.NodesMessage; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.storage.NodeBucket; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public class FindNodeHandler implements MessageHandler { + private static final Logger logger = LogManager.getLogger(FindNodeHandler.class); + /** + * The maximum size of any packet is 1280 bytes. Implementations should not generate or process + * packets larger than this size. As per specification the maximum size of an ENR is 300 bytes. A + * NODES message containing all FINDNODE response records would be at least 4800 bytes, not + * including additional data such as the header. To stay below the size limit, NODES responses are + * sent as multiple messages and specify the total number of responses in the message. 4х300 = + * 1200 and we always have 80 bytes for everything else. + */ + private static final int MAX_NODES_PER_MESSAGE = 4; + + public FindNodeHandler() {} + + @Override + public void handle(FindNodeMessage message, NodeSession session) { + Optional nodeBucketOptional = session.getBucket(message.getDistance()); + List> nodeRecordsList = new ArrayList<>(); + int total = 0; + + // Repack to lists of MAX_NODES_PER_MESSAGE size + List bucketRecords = + nodeBucketOptional.isPresent() + ? nodeBucketOptional.get().getNodeRecords() + : Collections.emptyList(); + for (NodeRecordInfo nodeRecordInfo : bucketRecords) { + if (total % MAX_NODES_PER_MESSAGE == 0) { + nodeRecordsList.add(new ArrayList<>()); + } + List currentList = nodeRecordsList.get(nodeRecordsList.size() - 1); + currentList.add(nodeRecordInfo.getNode()); + ++total; + } + logger.trace( + () -> + String.format( + "Sending %s nodes in reply to request with distance %s in session %s", + bucketRecords.size(), message.getDistance(), session)); + + // Send + if (nodeRecordsList.isEmpty()) { + nodeRecordsList.add(Collections.emptyList()); + } + int finalTotal = total; + nodeRecordsList.forEach( + recordsList -> + session.sendOutgoing( + MessagePacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + session.getAuthTag().get(), + session.getInitiatorKey(), + DiscoveryV5Message.from( + new NodesMessage( + message.getRequestId(), + finalTotal, + () -> recordsList, + recordsList.size()))))); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/MessageHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/MessageHandler.java new file mode 100644 index 000000000..dc25df62d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/MessageHandler.java @@ -0,0 +1,7 @@ +package org.ethereum.beacon.discovery.message.handler; + +import org.ethereum.beacon.discovery.NodeSession; + +public interface MessageHandler { + void handle(Message message, NodeSession session); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/NodesHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/NodesHandler.java new file mode 100644 index 000000000..70af0f45c --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/NodesHandler.java @@ -0,0 +1,64 @@ +package org.ethereum.beacon.discovery.message.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.message.NodesMessage; +import org.ethereum.beacon.discovery.pipeline.info.FindNodeRequestInfo; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfo; +import org.ethereum.beacon.discovery.task.TaskStatus; +import org.ethereum.beacon.discovery.task.TaskType; + +import java.util.Optional; + +public class NodesHandler implements MessageHandler { + private static final Logger logger = LogManager.getLogger(FindNodeHandler.class); + + @Override + public void handle(NodesMessage message, NodeSession session) { + // NODES total count handling + Optional requestInfoOpt = session.getRequestId(message.getRequestId()); + if (!requestInfoOpt.isPresent()) { + throw new RuntimeException( + String.format( + "Request #%s not found in session %s when handling message %s", + message.getRequestId(), session, message)); + } + FindNodeRequestInfo requestInfo = (FindNodeRequestInfo) requestInfoOpt.get(); + int newNodesCount = + requestInfo.getRemainingNodes() == null + ? message.getTotal() - 1 + : requestInfo.getRemainingNodes() - 1; + if (newNodesCount == 0) { + session.clearRequestId(message.getRequestId(), TaskType.FINDNODE); + } else { + session.updateRequestInfo( + message.getRequestId(), + new FindNodeRequestInfo( + TaskStatus.IN_PROCESS, + message.getRequestId(), + requestInfo.getFuture(), + requestInfo.getDistance(), + newNodesCount)); + } + + // Parse node records + logger.trace( + () -> + String.format( + "Received %s node records in session %s. Total buckets expected: %s", + message.getNodeRecordsSize(), session, message.getTotal())); + message + .getNodeRecords() + .forEach( + nodeRecordV5 -> { + nodeRecordV5.verify(); + NodeRecordInfo nodeRecordInfo = NodeRecordInfo.createDefault(nodeRecordV5); + if (!session.getNodeTable().getNode(nodeRecordV5.getNodeId()).isPresent()) { + session.getNodeTable().save(nodeRecordInfo); + } + session.putRecordInBucket(nodeRecordInfo); + }); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PingHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PingHandler.java new file mode 100644 index 000000000..80e01e662 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PingHandler.java @@ -0,0 +1,28 @@ +package org.ethereum.beacon.discovery.message.handler; + +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.ethereum.beacon.discovery.message.PingMessage; +import org.ethereum.beacon.discovery.message.PongMessage; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import tech.pegasys.artemis.util.bytes.Bytes4; + +public class PingHandler implements MessageHandler { + @Override + public void handle(PingMessage message, NodeSession session) { + PongMessage responseMessage = + new PongMessage( + message.getRequestId(), + session.getNodeRecord().getSeq(), + ((Bytes4) session.getNodeRecord().get(EnrField.IP_V4)), + (int) session.getNodeRecord().get(EnrField.UDP_V4)); + session.sendOutgoing( + MessagePacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + session.getAuthTag().get(), + session.getInitiatorKey(), + DiscoveryV5Message.from(responseMessage))); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PongHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PongHandler.java new file mode 100644 index 000000000..a212d39c6 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/message/handler/PongHandler.java @@ -0,0 +1,12 @@ +package org.ethereum.beacon.discovery.message.handler; + +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.message.PongMessage; +import org.ethereum.beacon.discovery.task.TaskType; + +public class PongHandler implements MessageHandler { + @Override + public void handle(PongMessage message, NodeSession session) { + session.clearRequestId(message.getRequestId(), TaskType.PING); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/DatagramToBytesValue.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DatagramToBytesValue.java new file mode 100644 index 000000000..4be03ad37 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DatagramToBytesValue.java @@ -0,0 +1,21 @@ +package org.ethereum.beacon.discovery.network; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.socket.DatagramPacket; +import io.netty.handler.codec.MessageToMessageDecoder; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.List; + +/** UDP Packet -> BytesValue converter with default Netty interface */ +public class DatagramToBytesValue extends MessageToMessageDecoder { + @Override + protected void decode(ChannelHandlerContext ctx, DatagramPacket msg, List out) + throws Exception { + ByteBuf buf = msg.content(); + byte[] data = new byte[buf.readableBytes()]; + buf.readBytes(data); + out.add(BytesValue.wrap(data)); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryClient.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryClient.java new file mode 100644 index 000000000..fab7112d7 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryClient.java @@ -0,0 +1,11 @@ +package org.ethereum.beacon.discovery.network; + +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Discovery client sends outgoing messages */ +public interface DiscoveryClient { + void stop(); + + void send(BytesValue data, NodeRecord recipient); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryServer.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryServer.java new file mode 100644 index 000000000..985b2d9e7 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/DiscoveryServer.java @@ -0,0 +1,15 @@ +package org.ethereum.beacon.discovery.network; + +import org.ethereum.beacon.schedulers.Scheduler; +import org.reactivestreams.Publisher; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Discovery server which listens to incoming messages according to setup */ +public interface DiscoveryServer { + void start(Scheduler scheduler); + + void stop(); + + /** Raw incoming packets stream */ + Publisher getIncomingPackets(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/IncomingMessageSink.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/IncomingMessageSink.java new file mode 100644 index 000000000..f6c1226d0 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/IncomingMessageSink.java @@ -0,0 +1,28 @@ +package org.ethereum.beacon.discovery.network; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import reactor.core.publisher.FluxSink; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** + * Netty interface handler for incoming packets in form of raw bytes data wrapped as {@link + * BytesValue} Implementation forwards all incoming packets in {@link FluxSink} provided via + * constructor, so it could be later linked to processor to form incoming messages stream + */ +public class IncomingMessageSink extends SimpleChannelInboundHandler { + private static final Logger logger = LogManager.getLogger(IncomingMessageSink.class); + private final FluxSink bytesValueSink; + + public IncomingMessageSink(FluxSink bytesValueSink) { + this.bytesValueSink = bytesValueSink; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, BytesValue msg) throws Exception { + logger.trace(() -> String.format("Incoming packet %s in session %s", msg, ctx)); + bytesValueSink.next(msg); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryClientImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryClientImpl.java new file mode 100644 index 000000000..e8184cb68 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryClientImpl.java @@ -0,0 +1,87 @@ +package org.ethereum.beacon.discovery.network; + +import io.netty.buffer.Unpooled; +import io.netty.channel.socket.DatagramPacket; +import io.netty.channel.socket.nio.NioDatagramChannel; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.enr.IdentitySchema; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Netty discovery UDP client */ +public class NettyDiscoveryClientImpl implements DiscoveryClient { + private static final int STOPPING_TIMEOUT = 10000; + private static final Logger logger = LogManager.getLogger(NettyDiscoveryClientImpl.class); + private AtomicBoolean listen = new AtomicBoolean(false); + private NioDatagramChannel channel; + + /** + * Constructs UDP client using + * + * @param outgoingStream Stream of outgoing packets, client will forward them to the channel + * @param channel Nio channel + */ + public NettyDiscoveryClientImpl( + Publisher outgoingStream, NioDatagramChannel channel) { + this.channel = channel; + Flux.from(outgoingStream) + .subscribe( + networkPacket -> + send(networkPacket.getPacket().getBytes(), networkPacket.getNodeRecord())); + logger.info("UDP discovery client started"); + listen.set(true); + } + + @Override + public void stop() { + if (listen.get()) { + logger.info("Stopping discovery client"); + listen.set(false); + if (channel != null) { + try { + channel.close().await(STOPPING_TIMEOUT); + } catch (InterruptedException ex) { + logger.error("Failed to stop discovery client", ex); + } + } + } else { + logger.warn("An attempt to stop already stopping/stopped discovery client"); + } + } + + @Override + public void send(BytesValue data, NodeRecord recipient) { + if (!(recipient.getIdentityScheme().equals(IdentitySchema.V4))) { + String error = + String.format( + "Accepts only V4 version of recipient's node records. Got %s instead", recipient); + logger.error(error); + throw new RuntimeException(error); + } + InetSocketAddress address; + try { + address = + new InetSocketAddress( + InetAddress.getByAddress(((Bytes4) recipient.get(EnrField.IP_V4)).extractArray()), + (int) recipient.get(EnrField.UDP_V4)); + } catch (UnknownHostException e) { + String error = String.format("Failed to resolve host for node record: %s", recipient); + logger.error(error); + throw new RuntimeException(error); + } + DatagramPacket packet = new DatagramPacket(Unpooled.copiedBuffer(data.extractArray()), address); + logger.trace(() -> String.format("Sending packet %s", packet)); + channel.write(packet); + channel.flush(); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServer.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServer.java new file mode 100644 index 000000000..eb1d39a88 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServer.java @@ -0,0 +1,13 @@ +package org.ethereum.beacon.discovery.network; + +import io.netty.channel.socket.nio.NioDatagramChannel; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** Netty-specific extension of {@link DiscoveryServer}. Made to reuse server channel for client. */ +public interface NettyDiscoveryServer extends DiscoveryServer { + + /** Reuse Netty server channel with client, so you are able to send packets from the same port */ + CompletableFuture useDatagramChannel(Consumer consumer); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServerImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServerImpl.java new file mode 100644 index 000000000..a2baff38e --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NettyDiscoveryServerImpl.java @@ -0,0 +1,136 @@ +package org.ethereum.beacon.discovery.network; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioDatagramChannel; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.schedulers.Scheduler; +import org.reactivestreams.Publisher; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.ReplayProcessor; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public class NettyDiscoveryServerImpl implements NettyDiscoveryServer { + private static final int RECREATION_TIMEOUT = 5000; + private static final int STOPPING_TIMEOUT = 10000; + private static final Logger logger = LogManager.getLogger(NettyDiscoveryServerImpl.class); + private final ReplayProcessor incomingPackets = ReplayProcessor.cacheLast(); + private final FluxSink incomingSink = incomingPackets.sink(); + private final Integer udpListenPort; + private final String udpListenHost; + private AtomicBoolean listen = new AtomicBoolean(true); + private Channel channel; + private NioDatagramChannel datagramChannel; + private Set> datagramChannelUsageQueue = new HashSet<>(); + + public NettyDiscoveryServerImpl(Bytes4 udpListenHost, Integer udpListenPort) { + try { + this.udpListenHost = InetAddress.getByAddress(udpListenHost.extractArray()).getHostAddress(); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + this.udpListenPort = udpListenPort; + } + + @Override + public void start(Scheduler scheduler) { + logger.info(String.format("Starting discovery server on UDP port %s", udpListenPort)); + scheduler.execute(this::serverLoop); + } + + private void serverLoop() { + NioEventLoopGroup group = new NioEventLoopGroup(1); + try { + while (listen.get()) { + Bootstrap b = new Bootstrap(); + b.group(group) + .channel(NioDatagramChannel.class) + .handler( + new ChannelInitializer() { + @Override + public void initChannel(NioDatagramChannel ch) throws Exception { + ch.pipeline() + .addLast(new DatagramToBytesValue()) + .addLast(new IncomingMessageSink(incomingSink)); + synchronized (NettyDiscoveryServerImpl.class) { + datagramChannel = ch; + datagramChannelUsageQueue.forEach( + nioDatagramChannelConsumer -> nioDatagramChannelConsumer.accept(ch)); + } + } + }); + + channel = b.bind(udpListenHost, udpListenPort).sync().channel(); + channel.closeFuture().sync(); + + if (!listen.get()) { + logger.info("Shutting down discovery server"); + break; + } + logger.error("Discovery server closed. Trying to restore after %s seconds delay"); + Thread.sleep(RECREATION_TIMEOUT); + } + } catch (Exception e) { + logger.error("Can't start discovery server", e); + } finally { + try { + group.shutdownGracefully().sync(); + } catch (Exception ex) { + logger.error("Failed to shutdown discovery sever thread group", ex); + } + } + } + + @Override + public Publisher getIncomingPackets() { + return incomingPackets; + } + + /** Reuse Netty server channel with client, so you are able to send packets from the same port */ + @Override + public synchronized CompletableFuture useDatagramChannel( + Consumer consumer) { + CompletableFuture usage = new CompletableFuture<>(); + if (datagramChannel != null) { + consumer.accept(datagramChannel); + usage.complete(null); + } else { + datagramChannelUsageQueue.add( + nioDatagramChannel -> { + consumer.accept(nioDatagramChannel); + usage.complete(null); + }); + } + + return usage; + } + + @Override + public void stop() { + if (listen.get()) { + logger.info("Stopping discovery server"); + listen.set(false); + if (channel != null) { + try { + channel.close().await(STOPPING_TIMEOUT); + } catch (InterruptedException ex) { + logger.error("Failed to stop discovery server", ex); + } + } + } else { + logger.warn("An attempt to stop already stopping/stopped discovery server"); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcel.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcel.java new file mode 100644 index 000000000..97b19cec0 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcel.java @@ -0,0 +1,16 @@ +package org.ethereum.beacon.discovery.network; + +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.Packet; + +/** + * Abstraction on the top of the {@link Packet}. + * + *

Stores `packet` and associated node record. Record could be a sender or recipient, depends on + * session. + */ +public interface NetworkParcel { + Packet getPacket(); + + NodeRecord getNodeRecord(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcelV5.java b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcelV5.java new file mode 100644 index 000000000..2d5f58b47 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/network/NetworkParcelV5.java @@ -0,0 +1,24 @@ +package org.ethereum.beacon.discovery.network; + +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.Packet; + +public class NetworkParcelV5 implements NetworkParcel { + private final Packet packet; + private final NodeRecord nodeRecord; + + public NetworkParcelV5(Packet packet, NodeRecord nodeRecord) { + this.packet = packet; + this.nodeRecord = nodeRecord; + } + + @Override + public Packet getPacket() { + return packet; + } + + @Override + public NodeRecord getNodeRecord() { + return nodeRecord; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AbstractPacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AbstractPacket.java new file mode 100644 index 000000000..4cc19d346 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AbstractPacket.java @@ -0,0 +1,16 @@ +package org.ethereum.beacon.discovery.packet; + +import tech.pegasys.artemis.util.bytes.BytesValue; + +public abstract class AbstractPacket implements Packet { + private final BytesValue bytes; + + AbstractPacket(BytesValue bytes) { + this.bytes = bytes; + } + + @Override + public BytesValue getBytes() { + return bytes; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AuthHeaderMessagePacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AuthHeaderMessagePacket.java new file mode 100644 index 000000000..25ad3d32c --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/AuthHeaderMessagePacket.java @@ -0,0 +1,270 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.RlpUtil; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.message.DiscoveryMessage; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.javatuples.Pair; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import javax.annotation.Nullable; +import java.math.BigInteger; + +/** + * Used as first encrypted message sent in response to WHOAREYOU {@link WhoAreYouPacket}. Contains + * an authentication header completing the handshake. + * + *

Format: + * message-packet = tag || auth-header || message + * auth-header = [auth-tag, id-nonce, auth-scheme-name, ephemeral-pubkey, auth-response] + * auth-scheme-name = "gcm" + * + *

auth-response-pt is encrypted with a separate key, the auth-resp-key, using an all-zero nonce. + * This is safe because only one message is ever encrypted with this key. + * + *

auth-response = aesgcm_encrypt(auth-resp-key, zero-nonce, auth-response-pt, "") + * zero-nonce = 12 zero bytes + * auth-response-pt = [version, id-nonce-sig, node-record] + * version = 5 + * id-nonce-sig = sign(static-node-key, sha256("discovery-id-nonce" || id-nonce)) + * static-node-key = the private key used for node record identity + * node-record = record of sender OR [] if enr-seq in WHOAREYOU != current seq + * message = aesgcm_encrypt(initiator-key, auth-tag, message-pt, tag || auth-header) + * message-pt = message-type || message-data + * auth-tag = AES-GCM nonce, 12 random bytes unique to message + */ +public class AuthHeaderMessagePacket extends AbstractPacket { + public static final String AUTH_SCHEME_NAME = "gcm"; + public static final BytesValue DISCOVERY_ID_NONCE = + BytesValue.wrap("discovery-id-nonce".getBytes()); + private static final BytesValue ZERO_NONCE = BytesValue.wrap(new byte[12]); + private EphemeralPubKeyDecoded decodedEphemeralPubKeyPt = null; + private MessagePtDecoded decodedMessagePt = null; + + public AuthHeaderMessagePacket(BytesValue bytes) { + super(bytes); + } + + public static AuthHeaderMessagePacket create( + Bytes32 tag, BytesValue authHeader, BytesValue messageCipherText) { + return new AuthHeaderMessagePacket(tag.concat(authHeader).concat(messageCipherText)); + } + + public static BytesValue createIdNonceMessage(BytesValue idNonce, BytesValue ephemeralPubkey) { + BytesValue message = DISCOVERY_ID_NONCE.concat(idNonce).concat(ephemeralPubkey); + return message; + } + + public static BytesValue signIdNonce( + BytesValue idNonce, BytesValue staticNodeKey, BytesValue ephemeralPubkey) { + BytesValue signed = + Functions.sign( + staticNodeKey, Functions.hash(createIdNonceMessage(idNonce, ephemeralPubkey))); + return signed; + } + + public static byte[] createAuthMessagePt(BytesValue idNonceSig, @Nullable NodeRecord nodeRecord) { + return RlpEncoder.encode( + new RlpList( + RlpString.create(5), + RlpString.create(idNonceSig.extractArray()), + nodeRecord == null ? new RlpList() : nodeRecord.asRlp())); + } + + public static BytesValue encodeAuthResponse(byte[] authResponsePt, BytesValue authResponseKey) { + return Functions.aesgcm_encrypt( + authResponseKey, ZERO_NONCE, BytesValue.wrap(authResponsePt), BytesValue.EMPTY); + } + + public static BytesValue encodeAuthHeaderRlp( + BytesValue authTag, BytesValue idNonce, BytesValue ephemeralPubkey, BytesValue authResponse) { + RlpList authHeaderRlp = + new RlpList( + RlpString.create(authTag.extractArray()), + RlpString.create(idNonce.extractArray()), + RlpString.create(AUTH_SCHEME_NAME.getBytes()), + RlpString.create(ephemeralPubkey.extractArray()), + RlpString.create(authResponse.extractArray())); + return BytesValue.wrap(RlpEncoder.encode(authHeaderRlp)); + } + + public static AuthHeaderMessagePacket create( + Bytes32 homeNodeId, + Bytes32 destNodeId, + BytesValue authResponseKey, + BytesValue idNonce, + BytesValue staticNodeKey, + @Nullable NodeRecord nodeRecord, + BytesValue ephemeralPubkey, + BytesValue authTag, + BytesValue initiatorKey, + DiscoveryMessage message) { + Bytes32 tag = Packet.createTag(homeNodeId, destNodeId); + BytesValue idNonceSig = signIdNonce(idNonce, staticNodeKey, ephemeralPubkey); + byte[] authResponsePt = createAuthMessagePt(idNonceSig, nodeRecord); + BytesValue authResponse = encodeAuthResponse(authResponsePt, authResponseKey); + BytesValue authHeader = encodeAuthHeaderRlp(authTag, idNonce, ephemeralPubkey, authResponse); + BytesValue encryptedData = + Functions.aesgcm_encrypt(initiatorKey, authTag, message.getBytes(), tag); + return create(tag, authHeader, encryptedData); + } + + public void verify(BytesValue expectedIdNonce, BytesValue remoteNodePubKey) { + verifyDecode(); + assert expectedIdNonce.equals(getIdNonce()); + assert Functions.verifyECDSASignature( + getIdNonceSig(), + Functions.hash(createIdNonceMessage(getIdNonce(), getEphemeralPubkey())), + remoteNodePubKey); + } + + public Bytes32 getHomeNodeId(Bytes32 destNodeId) { + verifyDecode(); + return Bytes32s.xor(Functions.hash(destNodeId), decodedEphemeralPubKeyPt.tag); + } + + public BytesValue getAuthTag() { + verifyDecode(); + return decodedEphemeralPubKeyPt.authTag; + } + + public BytesValue getIdNonce() { + verifyDecode(); + return decodedEphemeralPubKeyPt.idNonce; + } + + public BytesValue getEphemeralPubkey() { + verifyEphemeralPubKeyDecode(); + return decodedEphemeralPubKeyPt.ephemeralPubkey; + } + + public BytesValue getIdNonceSig() { + verifyDecode(); + return decodedMessagePt.idNonceSig; + } + + public NodeRecord getNodeRecord() { + verifyDecode(); + return decodedMessagePt.nodeRecord; + } + + public DiscoveryMessage getMessage() { + verifyDecode(); + return decodedMessagePt.message; + } + + private void verifyEphemeralPubKeyDecode() { + if (decodedEphemeralPubKeyPt == null) { + throw new RuntimeException("You should run decodeEphemeralPubKey before!"); + } + } + + private void verifyDecode() { + if (decodedEphemeralPubKeyPt == null || decodedMessagePt == null) { + throw new RuntimeException("You should run decodeEphemeralPubKey and decodeMessage before!"); + } + } + + public void decodeEphemeralPubKey() { + if (decodedEphemeralPubKeyPt != null) { + return; + } + EphemeralPubKeyDecoded blank = new EphemeralPubKeyDecoded(); + blank.tag = Bytes32.wrap(getBytes().slice(0, 32), 0); + Pair decodeRes = RlpUtil.decodeFirstList(getBytes().slice(32)); + blank.messageEncrypted = decodeRes.getValue1(); + RlpList authHeaderParts = (RlpList) decodeRes.getValue0().getValues().get(0); + // [auth-tag, id-nonce, auth-scheme-name, ephemeral-pubkey, auth-response] + blank.authTag = BytesValue.wrap(((RlpString) authHeaderParts.getValues().get(0)).getBytes()); + blank.idNonce = BytesValue.wrap(((RlpString) authHeaderParts.getValues().get(1)).getBytes()); + assert AUTH_SCHEME_NAME.equals( + new String(((RlpString) authHeaderParts.getValues().get(2)).getBytes())); + blank.ephemeralPubkey = + BytesValue.wrap(((RlpString) authHeaderParts.getValues().get(3)).getBytes()); + blank.authResponse = + BytesValue.wrap(((RlpString) authHeaderParts.getValues().get(4)).getBytes()); + this.decodedEphemeralPubKeyPt = blank; + } + + /** Run {@link AuthHeaderMessagePacket#decodeEphemeralPubKey()} before second part */ + public void decodeMessage( + BytesValue readKey, BytesValue authResponseKey, NodeRecordFactory nodeRecordFactory) { + if (decodedEphemeralPubKeyPt == null) { + throw new RuntimeException("Run decodeEphemeralPubKey() before"); + } + if (decodedMessagePt != null) { + return; + } + MessagePtDecoded blank = new MessagePtDecoded(); + BytesValue authResponsePt = + Functions.aesgcm_decrypt( + authResponseKey, ZERO_NONCE, decodedEphemeralPubKeyPt.authResponse, BytesValue.EMPTY); + RlpList authResponsePtParts = + (RlpList) RlpDecoder.decode(authResponsePt.extractArray()).getValues().get(0); + assert BigInteger.valueOf(5) + .equals(((RlpString) authResponsePtParts.getValues().get(0)).asPositiveBigInteger()); + blank.idNonceSig = + BytesValue.wrap(((RlpString) authResponsePtParts.getValues().get(1)).getBytes()); + RlpList nodeRecordDataList = ((RlpList) authResponsePtParts.getValues().get(2)); + blank.nodeRecord = + nodeRecordDataList.getValues().isEmpty() + ? null + : nodeRecordFactory.fromRlpList(nodeRecordDataList); + blank.message = + new DiscoveryV5Message( + Functions.aesgcm_decrypt( + readKey, + decodedEphemeralPubKeyPt.authTag, + decodedEphemeralPubKeyPt.messageEncrypted, + decodedEphemeralPubKeyPt.tag)); + this.decodedMessagePt = blank; + } + + @Override + public String toString() { + StringBuilder res = new StringBuilder("AuthHeaderMessagePacket{"); + if (decodedEphemeralPubKeyPt != null) { + res.append("tag=") + .append(decodedEphemeralPubKeyPt.tag) + .append(", authTag=") + .append(decodedEphemeralPubKeyPt.authTag) + .append(", idNonce=") + .append(decodedEphemeralPubKeyPt.idNonce) + .append(", ephemeralPubkey=") + .append(decodedEphemeralPubKeyPt.ephemeralPubkey); + } + if (decodedMessagePt != null) { + res.append(", idNonceSig=") + .append(decodedMessagePt.idNonceSig) + .append(", nodeRecord=") + .append(decodedMessagePt.nodeRecord) + .append(", message=") + .append(decodedMessagePt.message); + } + res.append('}'); + return res.toString(); + } + + private static class EphemeralPubKeyDecoded { + private Bytes32 tag; + private BytesValue authTag; + private BytesValue idNonce; + private BytesValue ephemeralPubkey; + private BytesValue authResponse; + private BytesValue messageEncrypted; + } + + private static class MessagePtDecoded { + private BytesValue idNonceSig; + private NodeRecord nodeRecord; + private DiscoveryMessage message; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/MessagePacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/MessagePacket.java new file mode 100644 index 000000000..2ea612a2b --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/MessagePacket.java @@ -0,0 +1,114 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.message.DiscoveryMessage; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** + * Used when handshake is completed as a {@link DiscoveryMessage} authenticated container + * + *

Format: + * message-packet = tag || rlp_bytes(auth-tag) || message + * message = aesgcm_encrypt(initiator-key, auth-tag, message-pt, tag) + * message-pt = message-type || message-data + */ +public class MessagePacket extends AbstractPacket { + private MessagePacketDecoded decoded = null; + private BytesValue initiatorKey = null; + + public MessagePacket(BytesValue bytes) { + super(bytes); + } + + public static MessagePacket create( + Bytes32 tag, BytesValue authTag, BytesValue messageCipherText) { + byte[] authTagBytesRlp = RlpEncoder.encode(RlpString.create(authTag.extractArray())); + BytesValue authTagEncoded = BytesValue.wrap(authTagBytesRlp); + return new MessagePacket(tag.concat(authTagEncoded).concat(messageCipherText)); + } + + public static MessagePacket create( + Bytes32 homeNodeId, + Bytes32 destNodeId, + BytesValue authTag, + BytesValue initiatorKey, + DiscoveryMessage message) { + Bytes32 tag = Packet.createTag(homeNodeId, destNodeId); + BytesValue encryptedData = + Functions.aesgcm_encrypt(initiatorKey, authTag, message.getBytes(), tag); + return create(tag, authTag, encryptedData); + } + + public void verify(BytesValue expectedAuthTag) {} + + public Bytes32 getHomeNodeId(Bytes32 destNodeId) { + verifyDecode(); + return Bytes32s.xor(Functions.hash(destNodeId), decoded.tag); + } + + public BytesValue getAuthTag() { + if (decoded == null) { + return BytesValue.wrap( + ((RlpString) + RlpDecoder.decode(getBytes().slice(32, 13).extractArray()).getValues().get(0)) + .getBytes()); + } + return decoded.authTag; + } + + public DiscoveryMessage getMessage() { + verifyDecode(); + return decoded.message; + } + + private void verifyDecode() { + if (decoded == null) { + throw new RuntimeException("You should decode packet at first!"); + } + } + + public void decode(BytesValue readKey) { + if (decoded != null) { + return; + } + MessagePacketDecoded blank = new MessagePacketDecoded(); + blank.tag = Bytes32.wrap(getBytes().slice(0, 32), 0); + blank.authTag = + BytesValue.wrap( + ((RlpString) + RlpDecoder.decode(getBytes().slice(32, 13).extractArray()).getValues().get(0)) + .getBytes()); + blank.message = + new DiscoveryV5Message( + Functions.aesgcm_decrypt(readKey, blank.authTag, getBytes().slice(45), blank.tag)); + this.decoded = blank; + } + + @Override + public String toString() { + if (decoded != null) { + return "MessagePacket{" + + "tag=" + + decoded.tag + + ", authTag=" + + decoded.authTag + + ", message=" + + decoded.message + + '}'; + } else { + return "MessagePacket{" + getBytes() + '}'; + } + } + + private static class MessagePacketDecoded { + private Bytes32 tag; + private BytesValue authTag; + private DiscoveryMessage message; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/Packet.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/Packet.java new file mode 100644 index 000000000..f39fdd650 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/Packet.java @@ -0,0 +1,18 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.discovery.Functions; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** + * Network packet as defined by discovery v5 specification. See https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire.md#packet-encoding + */ +public interface Packet { + static Bytes32 createTag(Bytes32 homeNodeId, Bytes32 destNodeId) { + return Bytes32s.xor(Functions.hash(destNodeId), homeNodeId); + } + + BytesValue getBytes(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/RandomPacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/RandomPacket.java new file mode 100644 index 000000000..fc8bca348 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/RandomPacket.java @@ -0,0 +1,85 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.discovery.Functions; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.Random; + +/** + * Sent if no session keys are available to initiate handshake + * + *

Format: + * random-packet = tag || rlp_bytes(auth-tag) || random-data + * auth-tag = 12 random bytes unique to message + * random-data = at least 44 bytes of random data + */ +public class RandomPacket extends AbstractPacket { + public static final int MIN_RANDOM_BYTES = 44; + private RandomPacketDecoded decoded = null; + + public RandomPacket(BytesValue bytes) { + super(bytes); + } + + public static RandomPacket create( + Bytes32 homeNodeId, Bytes32 destNodeId, BytesValue authTag, BytesValue randomBytes) { + Bytes32 tag = Packet.createTag(homeNodeId, destNodeId); + return create(tag, authTag, randomBytes); + } + + public static RandomPacket create(Bytes32 tag, BytesValue authTag, BytesValue randomBytes) { + assert randomBytes.size() >= MIN_RANDOM_BYTES; // At least 44 bytes, spec defined + byte[] authTagRlp = RlpEncoder.encode(RlpString.create(authTag.extractArray())); + BytesValue authTagEncoded = BytesValue.wrap(authTagRlp); + return new RandomPacket(tag.concat(authTagEncoded).concat(randomBytes)); + } + + public static RandomPacket create( + Bytes32 homeNodeId, Bytes32 destNodeId, BytesValue authTag, Random rnd) { + byte[] randomBytes = new byte[MIN_RANDOM_BYTES]; + rnd.nextBytes(randomBytes); // at least 44 bytes of random data, spec defined + return create(homeNodeId, destNodeId, authTag, BytesValue.wrap(randomBytes)); + } + + public Bytes32 getHomeNodeId(Bytes32 destNodeId) { + decode(); + return Bytes32s.xor(Functions.hash(destNodeId), decoded.tag); + } + + public BytesValue getAuthTag() { + decode(); + return decoded.authTag; + } + + private synchronized void decode() { + if (decoded != null) { + return; + } + RandomPacketDecoded blank = new RandomPacketDecoded(); + blank.tag = Bytes32.wrap(getBytes().slice(0, 32), 0); + blank.authTag = + BytesValue.wrap( + ((RlpString) RlpDecoder.decode(getBytes().slice(32).extractArray()).getValues().get(0)) + .getBytes()); + this.decoded = blank; + } + + @Override + public String toString() { + if (decoded != null) { + return "RandomPacket{" + "tag=" + decoded.tag + ", authTag=" + decoded.authTag + '}'; + } else { + return "RandomPacket{" + getBytes() + '}'; + } + } + + private static class RandomPacketDecoded { + private Bytes32 tag; + private BytesValue authTag; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/UnknownPacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/UnknownPacket.java new file mode 100644 index 000000000..84148408f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/UnknownPacket.java @@ -0,0 +1,63 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.crypto.Hashes; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes32s; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Default packet form until its goal is known */ +public class UnknownPacket extends AbstractPacket { + private static final int MAX_SIZE = 1280; + + public UnknownPacket(BytesValue bytes) { + super(bytes); + } + + public MessagePacket getMessagePacket() { + return new MessagePacket(getBytes()); + } + + public AuthHeaderMessagePacket getAuthHeaderMessagePacket() { + return new AuthHeaderMessagePacket(getBytes()); + } + + public RandomPacket getRandomPacket() { + return new RandomPacket(getBytes()); + } + + public WhoAreYouPacket getWhoAreYouPacket() { + return new WhoAreYouPacket(getBytes()); + } + + public boolean isWhoAreYouPacket(Bytes32 destNodeId) { + return WhoAreYouPacket.getStartMagic(destNodeId).equals(getBytes().slice(0, 32)); + } + + // tag = xor(sha256(dest-node-id), src-node-id) + // dest-node-id = 32-byte node ID of B + // src-node-id = 32-byte node ID of A + // + // The recipient can recover the sender's ID by performing the same calculation in reverse. + // + // src-node-id = xor(sha256(dest-node-id), tag) + public Bytes32 getSourceNodeId(Bytes32 destNodeId) { + assert !isWhoAreYouPacket(destNodeId); + BytesValue xorTag = getBytes().slice(0, 32); + return Bytes32s.xor(Hashes.sha256(destNodeId), Bytes32.wrap(xorTag, 0)); + } + + public void verify() { + if (getBytes().size() > MAX_SIZE) { + throw new RuntimeException(String.format("Packets should not exceed %s bytes", MAX_SIZE)); + } + } + + @Override + public String toString() { + return "UnknownPacket{" + + (getBytes().size() < 200 + ? getBytes() + : getBytes().slice(0, 190) + "..." + "(" + getBytes().size() + " bytes)") + + "}"; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/packet/WhoAreYouPacket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/WhoAreYouPacket.java new file mode 100644 index 000000000..b1fec77e4 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/packet/WhoAreYouPacket.java @@ -0,0 +1,118 @@ +package org.ethereum.beacon.discovery.packet; + +import org.ethereum.beacon.discovery.Functions; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.Bytes8; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +/** + * The WHOAREYOU packet, used during the handshake as a response to any message received from + * unknown host + * + *

Format: + * whoareyou-packet = magic || [token, id-nonce, enr-seq] + * magic = sha256(dest-node-id || "WHOAREYOU") + * token = auth-tag of request + * id-nonce = 32 random bytes + * enr-seq = highest ENR sequence number of node A known on node B's side + */ +public class WhoAreYouPacket extends AbstractPacket { + private static final BytesValue MAGIC_BYTES = BytesValue.wrap("WHOAREYOU".getBytes()); + private WhoAreYouDecoded decoded = null; + + public WhoAreYouPacket(BytesValue bytes) { + super(bytes); + } + + /** + * Create a packet by converting {@code destNodeId} to a magic value + */ + public static WhoAreYouPacket createFromNodeId( + Bytes32 destNodeId, BytesValue authTag, Bytes32 idNonce, UInt64 enrSeq) { + BytesValue magic = getStartMagic(destNodeId); + return createFromMagic(magic, authTag, idNonce, enrSeq); + } + + public static WhoAreYouPacket createFromMagic( + BytesValue magic, BytesValue authTag, Bytes32 idNonce, UInt64 enrSeq) { + byte[] rlpListEncoded = + RlpEncoder.encode( + new RlpList( + RlpString.create(authTag.extractArray()), + RlpString.create(idNonce.extractArray()), + RlpString.create(enrSeq.toBI()))); + return new WhoAreYouPacket(magic.concat(BytesValue.wrap(rlpListEncoded))); + } + + /** Calculates first 32 bytes of WHOAREYOU packet */ + public static BytesValue getStartMagic(Bytes32 destNodeId) { + return Functions.hash(destNodeId.concat(MAGIC_BYTES)); + } + + public BytesValue getAuthTag() { + decode(); + return decoded.authTag; + } + + public Bytes32 getIdNonce() { + decode(); + return decoded.idNonce; + } + + public UInt64 getEnrSeq() { + decode(); + return decoded.enrSeq; + } + + public void verify(Bytes32 destNodeId, BytesValue expectedAuthTag) { + decode(); + assert Functions.hash(destNodeId.concat(MAGIC_BYTES)).equals(decoded.magic); + assert expectedAuthTag.equals(getAuthTag()); + } + + private synchronized void decode() { + if (decoded != null) { + return; + } + WhoAreYouDecoded blank = new WhoAreYouDecoded(); + blank.magic = Bytes32.wrap(getBytes().slice(0, 32), 0); + RlpList payload = + (RlpList) RlpDecoder.decode(getBytes().slice(32).extractArray()).getValues().get(0); + blank.authTag = BytesValue.wrap(((RlpString) payload.getValues().get(0)).getBytes()); + blank.idNonce = Bytes32.wrap(((RlpString) payload.getValues().get(1)).getBytes()); + blank.enrSeq = + UInt64.fromBytesBigEndian( + Bytes8.leftPad(BytesValue.wrap(((RlpString) payload.getValues().get(2)).getBytes()))); + this.decoded = blank; + } + + @Override + public String toString() { + if (decoded != null) { + return "WhoAreYou{" + + "magic=" + + decoded.magic + + ", authTag=" + + decoded.authTag + + ", idNonce=" + + decoded.idNonce + + ", enrSeq=" + + decoded.enrSeq + + '}'; + } else { + return "WhoAreYou{" + getBytes() + '}'; + } + } + + private static class WhoAreYouDecoded { + private Bytes32 magic; + private BytesValue authTag; + private Bytes32 idNonce; + private UInt64 enrSeq; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Envelope.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Envelope.java new file mode 100644 index 000000000..c3d703cbf --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Envelope.java @@ -0,0 +1,35 @@ +package org.ethereum.beacon.discovery.pipeline; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** Container for any kind of objects used in packet-messages-tasks flow */ +public class Envelope { + private UUID id; + private Map data = new HashMap<>(); + + public Envelope() { + this.id = UUID.randomUUID(); + } + + public synchronized void put(Field key, Object value) { + data.put(key, value); + } + + public synchronized Object get(Field key) { + return data.get(key); + } + + public synchronized boolean remove(Field key) { + return data.remove(key) != null; + } + + public synchronized boolean contains(Field key) { + return data.containsKey(key); + } + + public UUID getId() { + return id; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/EnvelopeHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/EnvelopeHandler.java new file mode 100644 index 000000000..6f3fee096 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/EnvelopeHandler.java @@ -0,0 +1,5 @@ +package org.ethereum.beacon.discovery.pipeline; + +public interface EnvelopeHandler { + void handle(Envelope envelope); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Field.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Field.java new file mode 100644 index 000000000..52acbb5ab --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Field.java @@ -0,0 +1,19 @@ +package org.ethereum.beacon.discovery.pipeline; + +public enum Field { + SESSION_LOOKUP, // Node id, requests session lookup + SESSION, // Node session + INCOMING, // Raw incoming data + PACKET_UNKNOWN, // Unknown packet + PACKET_WHOAREYOU, // WhoAreYou packet + PACKET_AUTH_HEADER_MESSAGE, // Auth header message packet + PACKET_MESSAGE, // Standard message packet + MESSAGE, // Message extracted from the packet + NODE, // Sender/recipient node + BAD_PACKET, // Bad, rejected packet + BAD_MESSAGE, // Bad, rejected message + BAD_EXCEPTION, // Stores exception for bad packet or message + TASK, // Task to perform + TASK_OPTIONS, // Task options + FUTURE, // Completable future +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/HandlerUtil.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/HandlerUtil.java new file mode 100644 index 000000000..4916918e2 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/HandlerUtil.java @@ -0,0 +1,30 @@ +package org.ethereum.beacon.discovery.pipeline; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.function.Function; + +public class HandlerUtil { + private static final Logger logger = LogManager.getLogger(HandlerUtil.class); + + public static boolean requireField(Field field, Envelope envelope) { + if (envelope.contains(field)) { + return true; + } else { + logger.trace(() -> String.format("Requirement not satisfied: field %s not exists in envelope %s", + field, envelope.getId())); + return false; + } + } + + public static boolean requireCondition(Function conditionFunction, Envelope envelope) { + if (conditionFunction.apply(envelope)) { + return true; + } else { + logger.trace(() -> String.format("Requirement not satisfied: condition %s not met for envelope %s", + conditionFunction, envelope.getId())); + return false; + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Pipeline.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Pipeline.java new file mode 100644 index 000000000..aa485126e --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/Pipeline.java @@ -0,0 +1,21 @@ +package org.ethereum.beacon.discovery.pipeline; + +import org.reactivestreams.Publisher; + +/** + * Pipeline uses several {@link EnvelopeHandler} handlers to pass objects through the chain of + * linked handlers implementing pipeline (or chain of responsibility) pattern. + */ +public interface Pipeline { + /** Builds configured pipeline making it active */ + Pipeline build(); + + /** Pushes object inside pipeline */ + void push(Object object); + + /** Adds handler at the end of current chain */ + Pipeline addHandler(EnvelopeHandler envelopeHandler); + + /** Stream from the exit of built pipeline */ + Publisher getOutgoingEnvelopes(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/PipelineImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/PipelineImpl.java new file mode 100644 index 000000000..baafbde25 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/PipelineImpl.java @@ -0,0 +1,59 @@ +package org.ethereum.beacon.discovery.pipeline; + +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.ReplayProcessor; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.ethereum.beacon.discovery.pipeline.Field.INCOMING; + +public class PipelineImpl implements Pipeline { + private final List envelopeHandlers = new ArrayList<>(); + private final AtomicBoolean started = new AtomicBoolean(false); + private Flux pipeline = ReplayProcessor.cacheLast(); + private final FluxSink pipelineSink = ((ReplayProcessor) pipeline).sink(); + private Disposable subscription; + + @Override + public synchronized Pipeline build() { + started.set(true); + for (EnvelopeHandler handler : envelopeHandlers) { + pipeline = pipeline.doOnNext(handler::handle); + } + this.subscription = Flux.from(pipeline).subscribe(); + return this; + } + + @Override + public void push(Object object) { + if (!started.get()) { + throw new RuntimeException("You should build pipeline first"); + } + if (!(object instanceof Envelope)) { + Envelope envelope = new Envelope(); + envelope.put(INCOMING, object); + pipelineSink.next(envelope); + } else { + pipelineSink.next((Envelope) object); + } + } + + @Override + public Pipeline addHandler(EnvelopeHandler envelopeHandler) { + if (started.get()) { + throw new RuntimeException("Pipeline already started, couldn't add any handlers"); + } + envelopeHandlers.add(envelopeHandler); + return this; + } + + @Override + public Publisher getOutgoingEnvelopes() { + return pipeline; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/AuthHeaderMessagePacketHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/AuthHeaderMessagePacketHandler.java new file mode 100644 index 000000000..1d235733b --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/AuthHeaderMessagePacketHandler.java @@ -0,0 +1,93 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.schedulers.Scheduler; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import static org.ethereum.beacon.discovery.NodeSession.SessionStatus.AUTHENTICATED; + +/** Handles {@link AuthHeaderMessagePacket} in {@link Field#PACKET_AUTH_HEADER_MESSAGE} field */ +public class AuthHeaderMessagePacketHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(AuthHeaderMessagePacketHandler.class); + private final Pipeline outgoingPipeline; + private final Scheduler scheduler; + private final NodeRecordFactory nodeRecordFactory; + + public AuthHeaderMessagePacketHandler( + Pipeline outgoingPipeline, Scheduler scheduler, NodeRecordFactory nodeRecordFactory) { + this.outgoingPipeline = outgoingPipeline; + this.scheduler = scheduler; + this.nodeRecordFactory = nodeRecordFactory; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in AuthHeaderMessagePacketHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_AUTH_HEADER_MESSAGE, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in AuthHeaderMessagePacketHandler, requirements are satisfied!", + envelope.getId())); + + AuthHeaderMessagePacket packet = + (AuthHeaderMessagePacket) envelope.get(Field.PACKET_AUTH_HEADER_MESSAGE); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + try { + packet.decodeEphemeralPubKey(); + BytesValue ephemeralPubKey = packet.getEphemeralPubkey(); + Functions.HKDFKeys keys = + Functions.hkdf_expand( + session.getNodeRecord().getNodeId(), + session.getHomeNodeId(), + session.getStaticNodeKey(), + ephemeralPubKey, + session.getIdNonce()); + // Swap keys because we are not initiator, other side is + session.setInitiatorKey(keys.getRecipientKey()); + session.setRecipientKey(keys.getInitiatorKey()); + packet.decodeMessage(session.getRecipientKey(), keys.getAuthResponseKey(), nodeRecordFactory); + packet.verify( + session.getIdNonce(), + (BytesValue) session.getNodeRecord().get(EnrFieldV4.PKEY_SECP256K1)); + envelope.put(Field.MESSAGE, packet.getMessage()); + } catch (AssertionError ex) { + logger.info( + String.format( + "Verification not passed for message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus())); + } catch (Exception ex) { + String error = + String.format( + "Failed to read message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus()); + logger.error(error, ex); + envelope.remove(Field.PACKET_AUTH_HEADER_MESSAGE); + session.cancelAllRequests("Failed to handshake"); + return; + } + session.setStatus(AUTHENTICATED); + envelope.remove(Field.PACKET_AUTH_HEADER_MESSAGE); + NextTaskHandler.tryToSendAwaitTaskIfAny(session, outgoingPipeline, scheduler); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/BadPacketHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/BadPacketHandler.java new file mode 100644 index 000000000..31fb91dd6 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/BadPacketHandler.java @@ -0,0 +1,38 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; + +/** Handles packet from {@link Field#BAD_PACKET}. Currently just logs it. */ +public class BadPacketHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(BadPacketHandler.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in BadPacketLogger, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.BAD_PACKET, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in BadPacketLogger, requirements are satisfied!", envelope.getId())); + + logger.debug( + () -> + String.format( + "Bad packet: %s in envelope #%s", envelope.get(Field.BAD_PACKET), envelope.getId()), + envelope.get(Field.BAD_EXCEPTION) == null + ? null + : (Exception) envelope.get(Field.BAD_EXCEPTION)); + // TODO: Reputation penalty etc + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/IncomingDataPacker.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/IncomingDataPacker.java new file mode 100644 index 000000000..3b0ece51e --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/IncomingDataPacker.java @@ -0,0 +1,49 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Handles raw BytesValue incoming data in {@link Field#INCOMING} */ +public class IncomingDataPacker implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(IncomingDataPacker.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in IncomingDataPacker, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.INCOMING, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in IncomingDataPacker, requirements are satisfied!", + envelope.getId())); + + UnknownPacket unknownPacket = new UnknownPacket((BytesValue) envelope.get(Field.INCOMING)); + try { + unknownPacket.verify(); + envelope.put(Field.PACKET_UNKNOWN, unknownPacket); + logger.trace( + () -> + String.format("Incoming packet %s in envelope #%s", unknownPacket, envelope.getId())); + } catch(Exception ex) { + envelope.put(Field.BAD_PACKET, unknownPacket); + envelope.put(Field.BAD_EXCEPTION, ex); + envelope.put(Field.BAD_MESSAGE, "Incoming packet verification not passed"); + logger.trace( + () -> + String.format("Bad incoming packet %s in envelope #%s", unknownPacket, envelope.getId())); + } + envelope.remove(Field.INCOMING); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessageHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessageHandler.java new file mode 100644 index 000000000..1d5071e7e --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessageHandler.java @@ -0,0 +1,58 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.DiscoveryV5MessageProcessor; +import org.ethereum.beacon.discovery.MessageProcessor; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.message.DiscoveryMessage; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; + +public class MessageHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(MessageHandler.class); + private final MessageProcessor messageProcessor; + + public MessageHandler(NodeRecordFactory nodeRecordFactory) { + this.messageProcessor = + new MessageProcessor(new DiscoveryV5MessageProcessor(nodeRecordFactory)); + ; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in MessageHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.MESSAGE, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in MessageHandler, requirements are satisfied!", envelope.getId())); + + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + DiscoveryMessage message = (DiscoveryMessage) envelope.get(Field.MESSAGE); + try { + messageProcessor.handleIncoming(message, session); + } catch (Exception ex) { + logger.trace( + () -> + String.format( + "Failed to handle message %s in envelope #%s", message, envelope.getId()), + ex); + envelope.put(Field.BAD_MESSAGE, message); + envelope.put(Field.BAD_EXCEPTION, ex); + envelope.remove(Field.MESSAGE); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessagePacketHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessagePacketHandler.java new file mode 100644 index 000000000..e5809be7c --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/MessagePacketHandler.java @@ -0,0 +1,61 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; + +/** Handles {@link MessagePacket} in {@link Field#PACKET_MESSAGE} field */ +public class MessagePacketHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(MessagePacketHandler.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in MessagePacketHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_MESSAGE, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in MessagePacketHandler, requirements are satisfied!", + envelope.getId())); + + MessagePacket packet = (MessagePacket) envelope.get(Field.PACKET_MESSAGE); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + + try { + packet.decode(session.getRecipientKey()); + envelope.put(Field.MESSAGE, packet.getMessage()); + } catch (AssertionError ex) { + logger.error( + String.format( + "Verification not passed for message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus())); + envelope.remove(Field.PACKET_MESSAGE); + envelope.put(Field.BAD_PACKET, packet); + return; + } catch (Exception ex) { + String error = + String.format( + "Failed to read message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus()); + logger.error(error, ex); + envelope.remove(Field.PACKET_MESSAGE); + envelope.put(Field.BAD_PACKET, packet); + return; + } + envelope.remove(Field.PACKET_MESSAGE); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NewTaskHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NewTaskHandler.java new file mode 100644 index 000000000..20d818fc4 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NewTaskHandler.java @@ -0,0 +1,52 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; + +import java.util.concurrent.CompletableFuture; + +/** Enqueues task in session for any task found in {@link Field#TASK} */ +public class NewTaskHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(NewTaskHandler.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in NewTaskHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.TASK, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.TASK_OPTIONS, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.FUTURE, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in NewTaskHandler, requirements are satisfied!", envelope.getId())); + + TaskType task = (TaskType) envelope.get(Field.TASK); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + CompletableFuture completableFuture = + (CompletableFuture) envelope.get(Field.FUTURE); + TaskOptions taskOptions = (TaskOptions) envelope.get(Field.TASK_OPTIONS); + session.createNextRequest(task, taskOptions, completableFuture); + envelope.remove(Field.TASK); + envelope.remove(Field.FUTURE); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NextTaskHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NextTaskHandler.java new file mode 100644 index 000000000..8d9467b24 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NextTaskHandler.java @@ -0,0 +1,99 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.pipeline.info.GeneralRequestInfo; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfo; +import org.ethereum.beacon.discovery.task.TaskMessageFactory; +import org.ethereum.beacon.discovery.task.TaskStatus; +import org.ethereum.beacon.schedulers.Scheduler; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Optional; + +/** Gets next request task in session and processes it */ +public class NextTaskHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(NextTaskHandler.class); + private static final int DEFAULT_DELAY_MS = 1000; + private final Pipeline outgoingPipeline; + private final Scheduler scheduler; + + public NextTaskHandler(Pipeline outgoingPipeline, Scheduler scheduler) { + this.outgoingPipeline = outgoingPipeline; + this.scheduler = scheduler; + } + + public static void tryToSendAwaitTaskIfAny( + NodeSession session, Pipeline outgoingPipeline, Scheduler scheduler) { + if (session.getFirstAwaitRequestInfo().isPresent()) { + Envelope dummy = new Envelope(); + dummy.put(Field.SESSION, session); + scheduler.executeWithDelay( + Duration.ofMillis(DEFAULT_DELAY_MS), () -> outgoingPipeline.push(dummy)); + } + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in NextTaskHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in NextTaskHandler, requirements are satisfied!", envelope.getId())); + + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + Optional requestInfoOpt = session.getFirstAwaitRequestInfo(); + if (!requestInfoOpt.isPresent()) { + logger.trace(() -> String.format("Envelope %s: no awaiting requests", envelope.getId())); + return; + } + + RequestInfo requestInfo = requestInfoOpt.get(); + logger.trace( + () -> + String.format( + "Envelope %s: processing awaiting request %s", envelope.getId(), requestInfo)); + BytesValue authTag = session.generateNonce(); + BytesValue requestId = requestInfo.getRequestId(); + if (session.getStatus().equals(NodeSession.SessionStatus.INITIAL)) { + RandomPacket randomPacket = + RandomPacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + authTag, + new SecureRandom()); + session.setAuthTag(authTag); + session.sendOutgoing(randomPacket); + session.setStatus(NodeSession.SessionStatus.RANDOM_PACKET_SENT); + } else if (session.getStatus().equals(NodeSession.SessionStatus.AUTHENTICATED)) { + MessagePacket messagePacket = + TaskMessageFactory.createPacketFromRequest(requestInfo, authTag, session); + session.sendOutgoing(messagePacket); + RequestInfo sentRequestInfo = + new GeneralRequestInfo( + requestInfo.getTaskType(), + TaskStatus.SENT, + requestInfo.getRequestId(), + requestInfo.getFuture()); + session.updateRequestInfo(requestId, sentRequestInfo); + tryToSendAwaitTaskIfAny(session, outgoingPipeline, scheduler); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeIdToSession.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeIdToSession.java new file mode 100644 index 000000000..0b301dd43 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeIdToSession.java @@ -0,0 +1,135 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.network.NetworkParcelV5; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTable; +import org.ethereum.beacon.util.ExpirationScheduler; +import org.javatuples.Pair; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Performs {@link Field#SESSION_LOOKUP} request. Looks up for Node session based on NodeId, which + * should be in request field and stores it in {@link Field#SESSION} field. + */ +public class NodeIdToSession implements EnvelopeHandler { + private static final int CLEANUP_DELAY_SECONDS = 180; + private static final Logger logger = LogManager.getLogger(NodeIdToSession.class); + private final NodeRecord homeNodeRecord; + private final BytesValue staticNodeKey; + private final NodeBucketStorage nodeBucketStorage; + private final AuthTagRepository authTagRepo; + private final Map recentSessions = + new ConcurrentHashMap<>(); // nodeId -> session + private final NodeTable nodeTable; + private final Pipeline outgoingPipeline; + private ExpirationScheduler sessionExpirationScheduler = + new ExpirationScheduler<>(CLEANUP_DELAY_SECONDS, TimeUnit.SECONDS); + + public NodeIdToSession( + NodeRecord homeNodeRecord, + BytesValue staticNodeKey, + NodeBucketStorage nodeBucketStorage, + AuthTagRepository authTagRepo, + NodeTable nodeTable, + Pipeline outgoingPipeline) { + this.homeNodeRecord = homeNodeRecord; + this.staticNodeKey = staticNodeKey; + this.nodeBucketStorage = nodeBucketStorage; + this.authTagRepo = authTagRepo; + this.nodeTable = nodeTable; + this.outgoingPipeline = outgoingPipeline; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in NodeIdToSession, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.SESSION_LOOKUP, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in NodeIdToSession, requirements are satisfied!", envelope.getId())); + + Pair sessionRequest = + (Pair) envelope.get(Field.SESSION_LOOKUP); + envelope.remove(Field.SESSION_LOOKUP); + logger.trace( + () -> + String.format( + "Envelope %s: Session lookup requested for nodeId %s", + envelope.getId(), sessionRequest.getValue0())); + Optional nodeSessionOptional = getSession(sessionRequest.getValue0()); + if (nodeSessionOptional.isPresent()) { + envelope.put(Field.SESSION, nodeSessionOptional.get()); + logger.trace( + () -> + String.format( + "Session resolved: %s in envelope #%s", + nodeSessionOptional.get(), envelope.getId())); + } else { + logger.debug( + () -> + String.format( + "Envelope %s: Session not resolved for nodeId %s", + envelope.getId(), sessionRequest.getValue0())); + sessionRequest.getValue1().run(); + } + } + + private Optional getSession(Bytes32 nodeId) { + NodeSession context = recentSessions.get(nodeId); + if (context == null) { + Optional nodeOptional = nodeTable.getNode(nodeId); + if (!nodeOptional.isPresent()) { + logger.trace( + () -> String.format("Couldn't find node record for nodeId %s, ignoring", nodeId)); + return Optional.empty(); + } + NodeRecord nodeRecord = nodeOptional.get().getNode(); + SecureRandom random = new SecureRandom(); + context = + new NodeSession( + nodeRecord, + homeNodeRecord, + staticNodeKey, + nodeTable, + nodeBucketStorage, + authTagRepo, + packet -> outgoingPipeline.push(new NetworkParcelV5(packet, nodeRecord)), + random); + recentSessions.put(nodeId, context); + } + + final NodeSession contextBackup = context; + sessionExpirationScheduler.put( + context.getNodeRecord().getNodeId(), + () -> { + recentSessions.remove(contextBackup.getNodeRecord().getNodeId()); + contextBackup.cleanup(); + }); + return Optional.of(context); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeSessionRequestHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeSessionRequestHandler.java new file mode 100644 index 000000000..47362c042 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NodeSessionRequestHandler.java @@ -0,0 +1,39 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.javatuples.Pair; + +/** + * Searches for node in {@link Field#NODE} and requests session resolving using {@link + * Field#SESSION_LOOKUP} + */ +public class NodeSessionRequestHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(NodeSessionRequestHandler.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in NodeSessionRequestHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.NODE, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in NodeSessionRequestHandler, requirements are satisfied!", + envelope.getId())); + + envelope.put( + Field.SESSION_LOOKUP, + Pair.with(((NodeRecord) envelope.get(Field.NODE)).getNodeId(), (Runnable) () -> {})); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NotExpectedIncomingPacketHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NotExpectedIncomingPacketHandler.java new file mode 100644 index 000000000..af006a8c9 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/NotExpectedIncomingPacketHandler.java @@ -0,0 +1,88 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Handles {@link UnknownPacket} from node, which is not on any stage of the handshake with us */ +public class NotExpectedIncomingPacketHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(NotExpectedIncomingPacketHandler.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in NotExpectedIncomingPacketHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_UNKNOWN, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in NotExpectedIncomingPacketHandler, requirements are satisfied!", + envelope.getId())); + + UnknownPacket unknownPacket = (UnknownPacket) envelope.get(Field.PACKET_UNKNOWN); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + try { + // packet it either random or message packet if session is expired + BytesValue authTag = null; + try { + RandomPacket randomPacket = unknownPacket.getRandomPacket(); + authTag = randomPacket.getAuthTag(); + } catch (Exception ex) { + // Not fatal, 1st attempt + } + // 2nd attempt + if (authTag == null) { + MessagePacket messagePacket = unknownPacket.getMessagePacket(); + authTag = messagePacket.getAuthTag(); + } + session.setAuthTag(authTag); + byte[] idNonceBytes = new byte[32]; + Functions.getRandom().nextBytes(idNonceBytes); + Bytes32 idNonce = Bytes32.wrap(idNonceBytes); + session.setIdNonce(idNonce); + WhoAreYouPacket whoAreYouPacket = + WhoAreYouPacket.createFromNodeId( + session.getNodeRecord().getNodeId(), + authTag, + idNonce, + session.getNodeRecord().getSeq()); + session.sendOutgoing(whoAreYouPacket); + } catch (AssertionError ex) { + logger.info( + String.format( + "Verification not passed for message [%s] from node %s in status %s", + unknownPacket, session.getNodeRecord(), session.getStatus())); + } catch (Exception ex) { + String error = + String.format( + "Failed to read message [%s] from node %s in status %s", + unknownPacket, session.getNodeRecord(), session.getStatus()); + logger.error(error, ex); + envelope.put(Field.BAD_PACKET, envelope.get(Field.PACKET_UNKNOWN)); + envelope.put(Field.BAD_EXCEPTION, ex); + envelope.remove(Field.PACKET_UNKNOWN); + return; + } + session.setStatus(NodeSession.SessionStatus.WHOAREYOU_SENT); + envelope.remove(Field.PACKET_UNKNOWN); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/OutgoingParcelHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/OutgoingParcelHandler.java new file mode 100644 index 000000000..29b936cda --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/OutgoingParcelHandler.java @@ -0,0 +1,47 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.network.NetworkParcel; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import reactor.core.publisher.FluxSink; + +/** + * Looks up for {@link NetworkParcel} in {@link Field#INCOMING} field. If it's found, it shows that + * we have outgoing parcel at the very first stage. Handler pushes it to `outgoingSink` stream which + * is linked with discovery client. + */ +public class OutgoingParcelHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(OutgoingParcelHandler.class); + + private final FluxSink outgoingSink; + + public OutgoingParcelHandler(FluxSink outgoingSink) { + this.outgoingSink = outgoingSink; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in OutgoingParcelHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.INCOMING, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in OutgoingParcelHandler, requirements are satisfied!", + envelope.getId())); + + if (envelope.get(Field.INCOMING) instanceof NetworkParcel) { + outgoingSink.next((NetworkParcel) envelope.get(Field.INCOMING)); + envelope.remove(Field.INCOMING); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTagToSender.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTagToSender.java new file mode 100644 index 000000000..9d66044ea --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTagToSender.java @@ -0,0 +1,62 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.javatuples.Pair; +import tech.pegasys.artemis.util.bytes.Bytes32; + +/** + * Assuming we have some unknown packet in {@link Field#PACKET_UNKNOWN}, resolves sender node id + * using `tag` field of the packet. Next, puts it to the {@link Field#SESSION_LOOKUP} so sender + * session could be resolved by another handler. + */ +public class UnknownPacketTagToSender implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(UnknownPacketTagToSender.class); + private final Bytes32 homeNodeId; + + public UnknownPacketTagToSender(NodeRecord homeNodeRecord) { + this.homeNodeId = homeNodeRecord.getNodeId(); + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in UnknownPacketTagToSender, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_UNKNOWN, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in UnknownPacketTagToSender, requirements are satisfied!", + envelope.getId())); + + if (!envelope.contains(Field.PACKET_UNKNOWN)) { + return; + } + UnknownPacket unknownPacket = (UnknownPacket) envelope.get(Field.PACKET_UNKNOWN); + Bytes32 fromNodeId = unknownPacket.getSourceNodeId(homeNodeId); + envelope.put( + Field.SESSION_LOOKUP, + Pair.with( + fromNodeId, + (Runnable) + () -> { + envelope.put(Field.BAD_PACKET, envelope.get(Field.PACKET_UNKNOWN)); + envelope.put( + Field.BAD_EXCEPTION, + new RuntimeException( + String.format("Session couldn't be created for nodeId %s", fromNodeId))); + envelope.remove(Field.PACKET_UNKNOWN); + })); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTypeByStatus.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTypeByStatus.java new file mode 100644 index 000000000..749861a80 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/UnknownPacketTypeByStatus.java @@ -0,0 +1,78 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; + +/** + * Resolves incoming packet type based on session states and places packet into the corresponding + * field. Doesn't recognize WhoAreYou packet, last should be resolved by {@link WhoAreYouAttempt} + */ +public class UnknownPacketTypeByStatus implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(UnknownPacketTypeByStatus.class); + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in UnknownPacketTypeByStatus, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.PACKET_UNKNOWN, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in UnknownPacketTypeByStatus, requirements are satisfied!", envelope.getId())); + + UnknownPacket unknownPacket = (UnknownPacket) envelope.get(Field.PACKET_UNKNOWN); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + switch (session.getStatus()) { + case INITIAL: + { + // We still don't know what's the type of the packet + break; + } + case RANDOM_PACKET_SENT: + { + // Should receive WHOAREYOU in answer, not our case + break; + } + case WHOAREYOU_SENT: + { + AuthHeaderMessagePacket authHeaderMessagePacket = + unknownPacket.getAuthHeaderMessagePacket(); + envelope.put(Field.PACKET_AUTH_HEADER_MESSAGE, authHeaderMessagePacket); + envelope.remove(Field.PACKET_UNKNOWN); + break; + } + case AUTHENTICATED: + { + MessagePacket messagePacket = unknownPacket.getMessagePacket(); + envelope.put(Field.PACKET_MESSAGE, messagePacket); + envelope.remove(Field.PACKET_UNKNOWN); + break; + } + default: + { + String error = + String.format( + "Not expected status:%s from node: %s", + session.getStatus(), session.getNodeRecord()); + logger.error(error); + throw new RuntimeException(error); + } + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouAttempt.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouAttempt.java new file mode 100644 index 000000000..93583f3c5 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouAttempt.java @@ -0,0 +1,49 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import tech.pegasys.artemis.util.bytes.Bytes32; + +/** + * Tries to get WHOAREYOU packet from unknown incoming packet in {@link Field#PACKET_UNKNOWN}. If it + * was successful, places the result in {@link Field#PACKET_WHOAREYOU} + */ +public class WhoAreYouAttempt implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(WhoAreYouAttempt.class); + private final Bytes32 homeNodeId; + + public WhoAreYouAttempt(Bytes32 homeNodeId) { + this.homeNodeId = homeNodeId; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouAttempt, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_UNKNOWN, envelope)) { + return; + } + if (!(HandlerUtil.requireCondition( + envelope1 -> + ((UnknownPacket) envelope1.get(Field.PACKET_UNKNOWN)).isWhoAreYouPacket(homeNodeId), + envelope))) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouAttempt, requirements are satisfied!", envelope.getId())); + + UnknownPacket unknownPacket = (UnknownPacket) envelope.get(Field.PACKET_UNKNOWN); + envelope.put(Field.PACKET_WHOAREYOU, unknownPacket.getWhoAreYouPacket()); + envelope.remove(Field.PACKET_UNKNOWN); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouPacketHandler.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouPacketHandler.java new file mode 100644 index 000000000..990c8b119 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouPacketHandler.java @@ -0,0 +1,134 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.ethereum.beacon.discovery.message.V5Message; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfo; +import org.ethereum.beacon.discovery.task.TaskMessageFactory; +import org.ethereum.beacon.schedulers.Scheduler; +import org.ethereum.beacon.util.Utils; +import org.web3j.crypto.ECKeyPair; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.Optional; +import java.util.function.Supplier; + +import static org.ethereum.beacon.discovery.Functions.PUBKEY_SIZE; + +/** Handles {@link WhoAreYouPacket} in {@link Field#PACKET_WHOAREYOU} field */ +public class WhoAreYouPacketHandler implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(WhoAreYouPacketHandler.class); + private final Pipeline outgoingPipeline; + private final Scheduler scheduler; + + public WhoAreYouPacketHandler(Pipeline outgoingPipeline, Scheduler scheduler) { + this.outgoingPipeline = outgoingPipeline; + this.scheduler = scheduler; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouPacketHandler, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.SESSION, envelope)) { + return; + } + if (!HandlerUtil.requireField(Field.PACKET_WHOAREYOU, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouPacketHandler, requirements are satisfied!", + envelope.getId())); + + WhoAreYouPacket packet = (WhoAreYouPacket) envelope.get(Field.PACKET_WHOAREYOU); + NodeSession session = (NodeSession) envelope.get(Field.SESSION); + try { + NodeRecord respRecord = null; + if (packet.getEnrSeq().compareTo(session.getHomeNodeRecord().getSeq()) < 0) { + respRecord = session.getHomeNodeRecord(); + } + BytesValue remotePubKey = + (BytesValue) session.getNodeRecord().getKey(EnrFieldV4.PKEY_SECP256K1); + byte[] ephemeralKeyBytes = new byte[32]; + Functions.getRandom().nextBytes(ephemeralKeyBytes); + ECKeyPair ephemeralKey = ECKeyPair.create(ephemeralKeyBytes); + + Functions.HKDFKeys hkdfKeys = + Functions.hkdf_expand( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + BytesValue.wrap(ephemeralKeyBytes), + remotePubKey, + packet.getIdNonce()); + session.setInitiatorKey(hkdfKeys.getInitiatorKey()); + session.setRecipientKey(hkdfKeys.getRecipientKey()); + BytesValue authResponseKey = hkdfKeys.getAuthResponseKey(); + Optional requestInfoOpt = session.getFirstAwaitRequestInfo(); + final V5Message message = + requestInfoOpt + .map(requestInfo -> TaskMessageFactory.createMessageFromRequest(requestInfo, session)) + .orElseThrow( + (Supplier) + () -> + new RuntimeException( + String.format( + "Received WHOAREYOU in envelope #%s but no requests await in %s session", + envelope.getId(), session))); + + BytesValue ephemeralPubKey = + BytesValue.wrap( + Utils.extractBytesFromUnsignedBigInt(ephemeralKey.getPublicKey(), PUBKEY_SIZE)); + AuthHeaderMessagePacket response = + AuthHeaderMessagePacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + authResponseKey, + packet.getIdNonce(), + session.getStaticNodeKey(), + respRecord, + ephemeralPubKey, + session.generateNonce(), + hkdfKeys.getInitiatorKey(), + DiscoveryV5Message.from(message)); + session.sendOutgoing(response); + } catch (AssertionError ex) { + String error = + String.format( + "Verification not passed for message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus()); + logger.error(error, ex); + envelope.remove(Field.PACKET_WHOAREYOU); + session.cancelAllRequests("Bad WHOAREYOU received from node"); + return; + } catch (Throwable ex) { + String error = + String.format( + "Failed to read message [%s] from node %s in status %s", + packet, session.getNodeRecord(), session.getStatus()); + logger.error(error, ex); + envelope.remove(Field.PACKET_WHOAREYOU); + session.cancelAllRequests("Bad WHOAREYOU received from node"); + return; + } + session.setStatus(NodeSession.SessionStatus.AUTHENTICATED); + envelope.remove(Field.PACKET_WHOAREYOU); + NextTaskHandler.tryToSendAwaitTaskIfAny(session, outgoingPipeline, scheduler); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouSessionResolver.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouSessionResolver.java new file mode 100644 index 000000000..4a3e9fc31 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/handler/WhoAreYouSessionResolver.java @@ -0,0 +1,69 @@ +package org.ethereum.beacon.discovery.pipeline.handler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.EnvelopeHandler; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.HandlerUtil; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; + +import java.util.Optional; + +/** + * Resolves session using `authTagRepo` for `WHOAREYOU` packets which should be placed in {@link + * Field#PACKET_WHOAREYOU} + */ +public class WhoAreYouSessionResolver implements EnvelopeHandler { + private static final Logger logger = LogManager.getLogger(WhoAreYouSessionResolver.class); + private final AuthTagRepository authTagRepo; + + public WhoAreYouSessionResolver(AuthTagRepository authTagRepo) { + this.authTagRepo = authTagRepo; + } + + @Override + public void handle(Envelope envelope) { + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouSessionResolver, checking requirements satisfaction", + envelope.getId())); + if (!HandlerUtil.requireField(Field.PACKET_WHOAREYOU, envelope)) { + return; + } + logger.trace( + () -> + String.format( + "Envelope %s in WhoAreYouSessionResolver, requirements are satisfied!", + envelope.getId())); + + WhoAreYouPacket whoAreYouPacket = (WhoAreYouPacket) envelope.get(Field.PACKET_WHOAREYOU); + Optional nodeSessionOptional = authTagRepo.get(whoAreYouPacket.getAuthTag()); + if (nodeSessionOptional.isPresent() + && (nodeSessionOptional + .get() + .getStatus() + .equals( + NodeSession.SessionStatus.RANDOM_PACKET_SENT) // We've started handshake before + || nodeSessionOptional + .get() + .getStatus() + .equals( + NodeSession.SessionStatus + .AUTHENTICATED))) { // We had authenticated session but it's expired + envelope.put(Field.SESSION, nodeSessionOptional.get()); + logger.trace( + () -> + String.format( + "Session resolved: %s in envelope #%s", + nodeSessionOptional.get(), envelope.getId())); + } else { + envelope.put(Field.BAD_PACKET, envelope.get(Field.PACKET_WHOAREYOU)); + envelope.remove(Field.PACKET_WHOAREYOU); + envelope.put(Field.BAD_EXCEPTION, new RuntimeException("Not expected WHOAREYOU packet")); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/FindNodeRequestInfo.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/FindNodeRequestInfo.java new file mode 100644 index 000000000..023bae1cf --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/FindNodeRequestInfo.java @@ -0,0 +1,42 @@ +package org.ethereum.beacon.discovery.pipeline.info; + +import org.ethereum.beacon.discovery.task.TaskStatus; +import org.ethereum.beacon.discovery.task.TaskType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import javax.annotation.Nullable; +import java.util.concurrent.CompletableFuture; + +public class FindNodeRequestInfo extends GeneralRequestInfo { + private final Integer remainingNodes; + private final int distance; + + public FindNodeRequestInfo( + TaskStatus taskStatus, + BytesValue requestId, + CompletableFuture future, + int distance, + @Nullable Integer remainingNodes) { + super(TaskType.FINDNODE, taskStatus, requestId, future); + this.distance = distance; + this.remainingNodes = remainingNodes; + } + + public int getDistance() { + return distance; + } + + public Integer getRemainingNodes() { + return remainingNodes; + } + + @Override + public String toString() { + return "FindNodeRequestInfo{" + + "remainingNodes=" + + remainingNodes + + ", distance=" + + distance + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/GeneralRequestInfo.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/GeneralRequestInfo.java new file mode 100644 index 000000000..fe5cffdf3 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/GeneralRequestInfo.java @@ -0,0 +1,57 @@ +package org.ethereum.beacon.discovery.pipeline.info; + +import org.ethereum.beacon.discovery.task.TaskStatus; +import org.ethereum.beacon.discovery.task.TaskType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.concurrent.CompletableFuture; + +public class GeneralRequestInfo implements RequestInfo { + private final TaskType taskType; + private final TaskStatus taskStatus; + private final BytesValue requestId; + private final CompletableFuture future; + + public GeneralRequestInfo( + TaskType taskType, + TaskStatus taskStatus, + BytesValue requestId, + CompletableFuture future) { + this.taskType = taskType; + this.taskStatus = taskStatus; + this.requestId = requestId; + this.future = future; + } + + @Override + public TaskType getTaskType() { + return taskType; + } + + @Override + public TaskStatus getTaskStatus() { + return taskStatus; + } + + @Override + public BytesValue getRequestId() { + return requestId; + } + + @Override + public CompletableFuture getFuture() { + return future; + } + + @Override + public String toString() { + return "GeneralRequestInfo{" + + "taskType=" + + taskType + + ", taskStatus=" + + taskStatus + + ", requestId=" + + requestId + + '}'; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfo.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfo.java new file mode 100644 index 000000000..4b7a0da2d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfo.java @@ -0,0 +1,22 @@ +package org.ethereum.beacon.discovery.pipeline.info; + +import org.ethereum.beacon.discovery.task.TaskStatus; +import org.ethereum.beacon.discovery.task.TaskType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.concurrent.CompletableFuture; + +/** Stores info related to performed request */ +public interface RequestInfo { + /** Task type, in execution of which request was created */ + TaskType getTaskType(); + + /** Status of corresponding task */ + TaskStatus getTaskStatus(); + + /** Id of request */ + BytesValue getRequestId(); + + /** Future that should be fired when request is fulfilled or cancelled due to errors */ + CompletableFuture getFuture(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfoFactory.java b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfoFactory.java new file mode 100644 index 000000000..05fe14672 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/pipeline/info/RequestInfoFactory.java @@ -0,0 +1,30 @@ +package org.ethereum.beacon.discovery.pipeline.info; + +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.concurrent.CompletableFuture; + +import static org.ethereum.beacon.discovery.task.TaskStatus.AWAIT; + +public class RequestInfoFactory { + public static RequestInfo create( + TaskType taskType, BytesValue id, TaskOptions taskOptions, CompletableFuture future) { + switch (taskType) { + case FINDNODE: + { + return new FindNodeRequestInfo(AWAIT, id, future, taskOptions.getDistance(), null); + } + case PING: + { + return new GeneralRequestInfo(taskType, AWAIT, id, future); + } + default: + { + throw new RuntimeException( + String.format("Factory doesn't know how to create task with type %s", taskType)); + } + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/AuthTagRepository.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/AuthTagRepository.java new file mode 100644 index 000000000..83f938853 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/AuthTagRepository.java @@ -0,0 +1,57 @@ +package org.ethereum.beacon.discovery.storage; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.NodeSession; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In memory repository with authTags, corresponding sessions {@link NodeSession} and 2-way getters: + * {@link #get(BytesValue)} and {@link #getTag(NodeSession)} + * + *

Expired authTags should be manually removed with {@link #expire(NodeSession)} + */ +public class AuthTagRepository { + private static final Logger logger = LogManager.getLogger(AuthTagRepository.class); + private Map authTags = new ConcurrentHashMap<>(); + private Map sessions = new ConcurrentHashMap<>(); + + public synchronized void put(BytesValue authTag, NodeSession session) { + logger.trace( + () -> + String.format( + "PUT: authTag[%s] => nodeSession[%s]", + authTag, session.getNodeRecord().getNodeId())); + authTags.put(authTag, session); + sessions.put(session, authTag); + } + + public Optional get(BytesValue authTag) { + logger.trace(() -> String.format("GET: authTag[%s]", authTag)); + NodeSession session = authTags.get(authTag); + return session == null ? Optional.empty() : Optional.of(session); + } + + public Optional getTag(NodeSession session) { + logger.trace(() -> String.format("GET: session %s", session)); + BytesValue authTag = sessions.get(session); + return authTag == null ? Optional.empty() : Optional.of(authTag); + } + + public synchronized void expire(NodeSession session) { + logger.trace(() -> String.format("REMOVE: session %s", session)); + BytesValue authTag = sessions.remove(session); + logger.trace( + () -> + authTag == null + ? "Session %s not found, was not removed" + : String.format("Session %s removed with authTag[%s]", session, authTag)); + if (authTag != null) { + authTags.remove(authTag); + } + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucket.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucket.java new file mode 100644 index 000000000..1e5da8ea0 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucket.java @@ -0,0 +1,103 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeStatus; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Storage for nodes, K-Bucket. Holds only {@link #K} nodes, replacing nodes with the same nodeId + * and nodes with old lastRetry. Also throws out DEAD nodes without taking any notice on other + * fields. + */ +public class NodeBucket { + /** Bucket size, number of nodes */ + public static final int K = 16; + + private static final Predicate FILTER = + nodeRecord -> nodeRecord.getStatus().equals(NodeStatus.ACTIVE); + private final TreeSet bucket = + new TreeSet<>((o1, o2) -> o2.getNode().hashCode() - o1.getNode().hashCode()); + + public static NodeBucket fromRlpBytes(BytesValue bytes, NodeRecordFactory nodeRecordFactory) { + NodeBucket nodeBucket = new NodeBucket(); + ((RlpList) RlpDecoder.decode(bytes.extractArray()).getValues().get(0)) + .getValues().stream() + .map(rt -> (RlpString) rt) + .map(RlpString::getBytes) + .map(BytesValue::wrap) + .map((BytesValue bytes1) -> NodeRecordInfo.fromRlpBytes(bytes1, nodeRecordFactory)) + .forEach(nodeBucket::put); + return nodeBucket; + } + + public synchronized boolean put(NodeRecordInfo nodeRecord) { + if (FILTER.test(nodeRecord)) { + if (!bucket.contains(nodeRecord)) { + boolean modified = bucket.add(nodeRecord); + if (bucket.size() > K) { + NodeRecordInfo worst = null; + for (NodeRecordInfo nodeRecordInfo : bucket) { + if (worst == null) { + worst = nodeRecordInfo; + } else if (worst.getLastRetry() > nodeRecordInfo.getLastRetry()) { + worst = nodeRecordInfo; + } + } + bucket.remove(worst); + } + return modified; + } else { + NodeRecordInfo bucketNode = bucket.subSet(nodeRecord, true, nodeRecord, true).first(); + if (nodeRecord.getLastRetry() > bucketNode.getLastRetry()) { + bucket.remove(bucketNode); + bucket.add(nodeRecord); + return true; + } + } + } else { + return bucket.remove(nodeRecord); + } + + return false; + } + + public boolean contains(NodeRecordInfo nodeRecordInfo) { + return bucket.contains(nodeRecordInfo); + } + + public void putAll(Collection nodeRecords) { + nodeRecords.forEach(this::put); + } + + public synchronized BytesValue toRlpBytes() { + byte[] res = + RlpEncoder.encode( + new RlpList( + bucket.stream() + .map(NodeRecordInfo::toRlpBytes) + .map(BytesValue::extractArray) + .map(RlpString::create) + .collect(Collectors.toList()))); + return BytesValue.wrap(res); + } + + public int size() { + return bucket.size(); + } + + public List getNodeRecords() { + return new ArrayList<>(bucket); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorage.java new file mode 100644 index 000000000..5f58850fb --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorage.java @@ -0,0 +1,14 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.discovery.NodeRecordInfo; + +import java.util.Optional; + +/** Stores {@link NodeRecordInfo}'s in {@link NodeBucket}'s */ +public interface NodeBucketStorage { + Optional get(int index); + + void put(NodeRecordInfo nodeRecordInfo); + + void commit(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorageImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorageImpl.java new file mode 100644 index 000000000..92beb306b --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeBucketStorageImpl.java @@ -0,0 +1,71 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.db.source.DataSource; +import org.ethereum.beacon.db.source.HoleyList; +import org.ethereum.beacon.db.source.impl.DataSourceList; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.Optional; + +/** + * Stores {@link NodeRecordInfo}'s in {@link NodeBucket}'s calculating index number of bucket as + * {@link Functions#logDistance(Bytes32, Bytes32)} from homeNodeId and ignoring index above {@link + * #MAXIMUM_BUCKET} + */ +public class NodeBucketStorageImpl implements NodeBucketStorage { + public static final String NODE_BUCKET_STORAGE_NAME = "node-bucket-table"; + public static final int MAXIMUM_BUCKET = 256; + private final HoleyList nodeBucketsTable; + private final Bytes32 homeNodeId; + + public NodeBucketStorageImpl( + Database database, SerializerFactory serializerFactory, NodeRecord homeNode) { + DataSource nodeBucketsSource = + database.createStorage(NODE_BUCKET_STORAGE_NAME); + this.nodeBucketsTable = + new DataSourceList<>( + nodeBucketsSource, + serializerFactory.getSerializer(NodeBucket.class), + serializerFactory.getDeserializer(NodeBucket.class)); + this.homeNodeId = homeNode.getNodeId(); + // Empty storage, saving home node + if (!nodeBucketsTable.get(0).isPresent()) { + NodeBucket zero = new NodeBucket(); + zero.put(NodeRecordInfo.createDefault(homeNode)); + nodeBucketsTable.put(0, zero); + } + } + + @Override + public Optional get(int index) { + return nodeBucketsTable.get(index); + } + + @Override + public void put(NodeRecordInfo nodeRecordInfo) { + int logDistance = Functions.logDistance(homeNodeId, nodeRecordInfo.getNode().getNodeId()); + if (logDistance <= MAXIMUM_BUCKET) { + Optional nodeBucketOpt = nodeBucketsTable.get(logDistance); + if (nodeBucketOpt.isPresent()) { + NodeBucket nodeBucket = nodeBucketOpt.get(); + boolean updated = nodeBucket.put(nodeRecordInfo); + if (updated) { + nodeBucketsTable.put(logDistance, nodeBucket); + } + } else { + NodeBucket nodeBucket = new NodeBucket(); + nodeBucket.put(nodeRecordInfo); + nodeBucketsTable.put(logDistance, nodeBucket); + } + } + } + + @Override + public void commit() {} +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeIndex.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeIndex.java new file mode 100644 index 000000000..ff3a39776 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeIndex.java @@ -0,0 +1,50 @@ +package org.ethereum.beacon.discovery.storage; + +import org.web3j.rlp.RlpDecoder; +import org.web3j.rlp.RlpEncoder; +import org.web3j.rlp.RlpList; +import org.web3j.rlp.RlpString; +import org.web3j.rlp.RlpType; +import tech.pegasys.artemis.ethereum.core.Hash32; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.ArrayList; +import java.util.List; + +/** Node Index. Stores several node keys. */ +public class NodeIndex { + private List entries; + + public NodeIndex() { + this.entries = new ArrayList<>(); + } + + public static NodeIndex fromRlpBytes(BytesValue bytes) { + RlpList internalList = (RlpList) RlpDecoder.decode(bytes.extractArray()).getValues().get(0); + List entries = new ArrayList<>(); + for (RlpType entry : internalList.getValues()) { + entries.add(Hash32.wrap(Bytes32.wrap(((RlpString) entry).getBytes()))); + } + NodeIndex res = new NodeIndex(); + res.setEntries(entries); + return res; + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } + + public BytesValue toRlpBytes() { + List values = new ArrayList<>(); + for (Hash32 hash32 : getEntries()) { + values.add(RlpString.create(hash32.extractArray())); + } + byte[] bytes = RlpEncoder.encode(new RlpList(values)); + return BytesValue.wrap(bytes); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeSerializerFactory.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeSerializerFactory.java new file mode 100644 index 000000000..c3d69f76d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeSerializerFactory.java @@ -0,0 +1,43 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** Serializer for {@link NodeRecordInfo}, {@link NodeIndex} and {@link NodeBucket} */ +public class NodeSerializerFactory implements SerializerFactory { + private final Map> deserializerMap = new HashMap<>(); + private final Map> serializerMap = new HashMap<>(); + + public NodeSerializerFactory(NodeRecordFactory nodeRecordFactory) { + deserializerMap.put( + NodeRecordInfo.class, bytes1 -> NodeRecordInfo.fromRlpBytes(bytes1, nodeRecordFactory)); + serializerMap.put(NodeRecordInfo.class, o -> ((NodeRecordInfo) o).toRlpBytes()); + deserializerMap.put(NodeIndex.class, NodeIndex::fromRlpBytes); + serializerMap.put(NodeIndex.class, o -> ((NodeIndex) o).toRlpBytes()); + deserializerMap.put( + NodeBucket.class, bytes -> NodeBucket.fromRlpBytes(bytes, nodeRecordFactory)); + serializerMap.put(NodeBucket.class, o -> ((NodeBucket) o).toRlpBytes()); + } + + @Override + public Function getDeserializer(Class objectClass) { + if (!deserializerMap.containsKey(objectClass)) { + throw new RuntimeException(String.format("Type %s is not supported", objectClass)); + } + return bytes -> (T) deserializerMap.get(objectClass).apply(bytes); + } + + @Override + public Function getSerializer(Class objectClass) { + if (!serializerMap.containsKey(objectClass)) { + throw new RuntimeException(String.format("Type %s is not supported", objectClass)); + } + return value -> serializerMap.get(objectClass).apply(value); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTable.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTable.java new file mode 100644 index 000000000..89ccfbbb8 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTable.java @@ -0,0 +1,25 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.bytes.Bytes32; + +import java.util.List; +import java.util.Optional; + +/** + * Stores Ethereum Node Records in {@link NodeRecordInfo} containers. Also stores home node as node + * record. + */ +public interface NodeTable { + void save(NodeRecordInfo node); + + void remove(NodeRecordInfo node); + + Optional getNode(Bytes32 nodeId); + + /** Returns list of nodes including `nodeId` (if it's found) in logLimit distance from it. */ + List findClosestNodes(Bytes32 nodeId, int logLimit); + + NodeRecord getHomeNode(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableImpl.java new file mode 100644 index 000000000..5a85c22b3 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableImpl.java @@ -0,0 +1,155 @@ +package org.ethereum.beacon.discovery.storage; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.db.source.DataSource; +import org.ethereum.beacon.db.source.HoleyList; +import org.ethereum.beacon.db.source.SingleValueSource; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.ethereum.core.Hash32; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Stores Ethereum Node Records in {@link NodeRecordInfo} containers. Also stores home node as node + * record. Uses indexes, {@link NodeIndex} for quick access to nodes that are close to others. + */ +public class NodeTableImpl implements NodeTable { + static final long NUMBER_OF_INDEXES = 256; + private static final Logger logger = LogManager.getLogger(NodeTableImpl.class); + private static final int MAXIMUM_INFO_IN_ONE_BYTE = 256; + private static final boolean START_FROM_BEGINNING = true; + private final DataSource nodeTable; + private final HoleyList indexTable; + private final SingleValueSource homeNodeSource; + + public NodeTableImpl( + DataSource nodeTable, + HoleyList indexTable, + SingleValueSource homeNodeSource) { + this.nodeTable = nodeTable; + this.indexTable = indexTable; + this.homeNodeSource = homeNodeSource; + } + + @VisibleForTesting + static long getNodeIndex(Bytes32 nodeKey) { + int activeBytes = 1; + long required = NUMBER_OF_INDEXES; + while (required > 0) { + if (required == MAXIMUM_INFO_IN_ONE_BYTE) { + required = 0; + } else { + required = required / MAXIMUM_INFO_IN_ONE_BYTE; + } + + if (required > 0) { + activeBytes++; + } + } + + int start = START_FROM_BEGINNING ? 0 : nodeKey.size() - activeBytes; + BytesValue active = nodeKey.slice(start, activeBytes); + BigInteger activeNumber = new BigInteger(1, active.extractArray()); + // XXX: could be optimized for small NUMBER_OF_INDEXES + BigInteger index = activeNumber.mod(BigInteger.valueOf(NUMBER_OF_INDEXES)); + + return index.longValue(); + } + + @Override + public void save(NodeRecordInfo node) { + Hash32 nodeKey = Hash32.wrap(node.getNode().getNodeId()); + nodeTable.put(nodeKey, node); + NodeIndex activeIndex = indexTable.get(getNodeIndex(nodeKey)).orElseGet(NodeIndex::new); + List nodes = activeIndex.getEntries(); + if (!nodes.contains(nodeKey)) { + nodes.add(nodeKey); + indexTable.put(getNodeIndex(nodeKey), activeIndex); + } + } + + @Override + public void remove(NodeRecordInfo node) { + Hash32 nodeKey = Hash32.wrap(node.getNode().getNodeId()); + nodeTable.remove(nodeKey); + NodeIndex activeIndex = indexTable.get(getNodeIndex(nodeKey)).orElseGet(NodeIndex::new); + List nodes = activeIndex.getEntries(); + if (nodes.contains(nodeKey)) { + nodes.remove(nodeKey); + indexTable.put(getNodeIndex(nodeKey), activeIndex); + } + } + + @Override + public Optional getNode(Bytes32 nodeId) { + return nodeTable.get(Hash32.wrap(nodeId)); + } + + /** + * Returns list of nodes including `nodeId` (if it's found) in logLimit distance from it. Uses + * {@link Functions#logDistance(Bytes32, Bytes32)} as distance function. + */ + @Override + public List findClosestNodes(Bytes32 nodeId, int logLimit) { + long start = getNodeIndex(nodeId); + boolean limitReached = false; + long currentIndexUp = start; + long currentIndexDown = start; + Set res = new HashSet<>(); + while (!limitReached) { + Optional upNodesOptional = + currentIndexUp >= NUMBER_OF_INDEXES ? Optional.empty() : indexTable.get(currentIndexUp); + Optional downNodesOptional = + currentIndexDown < 0 ? Optional.empty() : indexTable.get(currentIndexDown); + if (currentIndexUp >= NUMBER_OF_INDEXES && currentIndexDown < 0) { + // Bounds are reached from both top and bottom + break; + } + if (upNodesOptional.isPresent()) { + NodeIndex upNodes = upNodesOptional.get(); + for (Hash32 currentNodeId : upNodes.getEntries()) { + if (Functions.logDistance(currentNodeId, nodeId) >= logLimit) { + limitReached = true; + break; + } else { + res.add(getNode(currentNodeId).get()); + } + } + } + if (downNodesOptional.isPresent()) { + NodeIndex downNodes = downNodesOptional.get(); + List entries = downNodes.getEntries(); + // XXX: iterate in reverse order to reach logDistance limit from the right side + for (int i = entries.size() - 1; i >= 0; i--) { + Hash32 currentNodeId = entries.get(i); + if (Functions.logDistance(currentNodeId, nodeId) >= logLimit) { + limitReached = true; + break; + } else { + res.add(getNode(currentNodeId).get()); + } + } + } + currentIndexUp++; + currentIndexDown--; + } + + return new ArrayList<>(res); + } + + @Override + public NodeRecord getHomeNode() { + return homeNodeSource.get().map(NodeRecordInfo::getNode).orElse(null); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorage.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorage.java new file mode 100644 index 000000000..95a1078f8 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorage.java @@ -0,0 +1,13 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.db.source.SingleValueSource; +import org.ethereum.beacon.discovery.NodeRecordInfo; + +/** Stores {@link NodeTable} and home node info */ +public interface NodeTableStorage { + NodeTable get(); + + SingleValueSource getHomeNodeSource(); + + void commit(); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactory.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactory.java new file mode 100644 index 000000000..cac4dc2d8 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactory.java @@ -0,0 +1,35 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +/** Creates {@link NodeTableStorage} */ +public interface NodeTableStorageFactory { + /** + * Creates storage for nodes table + * + * @param database Database + * @param serializerFactory Serializer factory + * @param homeNodeProvider Home node provider, accepts old sequence number of home node, usually + * sequence number is increased by 1 on each restart and ENR is signed with new sequence + * number + * @param bootNodesSupplier boot nodes provider + * @return {@link NodeTableStorage} from `database` but if it doesn't exist, creates new one with + * home node provided by `homeNodeSupplier` and boot nodes provided with `bootNodesSupplier`. + * Uses `serializerFactory` for node records serialization. + */ + NodeTableStorage createTable( + Database database, + SerializerFactory serializerFactory, + Function homeNodeProvider, + Supplier> bootNodesSupplier); + + NodeBucketStorage createBucketStorage( + Database database, SerializerFactory serializerFactory, NodeRecord homeNode); +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactoryImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactoryImpl.java new file mode 100644 index 000000000..be8a4cc56 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageFactoryImpl.java @@ -0,0 +1,73 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +public class NodeTableStorageFactoryImpl implements NodeTableStorageFactory { + + private boolean isStorageEmpty(NodeTableStorage nodeTableStorage) { + return nodeTableStorage.get().getHomeNode() == null; + } + + /** + * Creates storage for nodes table + * + * @param database Database + * @param serializerFactory Serializer factory + * @param homeNodeProvider Home node provider, accepts old sequence number of home node, usually + * sequence number is increased by 1 on each restart and ENR is signed with new sequence + * number + * @param bootNodesSupplier boot nodes provider + * @return {@link NodeTableStorage} from `database` but if it doesn't exist, creates new one with + * home node provided by `homeNodeSupplier` and boot nodes provided with `bootNodesSupplier`. + * Uses `serializerFactory` for node records serialization. + */ + @Override + public NodeTableStorage createTable( + Database database, + SerializerFactory serializerFactory, + Function homeNodeProvider, + Supplier> bootNodesSupplier) { + NodeTableStorage nodeTableStorage = new NodeTableStorageImpl(database, serializerFactory); + + // Init storage with boot nodes if its empty + if (isStorageEmpty(nodeTableStorage)) { + bootNodesSupplier + .get() + .forEach( + nodeRecord -> { + if (!(nodeRecord instanceof NodeRecord)) { + throw new RuntimeException("Only V4 node records are supported as boot nodes"); + } + nodeRecord.verify(); + NodeRecordInfo nodeRecordInfo = NodeRecordInfo.createDefault(nodeRecord); + nodeTableStorage.get().save(nodeRecordInfo); + }); + } + // Rewrite home node with updated sequence number on init + UInt64 oldSeq = + nodeTableStorage + .getHomeNodeSource() + .get() + .map(nr -> nr.getNode().getSeq()) + .orElse(UInt64.ZERO); + NodeRecord updatedHomeNodeRecord = homeNodeProvider.apply(oldSeq); + updatedHomeNodeRecord.verify(); + nodeTableStorage.getHomeNodeSource().set(NodeRecordInfo.createDefault(updatedHomeNodeRecord)); + + return nodeTableStorage; + } + + @Override + public NodeBucketStorage createBucketStorage( + Database database, SerializerFactory serializerFactory, NodeRecord homeNode) { + return new NodeBucketStorageImpl(database, serializerFactory, homeNode); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageImpl.java b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageImpl.java new file mode 100644 index 000000000..c872b8926 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/storage/NodeTableStorageImpl.java @@ -0,0 +1,64 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.crypto.Hashes; +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.db.source.CodecSource; +import org.ethereum.beacon.db.source.DataSource; +import org.ethereum.beacon.db.source.HoleyList; +import org.ethereum.beacon.db.source.SingleValueSource; +import org.ethereum.beacon.db.source.impl.DataSourceList; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import tech.pegasys.artemis.ethereum.core.Hash32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +/** Creates NodeTableStorage containing NodeTable with indexes */ +public class NodeTableStorageImpl implements NodeTableStorage { + public static final String NODE_TABLE_STORAGE_NAME = "node-table"; + public static final String INDEXES_STORAGE_NAME = "node-table-index"; + private static final Hash32 HOME_NODE_KEY = + Hash32.wrap(Hashes.sha256(BytesValue.wrap("HOME_NODE".getBytes()))); + private final DataSource nodeTableSource; + private final DataSource nodeIndexesSource; + private final SingleValueSource homeNodeSource; + private final NodeTable nodeTable; + + public NodeTableStorageImpl(Database database, SerializerFactory serializerFactory) { + DataSource nodeTableSource = + database.createStorage(NODE_TABLE_STORAGE_NAME); + this.nodeTableSource = nodeTableSource; + DataSource nodeIndexesSource = + database.createStorage(INDEXES_STORAGE_NAME); + this.nodeIndexesSource = nodeIndexesSource; + + DataSource nodeTable = + new CodecSource<>( + nodeTableSource, + key -> key, + serializerFactory.getSerializer(NodeRecordInfo.class), + serializerFactory.getDeserializer(NodeRecordInfo.class)); + HoleyList nodeIndexesTable = + new DataSourceList<>( + nodeIndexesSource, + serializerFactory.getSerializer(NodeIndex.class), + serializerFactory.getDeserializer(NodeIndex.class)); + this.homeNodeSource = SingleValueSource.fromDataSource(nodeTable, HOME_NODE_KEY); + this.nodeTable = new NodeTableImpl(nodeTable, nodeIndexesTable, homeNodeSource); + } + + @Override + public NodeTable get() { + return nodeTable; + } + + @Override + public SingleValueSource getHomeNodeSource() { + return homeNodeSource; + } + + @Override + public void commit() { + nodeTableSource.flush(); + nodeIndexesSource.flush(); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/DiscoveryTaskManager.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/DiscoveryTaskManager.java new file mode 100644 index 000000000..5f187f038 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/DiscoveryTaskManager.java @@ -0,0 +1,242 @@ +package org.ethereum.beacon.discovery.task; + +import org.ethereum.beacon.discovery.DiscoveryManager; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeStatus; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTable; +import org.ethereum.beacon.schedulers.Scheduler; +import tech.pegasys.artemis.util.bytes.Bytes32; + +import java.time.Duration; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static org.ethereum.beacon.discovery.NodeStatus.DEAD; + +/** Manages recurrent node check task(s) */ +public class DiscoveryTaskManager { + private static final int LIVE_CHECK_DISTANCE = 100; + private static final int RECURSIVE_LOOKUP_DISTANCE = 100; + private static final int STATUS_EXPIRATION_SECONDS = 600; + private static final int LIVE_CHECK_INTERVAL_SECONDS = 1; + private static final int RECURSIVE_LOOKUP_INTERVAL_SECONDS = 10; + private static final int RETRY_TIMEOUT_SECONDS = 60; + private static final int MAX_RETRIES = 10; + private final Scheduler scheduler; + private final Bytes32 homeNodeId; + private final LiveCheckTasks liveCheckTasks; + private final RecursiveLookupTasks recursiveLookupTasks; + private final NodeTable nodeTable; + private final NodeBucketStorage nodeBucketStorage; + /** + * Checks whether {@link org.ethereum.beacon.discovery.enr.NodeRecord} is ready for alive status + * check. Plus, marks records as DEAD if there were a lot of unsuccessful retries to get reply + * from node. + * + *

We don't need to recheck the node if + * + *

    + *
  • Node is ACTIVE and last connection retry was not too much time ago + *
  • Node is marked as {@link NodeStatus#DEAD} + *
  • Node is not ACTIVE but last connection retry was "seconds ago" + *
+ * + *

In all other cases method returns true, meaning node is ready for ping check + */ + private final Predicate LIVE_CHECK_NODE_RULE = + nodeRecord -> { + long currentTime = Functions.getTime(); + if (nodeRecord.getStatus() == NodeStatus.ACTIVE + && nodeRecord.getLastRetry() > currentTime - STATUS_EXPIRATION_SECONDS) { + return false; // no need to rediscover + } + if (DEAD.equals(nodeRecord.getStatus())) { + return false; // node looks dead but we are keeping its records for some reason + } + if ((currentTime - nodeRecord.getLastRetry()) + < (nodeRecord.getRetry() * nodeRecord.getRetry())) { + return false; // too early for retry + } + + return true; + }; + + /** + * Checks whether {@link org.ethereum.beacon.discovery.enr.NodeRecord} is ready for FINDNODE query + * which expands the list of all known nodes. + * + *

Node is eligible if + * + *

    + *
  • Node is ACTIVE and last connection retry was not too much time ago + *
+ */ + private final Predicate RECURSIVE_LOOKUP_NODE_RULE = + nodeRecord -> { + long currentTime = Functions.getTime(); + if (nodeRecord.getStatus() == NodeStatus.ACTIVE + && nodeRecord.getLastRetry() > currentTime - STATUS_EXPIRATION_SECONDS) { + return true; + } + + return false; + }; + + /** Checks whether node is eligible to be considered as dead */ + private final Predicate DEAD_RULE = + nodeRecord -> nodeRecord.getRetry() >= MAX_RETRIES; + + private final Consumer[] nodeRecordUpdatesConsumers; + private boolean resetDead; + private boolean removeDead; + + /** + * @param discoveryManager Discovery manager + * @param nodeTable Ethereum node records storage, stores all found nodes + * @param nodeBucketStorage Node bucket storage. stores only closest nodes in ready-to-answer + * format + * @param homeNode Home node + * @param scheduler scheduler to run recurrent tasks on + * @param resetDead Whether to reset dead status of the nodes on start. If set to true, resets its + * status at startup and sets number of used retries to 0. Reset applies after remove, so if + * remove is on, reset will be applied to 0 nodes + * @param removeDead Whether to remove nodes that are found dead after several retries + * @param nodeRecordUpdatesConsumers consumers are executed when nodeRecord is updated with new + * sequence number, so it should be updated in nodeSession + */ + public DiscoveryTaskManager( + DiscoveryManager discoveryManager, + NodeTable nodeTable, + NodeBucketStorage nodeBucketStorage, + NodeRecord homeNode, + Scheduler scheduler, + boolean resetDead, + boolean removeDead, + Consumer... nodeRecordUpdatesConsumers) { + this.scheduler = scheduler; + this.nodeTable = nodeTable; + this.nodeBucketStorage = nodeBucketStorage; + this.homeNodeId = homeNode.getNodeId(); + this.liveCheckTasks = + new LiveCheckTasks(discoveryManager, scheduler, Duration.ofSeconds(RETRY_TIMEOUT_SECONDS)); + this.recursiveLookupTasks = + new RecursiveLookupTasks( + discoveryManager, scheduler, Duration.ofSeconds(RETRY_TIMEOUT_SECONDS)); + this.resetDead = resetDead; + this.removeDead = removeDead; + this.nodeRecordUpdatesConsumers = nodeRecordUpdatesConsumers; + } + + public void start() { + scheduler.executeAtFixedRate( + Duration.ZERO, Duration.ofSeconds(LIVE_CHECK_INTERVAL_SECONDS), this::liveCheckTask); + scheduler.executeAtFixedRate( + Duration.ZERO, + Duration.ofSeconds(RECURSIVE_LOOKUP_INTERVAL_SECONDS), + this::recursiveLookupTask); + } + + private void liveCheckTask() { + List nodes = nodeTable.findClosestNodes(homeNodeId, LIVE_CHECK_DISTANCE); + + // Dead nodes handling + nodes.stream() + .filter(DEAD_RULE) + .forEach( + deadMarkedNode -> { + if (removeDead) { + nodeTable.remove(deadMarkedNode); + } else { + nodeTable.save( + new NodeRecordInfo( + deadMarkedNode.getNode(), + deadMarkedNode.getLastRetry(), + DEAD, + deadMarkedNode.getRetry())); + } + }); + + // resets dead records + Stream closestNodes = nodes.stream(); + if (resetDead) { + closestNodes = + closestNodes.map( + nodeRecordInfo -> { + if (DEAD.equals(nodeRecordInfo.getStatus())) { + return new NodeRecordInfo( + nodeRecordInfo.getNode(), nodeRecordInfo.getLastRetry(), NodeStatus.SLEEP, 0); + } else { + return nodeRecordInfo; + } + }); + resetDead = false; + } + + // Live check task + closestNodes + .filter(LIVE_CHECK_NODE_RULE) + .forEach( + nodeRecord -> + liveCheckTasks.add( + nodeRecord, + () -> + updateNode( + nodeRecord, + new NodeRecordInfo( + nodeRecord.getNode(), Functions.getTime(), NodeStatus.ACTIVE, 0)), + () -> + updateNode( + nodeRecord, + new NodeRecordInfo( + nodeRecord.getNode(), + Functions.getTime(), + NodeStatus.SLEEP, + (nodeRecord.getRetry() + 1))))); + } + + private void recursiveLookupTask() { + List nodes = nodeTable.findClosestNodes(homeNodeId, RECURSIVE_LOOKUP_DISTANCE); + nodes.stream() + .filter(RECURSIVE_LOOKUP_NODE_RULE) + .forEach( + nodeRecord -> + recursiveLookupTasks.add( + nodeRecord, + () -> {}, + () -> + updateNode( + nodeRecord, + new NodeRecordInfo( + nodeRecord.getNode(), + Functions.getTime(), + NodeStatus.SLEEP, + (nodeRecord.getRetry() + 1))))); + } + + void onNodeRecordUpdate(NodeRecord nodeRecord) { + for (Consumer consumer : nodeRecordUpdatesConsumers) { + consumer.accept(nodeRecord); + } + } + + private void updateNode(NodeRecordInfo oldNodeRecordInfo, NodeRecordInfo newNodeRecordInfo) { + // use node with latest seq known + if (newNodeRecordInfo.getNode().getSeq().compareTo(oldNodeRecordInfo.getNode().getSeq()) < 0) { + newNodeRecordInfo = + new NodeRecordInfo( + oldNodeRecordInfo.getNode(), + newNodeRecordInfo.getLastRetry(), + newNodeRecordInfo.getStatus(), + newNodeRecordInfo.getRetry()); + } else { + onNodeRecordUpdate(newNodeRecordInfo.getNode()); + } + nodeTable.save(newNodeRecordInfo); + nodeBucketStorage.put(newNodeRecordInfo); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/LiveCheckTasks.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/LiveCheckTasks.java new file mode 100644 index 000000000..0b895dcce --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/LiveCheckTasks.java @@ -0,0 +1,61 @@ +package org.ethereum.beacon.discovery.task; + +import com.google.common.collect.Sets; +import org.ethereum.beacon.discovery.DiscoveryManager; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.schedulers.Scheduler; +import org.ethereum.beacon.util.ExpirationScheduler; +import tech.pegasys.artemis.util.bytes.Bytes32; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Sends {@link TaskType#PING} to closest NodeRecords added via {@link #add(NodeRecordInfo, + * Runnable, Runnable)}. Tasks is called failed if timeout is reached and reply from node is not + * received. + */ +public class LiveCheckTasks { + private final Scheduler scheduler; + private final DiscoveryManager discoveryManager; + private final Set currentTasks = Sets.newConcurrentHashSet(); + private final ExpirationScheduler taskTimeouts; + + public LiveCheckTasks(DiscoveryManager discoveryManager, Scheduler scheduler, Duration timeout) { + this.discoveryManager = discoveryManager; + this.scheduler = scheduler; + this.taskTimeouts = + new ExpirationScheduler<>(timeout.get(ChronoUnit.MILLIS), TimeUnit.MILLISECONDS); + } + + public void add(NodeRecordInfo nodeRecordInfo, Runnable successCallback, Runnable failCallback) { + synchronized (this) { + if (currentTasks.contains(nodeRecordInfo.getNode().getNodeId())) { + return; + } + currentTasks.add(nodeRecordInfo.getNode().getNodeId()); + } + + scheduler.execute( + () -> { + CompletableFuture retry = discoveryManager.ping(nodeRecordInfo.getNode()); + taskTimeouts.put( + nodeRecordInfo.getNode().getNodeId(), + () -> + retry.completeExceptionally(new RuntimeException("Timeout for node check task"))); + retry.whenComplete( + (aVoid, throwable) -> { + if (throwable != null) { + failCallback.run(); + currentTasks.remove(nodeRecordInfo.getNode().getNodeId()); + } else { + successCallback.run(); + currentTasks.remove(nodeRecordInfo.getNode().getNodeId()); + } + }); + }); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/RecursiveLookupTasks.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/RecursiveLookupTasks.java new file mode 100644 index 000000000..2c44e0fde --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/RecursiveLookupTasks.java @@ -0,0 +1,65 @@ +package org.ethereum.beacon.discovery.task; + +import com.google.common.collect.Sets; +import org.ethereum.beacon.discovery.DiscoveryManager; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.schedulers.Scheduler; +import org.ethereum.beacon.util.ExpirationScheduler; +import tech.pegasys.artemis.util.bytes.Bytes32; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Sends {@link TaskType#FINDNODE} to closest NodeRecords added via {@link #add(NodeRecordInfo, + * Runnable, Runnable)}. Tasks is called failed if timeout is reached and reply from node is not + * received. + */ +public class RecursiveLookupTasks { + private static final int DEFAULT_DISTANCE = 100; + private final Scheduler scheduler; + private final DiscoveryManager discoveryManager; + private final Set currentTasks = Sets.newConcurrentHashSet(); + private final ExpirationScheduler taskTimeouts; + + public RecursiveLookupTasks( + DiscoveryManager discoveryManager, Scheduler scheduler, Duration timeout) { + this.discoveryManager = discoveryManager; + this.scheduler = scheduler; + this.taskTimeouts = + new ExpirationScheduler<>(timeout.get(ChronoUnit.MILLIS), TimeUnit.MILLISECONDS); + } + + public void add(NodeRecordInfo nodeRecordInfo, Runnable successCallback, Runnable failCallback) { + synchronized (this) { + if (currentTasks.contains(nodeRecordInfo.getNode().getNodeId())) { + return; + } + currentTasks.add(nodeRecordInfo.getNode().getNodeId()); + } + + scheduler.execute( + () -> { + CompletableFuture retry = + discoveryManager.findNodes(nodeRecordInfo.getNode(), DEFAULT_DISTANCE); + taskTimeouts.put( + nodeRecordInfo.getNode().getNodeId(), + () -> + retry.completeExceptionally( + new RuntimeException("Timeout for node recursive lookup task"))); + retry.whenComplete( + (aVoid, throwable) -> { + if (throwable != null) { + failCallback.run(); + currentTasks.remove(nodeRecordInfo.getNode().getNodeId()); + } else { + successCallback.run(); + currentTasks.remove(nodeRecordInfo.getNode().getNodeId()); + } + }); + }); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskMessageFactory.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskMessageFactory.java new file mode 100644 index 000000000..51261fe96 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskMessageFactory.java @@ -0,0 +1,83 @@ +package org.ethereum.beacon.discovery.task; + +import org.ethereum.beacon.discovery.NodeSession; +import org.ethereum.beacon.discovery.message.DiscoveryV5Message; +import org.ethereum.beacon.discovery.message.FindNodeMessage; +import org.ethereum.beacon.discovery.message.PingMessage; +import org.ethereum.beacon.discovery.message.V5Message; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.pipeline.info.FindNodeRequestInfo; +import org.ethereum.beacon.discovery.pipeline.info.RequestInfo; +import tech.pegasys.artemis.util.bytes.BytesValue; + +public class TaskMessageFactory { + public static MessagePacket createPacketFromRequest( + RequestInfo requestInfo, BytesValue authTag, NodeSession session) { + switch (requestInfo.getTaskType()) { + case PING: + { + return createPingPacket(authTag, session, requestInfo.getRequestId()); + } + case FINDNODE: + { + FindNodeRequestInfo nodeRequestInfo = (FindNodeRequestInfo) requestInfo; + return createFindNodePacket( + authTag, session, requestInfo.getRequestId(), nodeRequestInfo.getDistance()); + } + default: + { + throw new RuntimeException( + String.format("Type %s is not supported!", requestInfo.getTaskType())); + } + } + } + + public static V5Message createMessageFromRequest(RequestInfo requestInfo, NodeSession session) { + switch (requestInfo.getTaskType()) { + case PING: + { + return createPing(session, requestInfo.getRequestId()); + } + case FINDNODE: + { + FindNodeRequestInfo nodeRequestInfo = (FindNodeRequestInfo) requestInfo; + return createFindNode(requestInfo.getRequestId(), nodeRequestInfo.getDistance()); + } + default: + { + throw new RuntimeException( + String.format("Type %s is not supported!", requestInfo.getTaskType())); + } + } + } + + public static MessagePacket createPingPacket( + BytesValue authTag, NodeSession session, BytesValue requestId) { + + return MessagePacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + authTag, + session.getInitiatorKey(), + DiscoveryV5Message.from(createPing(session, requestId))); + } + + public static PingMessage createPing(NodeSession session, BytesValue requestId) { + return new PingMessage(requestId, session.getNodeRecord().getSeq()); + } + + public static MessagePacket createFindNodePacket( + BytesValue authTag, NodeSession session, BytesValue requestId, int distance) { + FindNodeMessage findNodeMessage = createFindNode(requestId, distance); + return MessagePacket.create( + session.getHomeNodeId(), + session.getNodeRecord().getNodeId(), + authTag, + session.getInitiatorKey(), + DiscoveryV5Message.from(findNodeMessage)); + } + + public static FindNodeMessage createFindNode(BytesValue requestId, int distance) { + return new FindNodeMessage(requestId, distance); + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskOptions.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskOptions.java new file mode 100644 index 000000000..e5fa6539f --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskOptions.java @@ -0,0 +1,24 @@ +package org.ethereum.beacon.discovery.task; + +/** Specific options to clarify task features */ +public class TaskOptions { + private boolean livenessUpdate; + private int distance; + + public TaskOptions(boolean livenessUpdate) { + this.livenessUpdate = livenessUpdate; + } + + public TaskOptions(boolean livenessUpdate, int distance) { + this.livenessUpdate = livenessUpdate; + this.distance = distance; + } + + public boolean isLivenessUpdate() { + return livenessUpdate; + } + + public int getDistance() { + return distance; + } +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskStatus.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskStatus.java new file mode 100644 index 000000000..ea259b400 --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskStatus.java @@ -0,0 +1,8 @@ +package org.ethereum.beacon.discovery.task; + +public enum TaskStatus { + AWAIT, // waiting for handshake or whatever + SENT, // request sent + IN_PROCESS, // reply should contain several messages, at least one received but not all + // XXX: completed task is not stored, so no status for completed +} diff --git a/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskType.java b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskType.java new file mode 100644 index 000000000..efdad457d --- /dev/null +++ b/discovery/src/main/java/org/ethereum/beacon/discovery/task/TaskType.java @@ -0,0 +1,6 @@ +package org.ethereum.beacon.discovery.task; + +public enum TaskType { + PING, + FINDNODE; +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryInteropTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryInteropTest.java new file mode 100644 index 000000000..921b14950 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryInteropTest.java @@ -0,0 +1,135 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.IdentitySchema; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.storage.NodeBucket; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorageFactoryImpl; +import org.ethereum.beacon.schedulers.Schedulers; +import org.javatuples.Pair; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.publisher.Flux; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY; +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY_NO_VERIFICATION; +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.ethereum.beacon.discovery.TestUtil.parseStringIP; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * Inter-operational test with Geth. Start it in docker separately + * + *

You need to build and run Geth discv5 test to interact with. Configure Geth running time in + * test.sh located in `resources/geth`, after that build docker and run it: + * cd discovery/src/test/resources/geth + * docker build -t gethv5:1.0 . && docker run --network host -d gethv5:1.0 + * + * + *

After container starts, fire this test to fall in Geth's side running time and it should pass! + * You could check Geth test logs by following command: + * docker logs + * + */ +@Ignore("Requires manual startup, takes a bit to start") +public class DiscoveryInteropTest { + private static final BytesValue gethNodePrivKey = + BytesValue.fromHexString("fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + + @Test + public void testInterop() throws Exception { + // 1) start 2 nodes + Pair nodePair1 = TestUtil.generateNode(40412, true); + System.out.println(String.format("Node %s started", nodePair1.getValue1().getNodeId())); + NodeRecord nodeRecord1 = nodePair1.getValue1(); + // Geth node + NodeRecord nodeRecord2 = + NODE_RECORD_FACTORY.createFromValues( + UInt64.valueOf(1L), + Pair.with(EnrField.ID, IdentitySchema.V4), + Pair.with(EnrField.IP_V4, parseStringIP("127.0.0.1")), + Pair.with(EnrField.UDP_V4, 30303), + Pair.with( + EnrFieldV4.PKEY_SECP256K1, Functions.derivePublicKeyFromPrivate(gethNodePrivKey))); + nodeRecord2.sign(gethNodePrivKey); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database1 = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage1 = + nodeTableStorageFactory.createTable( + database1, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord1, + () -> + new ArrayList() { + { + add(nodeRecord2); + } + }); + NodeBucketStorage nodeBucketStorage1 = + nodeTableStorageFactory.createBucketStorage(database1, TEST_SERIALIZER, nodeRecord1); + DiscoveryManagerImpl discoveryManager1 = + new DiscoveryManagerImpl( + nodeTableStorage1.get(), + nodeBucketStorage1, + nodeRecord1, + nodePair1.getValue0(), + NODE_RECORD_FACTORY_NO_VERIFICATION, + Schedulers.createDefault().newSingleThreadDaemon("server-1"), + Schedulers.createDefault().newSingleThreadDaemon("tasks-1")); + + // 3) Expect standard 1 => 2 dialog + CountDownLatch randomSent1to2 = new CountDownLatch(1); + CountDownLatch authPacketSent1to2 = new CountDownLatch(1); + CountDownLatch nodesReceivedAt1 = new CountDownLatch(1); + + Flux.from(discoveryManager1.getOutgoingMessages()) + .map(p -> new UnknownPacket(p.getPacket().getBytes())) + .subscribe( + networkPacket -> { + // 1 -> 2 random + if (randomSent1to2.getCount() != 0) { + RandomPacket randomPacket = networkPacket.getRandomPacket(); + System.out.println("1 => 2: " + randomPacket); + randomSent1to2.countDown(); + } else if (authPacketSent1to2.getCount() != 0) { + // 1 -> 2 auth packet with FINDNODES + AuthHeaderMessagePacket authHeaderMessagePacket = + networkPacket.getAuthHeaderMessagePacket(); + System.out.println("1 => 2: " + authHeaderMessagePacket); + authPacketSent1to2.countDown(); + } + }); + + // 4) fire 1 to 2 dialog + discoveryManager1.start(); + int distance = Functions.logDistance(nodeRecord1.getNodeId(), nodeRecord2.getNodeId()); + discoveryManager1.findNodes(nodeRecord2, distance); + + assert randomSent1to2.await(1, TimeUnit.SECONDS); + // assert whoareyouSent2to1.await(1, TimeUnit.SECONDS); + int distance1To2 = Functions.logDistance(nodeRecord1.getNodeId(), nodeRecord2.getNodeId()); + assertFalse(nodeBucketStorage1.get(distance1To2).isPresent()); + assert authPacketSent1to2.await(1, TimeUnit.SECONDS); + Thread.sleep(1000); + // 1 sent findnodes to 2, received only (2) in answer + // 1 added 2 to its nodeBuckets, because its now checked, but not before + NodeBucket bucketAt1With2 = nodeBucketStorage1.get(distance1To2).get(); + assertEquals(1, bucketAt1With2.size()); + assertEquals( + nodeRecord2.getNodeId(), bucketAt1With2.getNodeRecords().get(0).getNode().getNodeId()); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNetworkTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNetworkTest.java new file mode 100644 index 000000000..8850fefc8 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNetworkTest.java @@ -0,0 +1,149 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.storage.NodeBucket; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorageFactoryImpl; +import org.ethereum.beacon.schedulers.Schedulers; +import org.javatuples.Pair; +import org.junit.Test; +import reactor.core.publisher.Flux; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY_NO_VERIFICATION; +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** Same as {@link DiscoveryNoNetworkTest} but using real network */ +public class DiscoveryNetworkTest { + @Test + public void test() throws Exception { + // 1) start 2 nodes + Pair nodePair1 = TestUtil.generateNode(30303, true); + Pair nodePair2 = TestUtil.generateNode(30304, true); + NodeRecord nodeRecord1 = nodePair1.getValue1(); + NodeRecord nodeRecord2 = nodePair2.getValue1(); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database1 = Database.inMemoryDB(); + Database database2 = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage1 = + nodeTableStorageFactory.createTable( + database1, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord1, + () -> + new ArrayList() { + { + add(nodeRecord2); + } + }); + NodeBucketStorage nodeBucketStorage1 = + nodeTableStorageFactory.createBucketStorage(database1, TEST_SERIALIZER, nodeRecord1); + NodeTableStorage nodeTableStorage2 = + nodeTableStorageFactory.createTable( + database2, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord2, + () -> + new ArrayList() { + { + add(nodeRecord1); + } + }); + NodeBucketStorage nodeBucketStorage2 = + nodeTableStorageFactory.createBucketStorage(database2, TEST_SERIALIZER, nodeRecord2); + DiscoveryManagerImpl discoveryManager1 = + new DiscoveryManagerImpl( + nodeTableStorage1.get(), + nodeBucketStorage1, + nodeRecord1, + nodePair1.getValue0(), + NODE_RECORD_FACTORY_NO_VERIFICATION, + Schedulers.createDefault().newSingleThreadDaemon("server-1"), + Schedulers.createDefault().newSingleThreadDaemon("tasks-1")); + DiscoveryManagerImpl discoveryManager2 = + new DiscoveryManagerImpl( + nodeTableStorage2.get(), + nodeBucketStorage2, + nodeRecord2, + nodePair2.getValue0(), + NODE_RECORD_FACTORY_NO_VERIFICATION, + Schedulers.createDefault().newSingleThreadDaemon("server-2"), + Schedulers.createDefault().newSingleThreadDaemon("tasks-2")); + + // 3) Expect standard 1 => 2 dialog + CountDownLatch randomSent1to2 = new CountDownLatch(1); + CountDownLatch whoareyouSent2to1 = new CountDownLatch(1); + CountDownLatch authPacketSent1to2 = new CountDownLatch(1); + CountDownLatch nodesSent2to1 = new CountDownLatch(1); + + Flux.from(discoveryManager1.getOutgoingMessages()) + .map(p -> new UnknownPacket(p.getPacket().getBytes())) + .subscribe( + networkPacket -> { + // 1 -> 2 random + if (randomSent1to2.getCount() != 0) { + RandomPacket randomPacket = networkPacket.getRandomPacket(); + System.out.println("1 => 2: " + randomPacket); + randomSent1to2.countDown(); + } else if (authPacketSent1to2.getCount() != 0) { + // 1 -> 2 auth packet with FINDNODES + AuthHeaderMessagePacket authHeaderMessagePacket = + networkPacket.getAuthHeaderMessagePacket(); + System.out.println("1 => 2: " + authHeaderMessagePacket); + authPacketSent1to2.countDown(); + } else { + throw new RuntimeException("Not expected!"); + } + }); + Flux.from(discoveryManager2.getOutgoingMessages()) + .map(p -> new UnknownPacket(p.getPacket().getBytes())) + .subscribe( + networkPacket -> { + // 2 -> 1 whoareyou + if (whoareyouSent2to1.getCount() != 0) { + WhoAreYouPacket whoAreYouPacket = networkPacket.getWhoAreYouPacket(); + System.out.println("2 => 1: " + whoAreYouPacket); + whoareyouSent2to1.countDown(); + } else { + // 2 -> 1 nodes + MessagePacket messagePacket = networkPacket.getMessagePacket(); + System.out.println("2 => 1: " + messagePacket); + nodesSent2to1.countDown(); + } + }); + + // 4) fire 1 to 2 dialog + discoveryManager2.start(); + discoveryManager1.start(); + discoveryManager1.findNodes(nodeRecord2, 0); + + assert randomSent1to2.await(1, TimeUnit.SECONDS); + assert whoareyouSent2to1.await(1, TimeUnit.SECONDS); + int distance1To2 = Functions.logDistance(nodeRecord1.getNodeId(), nodeRecord2.getNodeId()); + assertFalse(nodeBucketStorage1.get(distance1To2).isPresent()); + assert authPacketSent1to2.await(1, TimeUnit.SECONDS); + assert nodesSent2to1.await(1, TimeUnit.SECONDS); + Thread.sleep(50); + // 1 sent findnodes to 2, received only (2) in answer, because 3 is not checked + // 1 added 2 to its nodeBuckets, because its now checked, but not before + NodeBucket bucketAt1With2 = nodeBucketStorage1.get(distance1To2).get(); + assertEquals(1, bucketAt1With2.size()); + assertEquals( + nodeRecord2.getNodeId(), bucketAt1With2.getNodeRecords().get(0).getNode().getNodeId()); + } + + // TODO: discovery tasks are emitted from time to time as they should +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNoNetworkTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNoNetworkTest.java new file mode 100644 index 000000000..b5eae2ea1 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/DiscoveryNoNetworkTest.java @@ -0,0 +1,163 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.mock.DiscoveryManagerNoNetwork; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.packet.UnknownPacket; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.storage.NodeBucket; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorageFactoryImpl; +import org.ethereum.beacon.schedulers.Schedulers; +import org.ethereum.beacon.stream.SimpleProcessor; +import org.javatuples.Pair; +import org.junit.Test; +import reactor.core.publisher.Flux; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * Discovery test without real network, instead outgoing stream of each peer is connected with + * incoming of another and vice versa + */ +public class DiscoveryNoNetworkTest { + + @Test + public void test() throws Exception { + // 1) start 2 nodes + Pair nodePair1 = TestUtil.generateUnverifiedNode(30303); + Pair nodePair2 = TestUtil.generateUnverifiedNode(30304); + Pair nodePair3 = TestUtil.generateUnverifiedNode(40412); + NodeRecord nodeRecord1 = nodePair1.getValue1(); + NodeRecord nodeRecord2 = nodePair2.getValue1(); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database1 = Database.inMemoryDB(); + Database database2 = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage1 = + nodeTableStorageFactory.createTable( + database1, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord1, + () -> + new ArrayList() { + { + add(nodeRecord2); + } + }); + NodeBucketStorage nodeBucketStorage1 = + nodeTableStorageFactory.createBucketStorage(database1, TEST_SERIALIZER, nodeRecord1); + NodeTableStorage nodeTableStorage2 = + nodeTableStorageFactory.createTable( + database2, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord2, + () -> + new ArrayList() { + { + add(nodeRecord1); + add(nodePair3.getValue1()); + } + }); + NodeBucketStorage nodeBucketStorage2 = + nodeTableStorageFactory.createBucketStorage(database2, TEST_SERIALIZER, nodeRecord2); + SimpleProcessor from1to2 = + new SimpleProcessor<>( + Schedulers.createDefault().newSingleThreadDaemon("from1to2-thread"), "from1to2"); + SimpleProcessor from2to1 = + new SimpleProcessor<>( + Schedulers.createDefault().newSingleThreadDaemon("from2to1-thread"), "from2to1"); + DiscoveryManagerNoNetwork discoveryManager1 = + new DiscoveryManagerNoNetwork( + nodeTableStorage1.get(), + nodeBucketStorage1, + nodeRecord1, + nodePair1.getValue0(), + from2to1, + Schedulers.createDefault().newSingleThreadDaemon("tasks-1")); + DiscoveryManagerNoNetwork discoveryManager2 = + new DiscoveryManagerNoNetwork( + nodeTableStorage2.get(), + nodeBucketStorage2, + nodeRecord2, + nodePair2.getValue0(), + from1to2, + Schedulers.createDefault().newSingleThreadDaemon("tasks-2")); + + // 2) Link outgoing of each one with incoming of another + Flux.from(discoveryManager1.getOutgoingMessages()) + .subscribe(t -> from1to2.onNext(t.getPacket().getBytes())); + Flux.from(discoveryManager2.getOutgoingMessages()) + .subscribe(t -> from2to1.onNext(t.getPacket().getBytes())); + + // 3) Expect standard 1 => 2 dialog + CountDownLatch randomSent1to2 = new CountDownLatch(1); + CountDownLatch whoareyouSent2to1 = new CountDownLatch(1); + CountDownLatch authPacketSent1to2 = new CountDownLatch(1); + CountDownLatch nodesSent2to1 = new CountDownLatch(1); + Flux.from(from1to2) + .map(UnknownPacket::new) + .subscribe( + networkPacket -> { + // 1 -> 2 random + if (randomSent1to2.getCount() != 0) { + RandomPacket randomPacket = networkPacket.getRandomPacket(); + System.out.println("1 => 2: " + randomPacket); + randomSent1to2.countDown(); + } else if (authPacketSent1to2.getCount() != 0) { + // 1 -> 2 auth packet with FINDNODES + AuthHeaderMessagePacket authHeaderMessagePacket = + networkPacket.getAuthHeaderMessagePacket(); + System.out.println("1 => 2: " + authHeaderMessagePacket); + authPacketSent1to2.countDown(); + } + }); + Flux.from(from2to1) + .map(UnknownPacket::new) + .subscribe( + networkPacket -> { + // 2 -> 1 whoareyou + if (whoareyouSent2to1.getCount() != 0) { + WhoAreYouPacket whoAreYouPacket = networkPacket.getWhoAreYouPacket(); + System.out.println("2 => 1: " + whoAreYouPacket); + whoareyouSent2to1.countDown(); + } else { + // 2 -> 1 nodes + MessagePacket messagePacket = networkPacket.getMessagePacket(); + System.out.println("2 => 1: " + messagePacket); + nodesSent2to1.countDown(); + } + }); + + // 4) fire 1 to 2 dialog + discoveryManager2.start(); + discoveryManager1.start(); + discoveryManager1.findNodes(nodeRecord2, 0); + + assert randomSent1to2.await(1, TimeUnit.SECONDS); + assert whoareyouSent2to1.await(1, TimeUnit.SECONDS); + int distance1To2 = Functions.logDistance(nodeRecord1.getNodeId(), nodeRecord2.getNodeId()); + assertFalse(nodeBucketStorage1.get(distance1To2).isPresent()); + assert authPacketSent1to2.await(1, TimeUnit.SECONDS); + assert nodesSent2to1.await(1, TimeUnit.SECONDS); + Thread.sleep(50); + // 1 sent findnodes to 2, received 0 nodes in answer, because 3 is not checked + // 1 added 2 to its nodeBuckets, because its now checked, but not before + NodeBucket bucketAt1With2 = nodeBucketStorage1.get(distance1To2).get(); + assertEquals(1, bucketAt1With2.size()); + assertEquals( + nodeRecord2.getNodeId(), bucketAt1With2.getNodeRecords().get(0).getNode().getNodeId()); + } + + // TODO: discovery tasks are emitted from time to time as they should +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/FunctionsTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/FunctionsTest.java new file mode 100644 index 000000000..b39f77182 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/FunctionsTest.java @@ -0,0 +1,140 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.util.Utils; +import org.junit.Test; +import org.web3j.crypto.ECKeyPair; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import static org.ethereum.beacon.discovery.Functions.PUBKEY_SIZE; +import static org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket.createIdNonceMessage; +import static org.junit.Assert.assertEquals; + +public class FunctionsTest { + private final BytesValue testKey1 = + BytesValue.fromHexString("3332ca2b7003810449b6e596c3d284e914a1a51c9f76e4d9d7d43ef84adf6ed6"); + private final BytesValue testKey2 = + BytesValue.fromHexString("66fb62bfbd66b9177a138c1e5cddbe4f7c30c343e94e68df8769459cb1cde628"); + private Bytes32 nodeId1; + private Bytes32 nodeId2; + + public FunctionsTest() { + byte[] homeNodeIdBytes = new byte[32]; + homeNodeIdBytes[0] = 0x01; + byte[] destNodeIdBytes = new byte[32]; + destNodeIdBytes[0] = 0x02; + this.nodeId1 = Bytes32.wrap(homeNodeIdBytes); + this.nodeId2 = Bytes32.wrap(destNodeIdBytes); + } + + @Test + public void testLogDistance() { + Bytes32 nodeId0 = + Bytes32.fromHexString("0000000000000000000000000000000000000000000000000000000000000000"); + Bytes32 nodeId1a = + Bytes32.fromHexString("0000000000000000000000000000000000000000000000000000000000000001"); + Bytes32 nodeId1b = + Bytes32.fromHexString("1000000000000000000000000000000000000000000000000000000000000000"); + Bytes32 nodeId1s = + Bytes32.fromHexString("1111111111111111111111111111111111111111111111111111111111111111"); + Bytes32 nodeId9s = + Bytes32.fromHexString("9999999999999999999999999999999999999999999999999999999999999999"); + Bytes32 nodeIdfs = + Bytes32.fromHexString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + assertEquals(0, Functions.logDistance(nodeId1a, nodeId1a)); + assertEquals(1, Functions.logDistance(nodeId0, nodeId1a)); + // So it's big endian + assertEquals(253, Functions.logDistance(nodeId0, nodeId1b)); + assertEquals(253, Functions.logDistance(nodeId0, nodeId1s)); + assertEquals(256, Functions.logDistance(nodeId0, nodeId9s)); + // maximum distance + assertEquals(256, Functions.logDistance(nodeId0, nodeIdfs)); + // logDistance is not an additive function + assertEquals(255, Functions.logDistance(nodeId9s, nodeIdfs)); + } + + @Test + public void hkdfExpandTest() { + BytesValue idNonce = + Bytes32.fromHexString("68b02a985ecb99cc2d10cf188879d93ae7684c4f4707770017b078c6497c5a5d"); + Functions.HKDFKeys keys1 = + Functions.hkdf_expand( + nodeId1, + nodeId2, + testKey1, + BytesValue.wrap(ECKeyPair.create(testKey2.extractArray()).getPublicKey().toByteArray()), + idNonce); + Functions.HKDFKeys keys2 = + Functions.hkdf_expand( + nodeId1, + nodeId2, + testKey2, + BytesValue.wrap(ECKeyPair.create(testKey1.extractArray()).getPublicKey().toByteArray()), + idNonce); + assertEquals(keys1, keys2); + } + + @Test + public void testGcmSimple() { + BytesValue authResponseKey = BytesValue.fromHexString("0x60bfc5c924a8d640f47df8b781f5a0e5"); + BytesValue authResponsePt = + BytesValue.fromHexString( + "0xf8aa05b8404f5fa8309cab170dbeb049de504b519288777aae0c4b25686f82310206a4a1e264dc6e8bfaca9187e8b3dbb56f49c7aa3d22bff3a279bf38fb00cb158b7b8ca7b865f86380018269648276348375647082765f826970847f00000189736563703235366b31b84013d14211e0287b2361a1615890a9b5212080546d0a257ae4cff96cf534992cb97e6adeb003652e807c7f2fe843e0c48d02d4feb0272e2e01f6e27915a431e773"); + BytesValue zeroNonce = BytesValue.wrap(new byte[12]); + BytesValue authResponse = + Functions.aesgcm_encrypt(authResponseKey, zeroNonce, authResponsePt, BytesValue.EMPTY); + BytesValue authResponsePtDecrypted = + Functions.aesgcm_decrypt(authResponseKey, zeroNonce, authResponse, BytesValue.EMPTY); + assertEquals(authResponsePt, authResponsePtDecrypted); + } + + @Test + public void testRecoverFromSignature() throws Exception { + BytesValue idNonceSig = + BytesValue.fromHexString( + "0xcf2bf743fc2273709bbc5117fd72775b0661ce1b6e9dffa01f45e2307fb138b90da16364ee7ae1705b938f6648d7725d35fe7e3f200e0ea022c1360b9b2e7385"); + BytesValue ephemeralKey = + BytesValue.fromHexString( + "0x9961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231503061ac4aaee666073d7e5bc2c80c3f5c5b500c1cb5fd0a76abbb6b675ad157"); + BytesValue nonce = + BytesValue.fromHexString( + "0x02a77e3aa0c144ae7c0a3af73692b7d6e5b7a2fdc0eda16e8d5e6cb0d08e88dd04"); + BytesValue privKey = + BytesValue.fromHexString( + "0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + BytesValue pubKeyUncompressed = + BytesValue.wrap( + Utils.extractBytesFromUnsignedBigInt( + ECKeyPair.create(privKey.extractArray()).getPublicKey(), PUBKEY_SIZE)); + BytesValue pubKey = + BytesValue.wrap(Functions.publicKeyToPoint(pubKeyUncompressed).getEncoded(true)); + + BytesValue message = + BytesValue.wrap("discovery-id-nonce".getBytes()).concat(nonce).concat(ephemeralKey); + assert Functions.verifyECDSASignature(idNonceSig, Functions.hash(message), pubKey); + } + + @Test + public void testSignAndRecoverFromSignature() { + BytesValue idNonce = + BytesValue.fromHexString( + "0xd550ca9d62930c947efff75b58a4ea1b44716d841cc0d690879d4f3cab5a4e84"); + BytesValue ephemeralPubkey = + BytesValue.fromHexString( + "0xd9bc9158f0a0c40e75490de66ef44f865588d1c7110b29d0c479db19f7644ddad2d8e948cb933bd767437b173888409d73644a36ae1d068997217357a22d674f"); + BytesValue privKey = + BytesValue.fromHexString( + "0xb5a8efa45da6906663cf7a158cd506da71ae7d732a68220e6644468526bb098e"); + BytesValue pubKey = + BytesValue.wrap( + Functions.publicKeyToPoint( + BytesValue.wrap( + Utils.extractBytesFromUnsignedBigInt( + ECKeyPair.create(privKey.extractArray()).getPublicKey(), 64))) + .getEncoded(true)); + BytesValue idNonceSig = AuthHeaderMessagePacket.signIdNonce(idNonce, privKey, ephemeralPubkey); + assert Functions.verifyECDSASignature( + idNonceSig, Functions.hash(createIdNonceMessage(idNonce, ephemeralPubkey)), pubKey); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/HandshakeHandlersTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/HandshakeHandlersTest.java new file mode 100644 index 000000000..875db6848 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/HandshakeHandlersTest.java @@ -0,0 +1,191 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.Packet; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.pipeline.PipelineImpl; +import org.ethereum.beacon.discovery.pipeline.handler.AuthHeaderMessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.MessageHandler; +import org.ethereum.beacon.discovery.pipeline.handler.MessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouPacketHandler; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorage; +import org.ethereum.beacon.discovery.storage.NodeTableStorageFactoryImpl; +import org.ethereum.beacon.discovery.task.TaskMessageFactory; +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; +import org.ethereum.beacon.schedulers.Scheduler; +import org.ethereum.beacon.schedulers.Schedulers; +import org.javatuples.Pair; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.ArrayList; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY_NO_VERIFICATION; +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.ethereum.beacon.discovery.pipeline.Field.BAD_PACKET; +import static org.ethereum.beacon.discovery.pipeline.Field.MESSAGE; +import static org.ethereum.beacon.discovery.pipeline.Field.PACKET_AUTH_HEADER_MESSAGE; +import static org.ethereum.beacon.discovery.pipeline.Field.PACKET_MESSAGE; +import static org.ethereum.beacon.discovery.pipeline.Field.SESSION; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class HandshakeHandlersTest { + + @Test + public void authHandlerWithMessageRoundTripTest() throws Exception { + // Node1 + Pair nodePair1 = TestUtil.generateUnverifiedNode(30303); + NodeRecord nodeRecord1 = nodePair1.getValue1(); + // Node2 + Pair nodePair2 = TestUtil.generateUnverifiedNode(30304); + NodeRecord nodeRecord2 = nodePair2.getValue1(); + Random rnd = new Random(); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database1 = Database.inMemoryDB(); + Database database2 = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage1 = + nodeTableStorageFactory.createTable( + database1, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord1, + () -> + new ArrayList() { + { + add(nodeRecord2); + } + }); + NodeBucketStorage nodeBucketStorage1 = + nodeTableStorageFactory.createBucketStorage(database1, TEST_SERIALIZER, nodeRecord1); + NodeTableStorage nodeTableStorage2 = + nodeTableStorageFactory.createTable( + database2, + TEST_SERIALIZER, + (oldSeq) -> nodeRecord2, + () -> + new ArrayList() { + { + add(nodeRecord1); + } + }); + NodeBucketStorage nodeBucketStorage2 = + nodeTableStorageFactory.createBucketStorage(database2, TEST_SERIALIZER, nodeRecord2); + + // Node1 create AuthHeaderPacket + final Packet[] outgoing1Packets = new Packet[2]; + final Semaphore outgoing1PacketsSemaphore = new Semaphore(2); + outgoing1PacketsSemaphore.acquire(2); + final Consumer outgoingMessages1to2 = + packet -> { + System.out.println("Outgoing packet from 1 to 2: " + packet); + outgoing1Packets[outgoing1PacketsSemaphore.availablePermits()] = packet; + outgoing1PacketsSemaphore.release(1); + }; + AuthTagRepository authTagRepository1 = new AuthTagRepository(); + NodeSession nodeSessionAt1For2 = + new NodeSession( + nodeRecord2, + nodeRecord1, + nodePair1.getValue0(), + nodeTableStorage1.get(), + nodeBucketStorage1, + authTagRepository1, + outgoingMessages1to2, + rnd); + final Consumer outgoingMessages2to1 = + packet -> { + // do nothing, we don't need to test it here + }; + NodeSession nodeSessionAt2For1 = + new NodeSession( + nodeRecord1, + nodeRecord2, + nodePair2.getValue0(), + nodeTableStorage2.get(), + nodeBucketStorage2, + new AuthTagRepository(), + outgoingMessages2to1, + rnd); + + Scheduler taskScheduler = Schedulers.createDefault().events(); + Pipeline outgoingPipeline = new PipelineImpl().build(); + WhoAreYouPacketHandler whoAreYouPacketHandlerNode1 = + new WhoAreYouPacketHandler(outgoingPipeline, taskScheduler); + Envelope envelopeAt1From2 = new Envelope(); + byte[] idNonceBytes = new byte[32]; + Functions.getRandom().nextBytes(idNonceBytes); + Bytes32 idNonce = Bytes32.wrap(idNonceBytes); + nodeSessionAt2For1.setIdNonce(idNonce); + BytesValue authTag = nodeSessionAt2For1.generateNonce(); + authTagRepository1.put(authTag, nodeSessionAt1For2); + envelopeAt1From2.put( + Field.PACKET_WHOAREYOU, + WhoAreYouPacket.createFromNodeId(nodePair1.getValue1().getNodeId(), authTag, idNonce, UInt64.ZERO)); + envelopeAt1From2.put(Field.SESSION, nodeSessionAt1For2); + CompletableFuture future = new CompletableFuture<>(); + nodeSessionAt1For2.createNextRequest(TaskType.FINDNODE, new TaskOptions(true), future); + whoAreYouPacketHandlerNode1.handle(envelopeAt1From2); + assert outgoing1PacketsSemaphore.tryAcquire(1, 1, TimeUnit.SECONDS); + outgoing1PacketsSemaphore.release(); + + // Node2 handle AuthHeaderPacket and finish handshake + AuthHeaderMessagePacketHandler authHeaderMessagePacketHandlerNode2 = + new AuthHeaderMessagePacketHandler( + outgoingPipeline, taskScheduler, NODE_RECORD_FACTORY_NO_VERIFICATION); + Envelope envelopeAt2From1 = new Envelope(); + envelopeAt2From1.put(PACKET_AUTH_HEADER_MESSAGE, outgoing1Packets[0]); + envelopeAt2From1.put(SESSION, nodeSessionAt2For1); + assertFalse(nodeSessionAt2For1.isAuthenticated()); + authHeaderMessagePacketHandlerNode2.handle(envelopeAt2From1); + assertTrue(nodeSessionAt2For1.isAuthenticated()); + + // Node 1 handles message from Node 2 + MessagePacketHandler messagePacketHandler1 = new MessagePacketHandler(); + Envelope envelopeAt1From2WithMessage = new Envelope(); + BytesValue pingAuthTag = nodeSessionAt1For2.generateNonce(); + MessagePacket pingPacketFrom2To1 = + TaskMessageFactory.createPingPacket( + pingAuthTag, + nodeSessionAt2For1, + nodeSessionAt2For1 + .createNextRequest(TaskType.PING, new TaskOptions(true), new CompletableFuture<>()) + .getRequestId()); + envelopeAt1From2WithMessage.put(PACKET_MESSAGE, pingPacketFrom2To1); + envelopeAt1From2WithMessage.put(SESSION, nodeSessionAt1For2); + messagePacketHandler1.handle(envelopeAt1From2WithMessage); + assertNull(envelopeAt1From2WithMessage.get(BAD_PACKET)); + assertNotNull(envelopeAt1From2WithMessage.get(MESSAGE)); + + MessageHandler messageHandler = new MessageHandler(NODE_RECORD_FACTORY_NO_VERIFICATION); + messageHandler.handle(envelopeAt1From2WithMessage); + assert outgoing1PacketsSemaphore.tryAcquire(2, 1, TimeUnit.SECONDS); + + // Node 2 handles message from Node 1 + MessagePacketHandler messagePacketHandler2 = new MessagePacketHandler(); + Envelope envelopeAt2From1WithMessage = new Envelope(); + Packet pongPacketFrom1To2 = outgoing1Packets[1]; + MessagePacket pongMessagePacketFrom1To2 = (MessagePacket) pongPacketFrom1To2; + envelopeAt2From1WithMessage.put(PACKET_MESSAGE, pongMessagePacketFrom1To2); + envelopeAt2From1WithMessage.put(SESSION, nodeSessionAt2For1); + messagePacketHandler2.handle(envelopeAt2From1WithMessage); + assertNull(envelopeAt2From1WithMessage.get(BAD_PACKET)); + assertNotNull(envelopeAt2From1WithMessage.get(MESSAGE)); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/NodeRecordTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/NodeRecordTest.java new file mode 100644 index 000000000..74d904ac2 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/NodeRecordTest.java @@ -0,0 +1,116 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.IdentitySchema; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.javatuples.Pair; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.net.InetAddress; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.ethereum.beacon.discovery.TestUtil.SEED; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * ENR serialization/deserialization test + * + *

ENR - Ethereum Node Record, according to https://eips.ethereum.org/EIPS/eip-778 + */ +public class NodeRecordTest { + private static final NodeRecordFactory NODE_RECORD_FACTORY = NodeRecordFactory.DEFAULT; + + @Test + public void testLocalhostV4() throws Exception { + final String expectedHost = "127.0.0.1"; + final Integer expectedUdpPort = 30303; + final Integer expectedTcpPort = null; + final UInt64 expectedSeqNumber = UInt64.valueOf(1); + final BytesValue expectedPublicKey = + BytesValue.fromHexString( + "03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138"); + final BytesValue expectedSignature = + BytesValue.fromHexString( + "7098ad865b00a582051940cb9cf36836572411a47278783077011599ed5cd16b76f2635f4e234738f30813a89eb9137e3e3df5266e3a1f11df72ecf1145ccb9c"); + + final String localhostEnr = + "-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"; + NodeRecord nodeRecord = NODE_RECORD_FACTORY.fromBase64(localhostEnr); + + assertEquals(IdentitySchema.V4, nodeRecord.getIdentityScheme()); + assertArrayEquals( + InetAddress.getByName(expectedHost).getAddress(), + ((BytesValue) nodeRecord.get(EnrField.IP_V4)).extractArray()); + assertEquals(expectedUdpPort, nodeRecord.get(EnrField.UDP_V4)); + assertEquals(expectedTcpPort, nodeRecord.get(EnrField.TCP_V4)); + assertEquals(expectedSeqNumber, nodeRecord.getSeq()); + assertEquals(expectedPublicKey, nodeRecord.get(EnrFieldV4.PKEY_SECP256K1)); + assertEquals(expectedSignature, nodeRecord.getSignature()); + + String localhostEnrRestored = nodeRecord.asBase64(); + // The order of fields is not strict so we don't compare strings + NodeRecord nodeRecordRestored = NODE_RECORD_FACTORY.fromBase64(localhostEnrRestored); + + assertEquals(IdentitySchema.V4, nodeRecordRestored.getIdentityScheme()); + assertArrayEquals( + InetAddress.getByName(expectedHost).getAddress(), + ((BytesValue) nodeRecordRestored.get(EnrField.IP_V4)).extractArray()); + assertEquals(expectedUdpPort, nodeRecordRestored.get(EnrField.UDP_V4)); + assertEquals(expectedTcpPort, nodeRecordRestored.get(EnrField.TCP_V4)); + assertEquals(expectedSeqNumber, nodeRecordRestored.getSeq()); + assertEquals(expectedPublicKey, nodeRecordRestored.get(EnrFieldV4.PKEY_SECP256K1)); + assertEquals(expectedSignature, nodeRecordRestored.getSignature()); + } + + @Test + public void testSignature() throws Exception { + Random rnd = new Random(SEED); + byte[] privKey = new byte[32]; + rnd.nextBytes(privKey); + Bytes4 localIp = Bytes4.wrap(InetAddress.getByName("127.0.0.1").getAddress()); + NodeRecord nodeRecord0 = + NodeRecordFactory.DEFAULT.createFromValues( + UInt64.ZERO, + Pair.with(EnrField.ID, IdentitySchema.V4), + Pair.with(EnrField.IP_V4, localIp), + Pair.with(EnrField.TCP_V4, 30303), + Pair.with(EnrField.UDP_V4, 30303), + Pair.with( + EnrFieldV4.PKEY_SECP256K1, + Functions.derivePublicKeyFromPrivate(BytesValue.wrap(privKey)))); + nodeRecord0.sign(BytesValue.wrap(privKey)); + nodeRecord0.verify(); + NodeRecord nodeRecord1 = + NodeRecordFactory.DEFAULT.createFromValues( + UInt64.valueOf(1), + Pair.with(EnrField.ID, IdentitySchema.V4), + Pair.with(EnrField.IP_V4, localIp), + Pair.with(EnrField.TCP_V4, 30303), + Pair.with(EnrField.UDP_V4, 30303), + Pair.with( + EnrFieldV4.PKEY_SECP256K1, + Functions.derivePublicKeyFromPrivate(BytesValue.wrap(privKey)))); + nodeRecord1.sign(BytesValue.wrap(privKey)); + nodeRecord1.verify(); + assertNotEquals(nodeRecord0.serialize(), nodeRecord1.serialize()); + assertNotEquals(nodeRecord0.getSignature(), nodeRecord1.getSignature()); + nodeRecord1.setSignature(nodeRecord0.getSignature()); + AtomicBoolean exceptionThrown = new AtomicBoolean(false); + try { + nodeRecord1.verify(); + } catch (AssertionError ex) { + exceptionThrown.set(true); + } + assertTrue(exceptionThrown.get()); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/SubTests.java b/discovery/src/test/java/org/ethereum/beacon/discovery/SubTests.java new file mode 100644 index 000000000..e58f10c07 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/SubTests.java @@ -0,0 +1,31 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.util.Utils; +import org.junit.Test; +import org.web3j.crypto.ECKeyPair; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.math.BigInteger; + +import static org.ethereum.beacon.discovery.Functions.PUBKEY_SIZE; +import static org.junit.Assert.assertEquals; + +/** + * Secondary tests not directly related to discovery but clarifying functions used somewhere in + * discovery routines + */ +public class SubTests { + /** + * Tests BigInteger to byte[]. Take a look at {@link + * Utils#extractBytesFromUnsignedBigInt(BigInteger, int)} for understanding the issue. + */ + @Test + public void testPubKeyBadPrefix() { + BytesValue privKey = + BytesValue.fromHexString( + "0xade78b68f25611ea57532f86bf01da909cc463465ed9efce9395403ff7fc99b5"); + ECKeyPair badKey = ECKeyPair.create(privKey.extractArray()); + byte[] pubKey = Utils.extractBytesFromUnsignedBigInt(badKey.getPublicKey(), PUBKEY_SIZE); + assertEquals(64, pubKey.length); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/TestUtil.java b/discovery/src/test/java/org/ethereum/beacon/discovery/TestUtil.java new file mode 100644 index 000000000..48071b4e8 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/TestUtil.java @@ -0,0 +1,88 @@ +package org.ethereum.beacon.discovery; + +import org.ethereum.beacon.chain.storage.impl.SerializerFactory; +import org.ethereum.beacon.discovery.enr.EnrField; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.IdentitySchema; +import org.ethereum.beacon.discovery.enr.IdentitySchemaV4Interpreter; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.mock.IdentitySchemaV4InterpreterMock; +import org.ethereum.beacon.discovery.storage.NodeSerializerFactory; +import org.javatuples.Pair; +import tech.pegasys.artemis.util.bytes.Bytes4; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Random; + +public class TestUtil { + public static final NodeRecordFactory NODE_RECORD_FACTORY = + new NodeRecordFactory(new IdentitySchemaV4Interpreter()); + public static final NodeRecordFactory NODE_RECORD_FACTORY_NO_VERIFICATION = + new NodeRecordFactory( + new IdentitySchemaV4InterpreterMock()); // doesn't verify ECDSA signature + public static final SerializerFactory TEST_SERIALIZER = + new NodeSerializerFactory(NODE_RECORD_FACTORY_NO_VERIFICATION); + public static final String LOCALHOST = "127.0.0.1"; + static final int SEED = 123456789; + + /** + * Generates node on 127.0.0.1 with provided port. Node key is random, but always the same for the + * same port. Signature is not valid if verified. + * + * @return + */ + public static Pair generateUnverifiedNode(int port) { + return generateNode(port, false); + } + + /** Parses string representation of IPv4. Say, `127.0.0.1` */ + public static Bytes4 parseStringIP(String ip) { + try { + return Bytes4.wrap(InetAddress.getByName(ip).getAddress()); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } + /** + * Generates node on 127.0.0.1 with provided port. Node key is random, but always the same for the + * same port. + * + * @param port listen port + * @param verification whether to use valid signature + * @return + */ + public static Pair generateNode(int port, boolean verification) { + final Random rnd = new Random(SEED); + final Bytes4 finalLocalIp = parseStringIP(LOCALHOST); + for (int i = 0; i < port; ++i) { + rnd.nextBoolean(); // skip according to input + } + byte[] privateKey = new byte[32]; + rnd.nextBytes(privateKey); + + NodeRecordFactory nodeRecordFactory = + verification ? NODE_RECORD_FACTORY : NODE_RECORD_FACTORY_NO_VERIFICATION; + NodeRecord nodeRecord = + nodeRecordFactory.createFromValues( + UInt64.valueOf(1), + new ArrayList>() { + { + add(Pair.with(EnrField.ID, IdentitySchema.V4)); + add(Pair.with(EnrField.IP_V4, finalLocalIp)); + add(Pair.with(EnrField.UDP_V4, port)); + add( + Pair.with( + EnrFieldV4.PKEY_SECP256K1, + Functions.derivePublicKeyFromPrivate(BytesValue.wrap(privateKey)))); + } + }); + + nodeRecord.sign(BytesValue.wrap(privateKey)); + return Pair.with(BytesValue.wrap(privateKey), nodeRecord); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/community/AuthHeaderMessagePacketTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/community/AuthHeaderMessagePacketTest.java new file mode 100644 index 000000000..c93b86c03 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/community/AuthHeaderMessagePacketTest.java @@ -0,0 +1,108 @@ +package org.ethereum.beacon.discovery.community; + +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import static org.junit.Assert.assertEquals; + +/** Tests {@link AuthHeaderMessagePacket} packet creation routines */ +public class AuthHeaderMessagePacketTest { + /** + * This first section entails signature generation, and adding any ENR into `auth-pt`. In this + * example, there is no ENR sent. This tests the signature generation and correct RLP encoding of + * the `auth-pt` before encryption. + */ + @Test + public void testAuthPtGeneration() { + BytesValue secretKey = + BytesValue.fromHexString( + "0x7e8107fe766b6d357205280acf65c24275129ca9e44c0fd00144ca50024a1ce7"); + BytesValue idNonce = + BytesValue.fromHexString( + "0xe551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c65"); + BytesValue ephemeralPubkey = + BytesValue.fromHexString( + "0xb35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81"); + // enr: [] + BytesValue expectedAuthPt = + BytesValue.fromHexString( + "0xf84405b840f753ac31b017536bacd0d0238a1f849e741aef03b7ad5db1d4e64d7aa80689931f21e590edcf80ee32bb2f30707fec88fb62ea8fbcd65b9272e9a0175fea976bc0"); + assertEquals( + expectedAuthPt, + BytesValue.wrap( + AuthHeaderMessagePacket.createAuthMessagePt( + AuthHeaderMessagePacket.signIdNonce(idNonce, secretKey, ephemeralPubkey), null))); + } + + /** + * The `auth-pt` must then be encrypted with AES-GCM. The auth-header uses a 12-byte 0 nonce with + * no authenticated data. + */ + @Test + public void testEncryptAuthMessagePt() { + BytesValue authRespKey = BytesValue.fromHexString("0x8c7caa563cebc5c06bb15fc1a2d426c3"); + BytesValue authPt = + BytesValue.fromHexString( + "0xf84405b840f753ac31b017536bacd0d0238a1f849e741aef03b7ad5db1d4e64d7aa80689931f21e590edcf80ee32bb2f30707fec88fb62ea8fbcd65b9272e9a0175fea976bc0"); + + BytesValue expectedAuthRespCiphertext = + BytesValue.fromHexString( + "0x570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852"); + assertEquals( + expectedAuthRespCiphertext, + AuthHeaderMessagePacket.encodeAuthResponse(authPt.extractArray(), authRespKey)); + } + + /** + * An authentication header is built. This test vector demonstrates the correct RLP-encoding of + * the authentication header with the above inputs. + */ + @Test + public void testAuthHeaderGeneration() { + BytesValue authTag = BytesValue.fromHexString("0x27b5af763c446acd2749fe8e"); + BytesValue idNonce = + BytesValue.fromHexString( + "0xe551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c65"); + BytesValue ephemeralPubkey = + BytesValue.fromHexString( + "0xb35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81"); + BytesValue authRespCiphertext = + BytesValue.fromHexString( + "0x570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852"); + + BytesValue expectedAuthHeaderRlp = + BytesValue.fromHexString( + "0xf8cc8c27b5af763c446acd2749fe8ea0e551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c658367636db840b35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81b856570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852"); + assertEquals( + expectedAuthHeaderRlp, + AuthHeaderMessagePacket.encodeAuthHeaderRlp( + authTag, idNonce, ephemeralPubkey, authRespCiphertext)); + } + + /** + * This combines the previously generated authentication header with encryption of the protocol + * message, providing the final rlp-encoded message with an authentication header. + */ + @Test + public void testEncodeMessage() { + Bytes32 tag = + Bytes32.fromHexString("0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903"); + BytesValue authHeaderRlp = + BytesValue.fromHexString( + "0xf8cc8c27b5af763c446acd2749fe8ea0e551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c658367636db840b35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81b856570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852"); + BytesValue encryptionKey = BytesValue.fromHexString("0x9f2d77db7004bf8a1a85107ac686990b"); + BytesValue messagePlaintext = BytesValue.fromHexString("0x01c20101"); + BytesValue authTag = BytesValue.fromHexString("0x27b5af763c446acd2749fe8e"); + + BytesValue expectedAuthMessageRlp = + BytesValue.fromHexString( + "0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903f8cc8c27b5af763c446acd2749fe8ea0e551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c658367636db840b35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81b856570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852a5d12a2d94b8ccb3ba55558229867dc13bfa3648"); + BytesValue encryptedData = + Functions.aesgcm_encrypt(encryptionKey, authTag, messagePlaintext, tag); + BytesValue authHeaderMessagePacket = tag.concat(authHeaderRlp).concat(encryptedData); + assertEquals(expectedAuthMessageRlp, authHeaderMessagePacket); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/community/CryptoTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/community/CryptoTest.java new file mode 100644 index 000000000..2434ee9b4 --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/community/CryptoTest.java @@ -0,0 +1,114 @@ +package org.ethereum.beacon.discovery.community; + +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import static org.junit.Assert.assertEquals; + +/** Tests crypto functions */ +public class CryptoTest { + + /** + * The ECDH function takes the elliptic-curve scalar multiplication of a public key and a private + * key. The wire protocol describes this process. + * + *

The input public key is an uncompressed secp256k1 key (64 bytes) and the private key is a + * raw secp256k1 private key (32 bytes). + */ + @Test + public void testECDHFunction() { + BytesValue publicKey = + BytesValue.fromHexString( + "0x9961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231503061ac4aaee666073d7e5bc2c80c3f5c5b500c1cb5fd0a76abbb6b675ad157"); + BytesValue secretKey = + BytesValue.fromHexString( + "0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + + BytesValue expectedSharedSecret = + BytesValue.fromHexString( + "0x033b11a2a1f214567e1537ce5e509ffd9b21373247f2a3ff6841f4976f53165e7e"); + assertEquals(expectedSharedSecret, Functions.deriveECDHKeyAgreement(secretKey, publicKey)); + } + + /** + * This test vector takes a secret key (as calculated from the previous test vector) along with + * two node id's and an `id-nonce`. This demonstrates the HKDF-EXPAND and HKDF-EXTRACT functions + * using the added key-agreement string as described in the wire specification. + * + *

Given a secret key (calculated from ECDH above) two `node-id`s (required to build the `info` + * as described in the specification) and the `id-nonce` (required for the HKDF-EXTRACT function), + * this should produce an `initiator-key`, `recipient-key` and an `auth-resp-key`. + */ + @Test + public void testHKDFExpand() { + BytesValue secretKey = + BytesValue.fromHexString( + "0x02a77e3aa0c144ae7c0a3af73692b7d6e5b7a2fdc0eda16e8d5e6cb0d08e88dd04"); + BytesValue nodeIdA = + BytesValue.fromHexString( + "0xa448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7"); + BytesValue nodeIdB = + BytesValue.fromHexString( + "0x885bba8dfeddd49855459df852ad5b63d13a3fae593f3f9fa7e317fd43651409"); + BytesValue idNonce = + BytesValue.fromHexString( + "0x0101010101010101010101010101010101010101010101010101010101010101"); + + BytesValue expectedInitiatorKey = + BytesValue.fromHexString("0x238d8b50e4363cf603a48c6cc3542967"); + BytesValue expectedRecipientKey = + BytesValue.fromHexString("0xbebc0183484f7e7ca2ac32e3d72c8891"); + BytesValue expectedAuthResponseKey = + BytesValue.fromHexString("0xe987ad9e414d5b4f9bfe4ff1e52f2fae"); + Functions.HKDFKeys keys = Functions.hkdf_expand(nodeIdA, nodeIdB, secretKey, idNonce); + assertEquals(expectedInitiatorKey, keys.getInitiatorKey()); + assertEquals(expectedRecipientKey, keys.getRecipientKey()); + assertEquals(expectedAuthResponseKey, keys.getAuthResponseKey()); + } + + /** + * Nonce signatures should prefix the string `discovery-id-nonce` and post-fix the ephemeral key + * before taking the `sha256` hash of the `id-nonce`. + * + *

See {@link org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket}, idNonceSig is a + * part of this packet + */ + @Test + public void testIdNonceSigning() { + BytesValue idNonce = + BytesValue.fromHexString( + "0xa77e3aa0c144ae7c0a3af73692b7d6e5b7a2fdc0eda16e8d5e6cb0d08e88dd04"); + BytesValue ephemeralKey = + BytesValue.fromHexString( + "0x9961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231503061ac4aaee666073d7e5bc2c80c3f5c5b500c1cb5fd0a76abbb6b675ad157"); + BytesValue localSecretKey = + BytesValue.fromHexString( + "0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + + BytesValue expectedIdNonceSig = + BytesValue.fromHexString( + "0xc5036e702a79902ad8aa147dabfe3958b523fd6fa36cc78e2889b912d682d8d35fdea142e141f690736d86f50b39746ba2d2fc510b46f82ee08f08fd55d133a4"); + assertEquals( + expectedIdNonceSig, + AuthHeaderMessagePacket.signIdNonce(idNonce, localSecretKey, ephemeralKey)); + } + + /** + * This test vector demonstrates the `AES_GCM` encryption/decryption used in the wire protocol. + */ + @Test + public void testAESGCM() { + BytesValue encryptionKey = BytesValue.fromHexString("0x9f2d77db7004bf8a1a85107ac686990b"); + BytesValue nonce = BytesValue.fromHexString("0x27b5af763c446acd2749fe8e"); + BytesValue pt = BytesValue.fromHexString("0x01c20101"); + BytesValue ad = + BytesValue.fromHexString( + "0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903"); + + BytesValue expectedMessageCiphertext = + BytesValue.fromHexString("a5d12a2d94b8ccb3ba55558229867dc13bfa3648"); + assertEquals(expectedMessageCiphertext, Functions.aesgcm_encrypt(encryptionKey, nonce, pt, ad)); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/community/MessageEncodingTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/community/MessageEncodingTest.java new file mode 100644 index 000000000..1413bb6ae --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/community/MessageEncodingTest.java @@ -0,0 +1,68 @@ +package org.ethereum.beacon.discovery.community; + +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.message.FindNodeMessage; +import org.ethereum.beacon.discovery.message.NodesMessage; +import org.ethereum.beacon.discovery.message.PingMessage; +import org.ethereum.beacon.discovery.message.PongMessage; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class MessageEncodingTest { + @Test + public void encodePing() { + PingMessage pingMessage = + new PingMessage(BytesValue.wrap(UInt64.valueOf(1).toBI().toByteArray()), UInt64.valueOf(1)); + assertEquals(BytesValue.fromHexString("0x01c20101"), pingMessage.getBytes()); + } + + @Test + public void encodePong() throws Exception { + PongMessage pongMessage = + new PongMessage( + BytesValue.wrap(UInt64.valueOf(1).toBI().toByteArray()), + UInt64.valueOf(1), + BytesValue.wrap(InetAddress.getByName("127.0.0.1").getAddress()), + 5000); + assertEquals(BytesValue.fromHexString("0x02ca0101847f000001821388"), pongMessage.getBytes()); + } + + @Test + public void encodeFindNode() { + FindNodeMessage findNodeMessage = + new FindNodeMessage(BytesValue.wrap(UInt64.valueOf(1).toBI().toByteArray()), 256); + assertEquals(BytesValue.fromHexString("0x03c401820100"), findNodeMessage.getBytes()); + } + + @Test + public void encodeNodes() { + NodeRecordFactory nodeRecordFactory = NodeRecordFactory.DEFAULT; + NodesMessage nodesMessage = + new NodesMessage( + BytesValue.wrap(UInt64.valueOf(1).toBI().toByteArray()), + 2, + () -> { + List nodeRecords = new ArrayList<>(); + nodeRecords.add( + nodeRecordFactory.fromBase64( + "-HW4QBzimRxkmT18hMKaAL3IcZF1UcfTMPyi3Q1pxwZZbcZVRI8DC5infUAB_UauARLOJtYTxaagKoGmIjzQxO2qUygBgmlkgnY0iXNlY3AyNTZrMaEDymNMrg1JrLQB2KTGtv6MVbcNEVv0AHacwUAPMljNMTg")); + nodeRecords.add( + nodeRecordFactory.fromBase64( + "-HW4QNfxw543Ypf4HXKXdYxkyzfcxcO-6p9X986WldfVpnVTQX1xlTnWrktEWUbeTZnmgOuAY_KUhbVV1Ft98WoYUBMBgmlkgnY0iXNlY3AyNTZrMaEDDiy3QkHAxPyOgWbxp5oF1bDdlYE6dLCUUp8xfVw50jU")); + return nodeRecords; + }, + 2); + assertEquals( + BytesValue.fromHexString( + "0x04f8f20102f8eef875b8401ce2991c64993d7c84c29a00bdc871917551c7d330fca2dd0d69c706596dc655448f030b98a77d4001fd46ae0112ce26d613c5a6a02a81a6223cd0c4edaa53280182696482763489736563703235366b31a103ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138f875b840d7f1c39e376297f81d7297758c64cb37dcc5c3beea9f57f7ce9695d7d5a67553417d719539d6ae4b445946de4d99e680eb8063f29485b555d45b7df16a1850130182696482763489736563703235366b31a1030e2cb74241c0c4fc8e8166f1a79a05d5b0dd95813a74b094529f317d5c39d235"), + nodesMessage.getBytes()); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/community/PacketEncodingTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/community/PacketEncodingTest.java new file mode 100644 index 000000000..590d22e5b --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/community/PacketEncodingTest.java @@ -0,0 +1,88 @@ +package org.ethereum.beacon.discovery.community; + +import org.ethereum.beacon.discovery.packet.AuthHeaderMessagePacket; +import org.ethereum.beacon.discovery.packet.MessagePacket; +import org.ethereum.beacon.discovery.packet.RandomPacket; +import org.ethereum.beacon.discovery.packet.WhoAreYouPacket; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import static org.junit.Assert.assertEquals; + +public class PacketEncodingTest { + @Test + public void encodeRandomPacketTest() { + RandomPacket randomPacket = + RandomPacket.create( + Bytes32.fromHexString( + "0x0101010101010101010101010101010101010101010101010101010101010101"), + BytesValue.fromHexString("0x020202020202020202020202"), + BytesValue.fromHexString( + "0x0404040404040404040404040404040404040404040404040404040404040404040404040404040404040404")); + assertEquals( + BytesValue.fromHexString( + "0x01010101010101010101010101010101010101010101010101010101010101018c0202020202020202020202020404040404040404040404040404040404040404040404040404040404040404040404040404040404040404"), + randomPacket.getBytes()); + } + + @Test + public void encodeWhoAreYouTest() { + WhoAreYouPacket whoAreYouPacket = + WhoAreYouPacket.createFromMagic( + BytesValue.fromHexString( + "0x0101010101010101010101010101010101010101010101010101010101010101"), + BytesValue.fromHexString("0x020202020202020202020202"), + Bytes32.fromHexString( + "0x0303030303030303030303030303030303030303030303030303030303030303"), + UInt64.valueOf(1)); + assertEquals( + BytesValue.fromHexString( + "0101010101010101010101010101010101010101010101010101010101010101ef8c020202020202020202020202a0030303030303030303030303030303030303030303030303030303030303030301"), + whoAreYouPacket.getBytes()); + } + + @Test + public void encodeAuthPacketTest() { + Bytes32 tag = + Bytes32.fromHexString("0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903"); + BytesValue authTag = BytesValue.fromHexString("0x27b5af763c446acd2749fe8e"); + BytesValue idNonce = + BytesValue.fromHexString( + "0xe551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c65"); + BytesValue ephemeralPubkey = + BytesValue.fromHexString( + "0xb35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81"); + BytesValue authRespCiphertext = + BytesValue.fromHexString( + "0x570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852"); + BytesValue messageCiphertext = + BytesValue.fromHexString("0xa5d12a2d94b8ccb3ba55558229867dc13bfa3648"); + BytesValue authHeader = + AuthHeaderMessagePacket.encodeAuthHeaderRlp( + authTag, idNonce, ephemeralPubkey, authRespCiphertext); + AuthHeaderMessagePacket authHeaderMessagePacket = + AuthHeaderMessagePacket.create(tag, authHeader, messageCiphertext); + + assertEquals( + BytesValue.fromHexString( + "0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903f8cc8c27b5af763c446acd2749fe8ea0e551b1c44264ab92bc0b3c9b26293e1ba4fed9128f3c3645301e8e119f179c658367636db840b35608c01ee67edff2cffa424b219940a81cf2fb9b66068b1cf96862a17d353e22524fbdcdebc609f85cbd58ebe7a872b01e24a3829b97dd5875e8ffbc4eea81b856570fbf23885c674867ab00320294a41732891457969a0f14d11c995668858b2ad731aa7836888020e2ccc6e0e5776d0d4bc4439161798565a4159aa8620992fb51dcb275c4f755c8b8030c82918898f1ac387f606852a5d12a2d94b8ccb3ba55558229867dc13bfa3648"), + authHeaderMessagePacket.getBytes()); + } + + @Test + public void encodeMessagePacketTest() { + MessagePacket messagePacket = + MessagePacket.create( + Bytes32.fromHexString( + "0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f42107903"), + BytesValue.fromHexString("0x27b5af763c446acd2749fe8e"), + BytesValue.fromHexString("0xa5d12a2d94b8ccb3ba55558229867dc13bfa3648")); + + assertEquals( + BytesValue.fromHexString( + "0x93a7400fa0d6a694ebc24d5cf570f65d04215b6ac00757875e3f3a5f421079038c27b5af763c446acd2749fe8ea5d12a2d94b8ccb3ba55558229867dc13bfa3648"), + messagePacket.getBytes()); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/mock/DiscoveryManagerNoNetwork.java b/discovery/src/test/java/org/ethereum/beacon/discovery/mock/DiscoveryManagerNoNetwork.java new file mode 100644 index 000000000..47551acae --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/mock/DiscoveryManagerNoNetwork.java @@ -0,0 +1,136 @@ +package org.ethereum.beacon.discovery.mock; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ethereum.beacon.discovery.DiscoveryManager; +import org.ethereum.beacon.discovery.DiscoveryManagerImpl; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.ethereum.beacon.discovery.enr.NodeRecordFactory; +import org.ethereum.beacon.discovery.network.NetworkParcel; +import org.ethereum.beacon.discovery.pipeline.Envelope; +import org.ethereum.beacon.discovery.pipeline.Field; +import org.ethereum.beacon.discovery.pipeline.Pipeline; +import org.ethereum.beacon.discovery.pipeline.PipelineImpl; +import org.ethereum.beacon.discovery.pipeline.handler.AuthHeaderMessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.BadPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.IncomingDataPacker; +import org.ethereum.beacon.discovery.pipeline.handler.MessageHandler; +import org.ethereum.beacon.discovery.pipeline.handler.MessagePacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NewTaskHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NextTaskHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NodeIdToSession; +import org.ethereum.beacon.discovery.pipeline.handler.NodeSessionRequestHandler; +import org.ethereum.beacon.discovery.pipeline.handler.NotExpectedIncomingPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.OutgoingParcelHandler; +import org.ethereum.beacon.discovery.pipeline.handler.UnknownPacketTagToSender; +import org.ethereum.beacon.discovery.pipeline.handler.UnknownPacketTypeByStatus; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouAttempt; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouPacketHandler; +import org.ethereum.beacon.discovery.pipeline.handler.WhoAreYouSessionResolver; +import org.ethereum.beacon.discovery.storage.AuthTagRepository; +import org.ethereum.beacon.discovery.storage.NodeBucketStorage; +import org.ethereum.beacon.discovery.storage.NodeTable; +import org.ethereum.beacon.discovery.task.TaskOptions; +import org.ethereum.beacon.discovery.task.TaskType; +import org.ethereum.beacon.schedulers.Scheduler; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.ReplayProcessor; +import tech.pegasys.artemis.util.bytes.BytesValue; + +import java.util.concurrent.CompletableFuture; + +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY_NO_VERIFICATION; + +/** + * Implementation of {@link DiscoveryManager} without network as an opposite to Netty network + * implementation {@link org.ethereum.beacon.discovery.DiscoveryManagerImpl} Outgoing packets could + * be obtained from `outgoingMessages` publisher, using {@link #getOutgoingMessages()}, incoming + * packets could be provided through the constructor parameter `incomingPackets` + */ +public class DiscoveryManagerNoNetwork implements DiscoveryManager { + private static final Logger logger = LogManager.getLogger(DiscoveryManagerImpl.class); + private final ReplayProcessor outgoingMessages = ReplayProcessor.cacheLast(); + private final FluxSink outgoingSink = outgoingMessages.sink(); + private final Publisher incomingPackets; + private final Pipeline incomingPipeline = new PipelineImpl(); + private final Pipeline outgoingPipeline = new PipelineImpl(); + private final NodeRecordFactory nodeRecordFactory = + NODE_RECORD_FACTORY_NO_VERIFICATION; // no signature verification + + public DiscoveryManagerNoNetwork( + NodeTable nodeTable, + NodeBucketStorage nodeBucketStorage, + NodeRecord homeNode, + BytesValue homeNodePrivateKey, + Publisher incomingPackets, + Scheduler taskScheduler) { + AuthTagRepository authTagRepo = new AuthTagRepository(); + this.incomingPackets = incomingPackets; + NodeIdToSession nodeIdToSession = + new NodeIdToSession( + homeNode, + homeNodePrivateKey, + nodeBucketStorage, + authTagRepo, + nodeTable, + outgoingPipeline); + incomingPipeline + .addHandler(new IncomingDataPacker()) + .addHandler(new WhoAreYouAttempt(homeNode.getNodeId())) + .addHandler(new WhoAreYouSessionResolver(authTagRepo)) + .addHandler(new UnknownPacketTagToSender(homeNode)) + .addHandler(nodeIdToSession) + .addHandler(new UnknownPacketTypeByStatus()) + .addHandler(new NotExpectedIncomingPacketHandler()) + .addHandler(new WhoAreYouPacketHandler(outgoingPipeline, taskScheduler)) + .addHandler( + new AuthHeaderMessagePacketHandler(outgoingPipeline, taskScheduler, nodeRecordFactory)) + .addHandler(new MessagePacketHandler()) + .addHandler(new MessageHandler(nodeRecordFactory)) + .addHandler(new BadPacketHandler()); + outgoingPipeline + .addHandler(new OutgoingParcelHandler(outgoingSink)) + .addHandler(new NodeSessionRequestHandler()) + .addHandler(nodeIdToSession) + .addHandler(new NewTaskHandler()) + .addHandler(new NextTaskHandler(outgoingPipeline, taskScheduler)); + } + + @Override + public void start() { + incomingPipeline.build(); + outgoingPipeline.build(); + Flux.from(incomingPackets).subscribe(incomingPipeline::push); + } + + @Override + public void stop() {} + + private CompletableFuture executeTaskImpl( + NodeRecord nodeRecord, TaskType taskType, TaskOptions taskOptions) { + Envelope envelope = new Envelope(); + envelope.put(Field.NODE, nodeRecord); + CompletableFuture future = new CompletableFuture<>(); + envelope.put(Field.TASK, taskType); + envelope.put(Field.FUTURE, future); + envelope.put(Field.TASK_OPTIONS, taskOptions); + outgoingPipeline.push(envelope); + return future; + } + + @Override + public CompletableFuture findNodes(NodeRecord nodeRecord, int distance) { + return executeTaskImpl(nodeRecord, TaskType.FINDNODE, new TaskOptions(true, distance)); + } + + @Override + public CompletableFuture ping(NodeRecord nodeRecord) { + return executeTaskImpl(nodeRecord, TaskType.PING, new TaskOptions(true)); + } + + public Publisher getOutgoingMessages() { + return outgoingMessages; + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/mock/IdentitySchemaV4InterpreterMock.java b/discovery/src/test/java/org/ethereum/beacon/discovery/mock/IdentitySchemaV4InterpreterMock.java new file mode 100644 index 000000000..631ff419b --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/mock/IdentitySchemaV4InterpreterMock.java @@ -0,0 +1,17 @@ +package org.ethereum.beacon.discovery.mock; + +import org.ethereum.beacon.discovery.enr.IdentitySchemaV4Interpreter; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import tech.pegasys.artemis.util.bytes.Bytes96; + +public class IdentitySchemaV4InterpreterMock extends IdentitySchemaV4Interpreter { + @Override + public void verify(NodeRecord nodeRecord) { + // Don't verify signature + } + + @Override + public void sign(NodeRecord nodeRecord, Object signOptions) { + nodeRecord.setSignature(Bytes96.ZERO); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeBucketTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeBucketTest.java new file mode 100644 index 000000000..affda3dae --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeBucketTest.java @@ -0,0 +1,102 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.Functions; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeStatus; +import org.ethereum.beacon.discovery.TestUtil; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.junit.Test; + +import java.util.Random; +import java.util.stream.IntStream; + +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NodeBucketTest { + private final Random rnd = new Random(); + + private NodeRecordInfo generateUniqueRecord(int portInc) { + NodeRecord nodeRecord = TestUtil.generateUnverifiedNode(30303 + portInc).getValue1(); + return new NodeRecordInfo(nodeRecord, 0L, NodeStatus.ACTIVE, 0); + } + + @Test + public void testBucket() { + NodeBucket nodeBucket = new NodeBucket(); + IntStream.range(0, 20).forEach(value -> nodeBucket.put(generateUniqueRecord(value))); + assertEquals(NodeBucket.K, nodeBucket.size()); + assertEquals(NodeBucket.K, nodeBucket.getNodeRecords().size()); + + long lastRetrySaved = -1L; + for (NodeRecordInfo nodeRecordInfo : nodeBucket.getNodeRecords()) { + assert nodeRecordInfo.getLastRetry() + >= lastRetrySaved; // Assert sorted by last retry, latest retry in the end + lastRetrySaved = nodeRecordInfo.getLastRetry(); + } + NodeRecordInfo willNotInsertNode = + new NodeRecordInfo(generateUniqueRecord(25).getNode(), -2L, NodeStatus.ACTIVE, 0); + nodeBucket.put(willNotInsertNode); + assertFalse(nodeBucket.contains(willNotInsertNode)); + NodeRecordInfo willInsertNode = + new NodeRecordInfo(generateUniqueRecord(26).getNode(), 1001L, NodeStatus.ACTIVE, 0); + NodeRecordInfo top = + nodeBucket.getNodeRecords().get(NodeBucket.K - 1); // latest retry should be kept + NodeRecordInfo bottom = nodeBucket.getNodeRecords().get(0); + nodeBucket.put(willInsertNode); + assertTrue(nodeBucket.contains(willInsertNode)); + assertTrue(nodeBucket.contains(top)); + assertFalse(nodeBucket.contains(bottom)); + NodeRecordInfo willInsertNode2 = + new NodeRecordInfo(willInsertNode.getNode(), 1002L, NodeStatus.ACTIVE, 0); + nodeBucket.put(willInsertNode2); // replaces willInsertNode with better last retry + assertTrue(nodeBucket.getNodeRecords().contains(willInsertNode2)); + NodeRecordInfo willNotInsertNode3 = + new NodeRecordInfo(willInsertNode.getNode(), 999L, NodeStatus.ACTIVE, 0); + nodeBucket.put(willNotInsertNode3); // does not replace willInsertNode with worse last retry + assertTrue(nodeBucket.getNodeRecords().contains(willInsertNode2)); + assertTrue(nodeBucket.getNodeRecords().contains(top)); + + NodeRecordInfo willInsertNodeDead = + new NodeRecordInfo(willInsertNode.getNode(), 1001L, NodeStatus.DEAD, 0); + nodeBucket.put(willInsertNodeDead); // removes willInsertNode + assertEquals(NodeBucket.K - 1, nodeBucket.size()); + assertFalse(nodeBucket.contains(willInsertNode2)); + } + + @Test + public void testStorage() { + NodeRecordInfo initial = generateUniqueRecord(0); + Database database = Database.inMemoryDB(); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + NodeBucketStorage nodeBucketStorage = + nodeTableStorageFactory.createBucketStorage(database, TEST_SERIALIZER, initial.getNode()); + + int j = 1; + for (int i = 0; i < 20; ) { + NodeRecordInfo nodeRecordInfo = generateUniqueRecord(j); + if (Functions.logDistance(initial.getNode().getNodeId(), nodeRecordInfo.getNode().getNodeId()) + == 255) { + nodeBucketStorage.put(nodeRecordInfo); + ++i; + } + ++j; + } + for (int i = 0; i < 3; ) { + NodeRecordInfo nodeRecordInfo = generateUniqueRecord(j); + if (Functions.logDistance(initial.getNode().getNodeId(), nodeRecordInfo.getNode().getNodeId()) + == 254) { + nodeBucketStorage.put(nodeRecordInfo); + ++i; + } + ++j; + } + assertEquals(16, nodeBucketStorage.get(255).get().size()); + assertEquals(3, nodeBucketStorage.get(254).get().size()); + assertFalse(nodeBucketStorage.get(253).isPresent()); + assertFalse(nodeBucketStorage.get(256).isPresent()); + } +} diff --git a/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeTableTest.java b/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeTableTest.java new file mode 100644 index 000000000..49065548b --- /dev/null +++ b/discovery/src/test/java/org/ethereum/beacon/discovery/storage/NodeTableTest.java @@ -0,0 +1,128 @@ +package org.ethereum.beacon.discovery.storage; + +import org.ethereum.beacon.db.Database; +import org.ethereum.beacon.discovery.NodeRecordInfo; +import org.ethereum.beacon.discovery.NodeStatus; +import org.ethereum.beacon.discovery.TestUtil; +import org.ethereum.beacon.discovery.enr.EnrFieldV4; +import org.ethereum.beacon.discovery.enr.NodeRecord; +import org.junit.Test; +import tech.pegasys.artemis.util.bytes.Bytes32; +import tech.pegasys.artemis.util.bytes.BytesValue; +import tech.pegasys.artemis.util.uint.UInt64; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import static org.ethereum.beacon.discovery.TestUtil.NODE_RECORD_FACTORY_NO_VERIFICATION; +import static org.ethereum.beacon.discovery.TestUtil.TEST_SERIALIZER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class NodeTableTest { + final String LOCALHOST_BASE64 = + "-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMRo9bfkceoY0W04hSgYU5Q1R_mmq3Qp9pBPMAIduKrAYN1ZHCCdl8="; + private Function HOME_NODE_SUPPLIER = + (oldSeq) -> TestUtil.generateUnverifiedNode(30303).getValue1(); + + @Test + public void testCreate() throws Exception { + NodeRecord nodeRecord = NODE_RECORD_FACTORY_NO_VERIFICATION.fromBase64(LOCALHOST_BASE64); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage = + nodeTableStorageFactory.createTable( + database, + TEST_SERIALIZER, + HOME_NODE_SUPPLIER, + () -> { + List nodes = new ArrayList<>(); + nodes.add(nodeRecord); + return nodes; + }); + Optional extendedEnr = nodeTableStorage.get().getNode(nodeRecord.getNodeId()); + assertTrue(extendedEnr.isPresent()); + NodeRecordInfo nodeRecord2 = extendedEnr.get(); + assertEquals( + nodeRecord.get(EnrFieldV4.PKEY_SECP256K1), + nodeRecord2.getNode().get(EnrFieldV4.PKEY_SECP256K1)); + assertEquals( + nodeTableStorage.get().getHomeNode().getNodeId(), + HOME_NODE_SUPPLIER.apply(UInt64.ZERO).getNodeId()); + } + + @Test + public void testFind() throws Exception { + NodeRecord localHostNode = NODE_RECORD_FACTORY_NO_VERIFICATION.fromBase64(LOCALHOST_BASE64); + NodeTableStorageFactoryImpl nodeTableStorageFactory = new NodeTableStorageFactoryImpl(); + Database database = Database.inMemoryDB(); + NodeTableStorage nodeTableStorage = + nodeTableStorageFactory.createTable( + database, + TEST_SERIALIZER, + HOME_NODE_SUPPLIER, + () -> { + List nodes = new ArrayList<>(); + nodes.add(localHostNode); + return nodes; + }); + + // node is adjusted to be close to localhostEnr + NodeRecord closestNode = TestUtil.generateUnverifiedNode(30267).getValue1(); + nodeTableStorage.get().save(new NodeRecordInfo(closestNode, -1L, NodeStatus.ACTIVE, 0)); + assertEquals( + nodeTableStorage + .get() + .getNode(closestNode.getNodeId()) + .get() + .getNode() + .get(EnrFieldV4.PKEY_SECP256K1), + closestNode.get(EnrFieldV4.PKEY_SECP256K1)); + // node is adjusted to be far from localhostEnr + NodeRecord farNode = TestUtil.generateUnverifiedNode(30304).getValue1(); + nodeTableStorage.get().save(new NodeRecordInfo(farNode, -1L, NodeStatus.ACTIVE, 0)); + List closestNodes = + nodeTableStorage.get().findClosestNodes(closestNode.getNodeId(), 254); + assertEquals(2, closestNodes.size()); + Set publicKeys = new HashSet<>(); + closestNodes.forEach( + n -> publicKeys.add((BytesValue) n.getNode().get(EnrFieldV4.PKEY_SECP256K1))); + assertTrue(publicKeys.contains(localHostNode.get(EnrFieldV4.PKEY_SECP256K1))); + assertTrue(publicKeys.contains(closestNode.get(EnrFieldV4.PKEY_SECP256K1))); + List farNodes = nodeTableStorage.get().findClosestNodes(farNode.getNodeId(), 1); + assertEquals(1, farNodes.size()); + assertEquals( + farNodes.get(0).getNode().get(EnrFieldV4.PKEY_SECP256K1), + farNode.get(EnrFieldV4.PKEY_SECP256K1)); + } + + /** + * Verifies that calculated index number is in range of [0, {@link + * NodeTableImpl#NUMBER_OF_INDEXES}) + */ + @Test + public void testIndexCalculation() { + Bytes32 nodeId0 = + Bytes32.fromHexString("0000000000000000000000000000000000000000000000000000000000000000"); + Bytes32 nodeId1a = + Bytes32.fromHexString("0000000000000000000000000000000000000000000000000000000000000001"); + Bytes32 nodeId1b = + Bytes32.fromHexString("1000000000000000000000000000000000000000000000000000000000000000"); + Bytes32 nodeId1s = + Bytes32.fromHexString("1111111111111111111111111111111111111111111111111111111111111111"); + Bytes32 nodeId9s = + Bytes32.fromHexString("9999999999999999999999999999999999999999999999999999999999999999"); + Bytes32 nodeIdfs = + Bytes32.fromHexString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + assertEquals(0, NodeTableImpl.getNodeIndex(nodeId0)); + assertEquals(0, NodeTableImpl.getNodeIndex(nodeId1a)); + assertEquals(16, NodeTableImpl.getNodeIndex(nodeId1b)); + assertEquals(17, NodeTableImpl.getNodeIndex(nodeId1s)); + assertEquals(153, NodeTableImpl.getNodeIndex(nodeId9s)); + assertEquals(255, NodeTableImpl.getNodeIndex(nodeIdfs)); + } +} diff --git a/discovery/src/test/resources/geth/Dockerfile b/discovery/src/test/resources/geth/Dockerfile new file mode 100644 index 000000000..d82728d74 --- /dev/null +++ b/discovery/src/test/resources/geth/Dockerfile @@ -0,0 +1,21 @@ +# Build Geth branch with discv5 implementation +FROM golang:1.12-alpine as builder + +RUN apk add --no-cache make gcc musl-dev linux-headers git + +ARG branch=discover-v5-rebase +RUN git clone --depth 1 --branch $branch https://github.com/fjl/go-ethereum.git + +ADD . /go-ethereum +RUN cd /go/go-ethereum && go mod init github.com/ethereum/go-ethereum + +ADD v5_interop_test.go /go/go-ethereum/p2p/discover/v5_interop_test.go +ADD test.sh /test.sh +RUN chmod +x /test.sh + +# Idle run to download all dependencies for the test +ENV TEST_DURATION=1 +RUN cd /go/go-ethereum/p2p/discover && go test -run TestNodesServer + +EXPOSE 8545 8546 30303 30303/udp +ENTRYPOINT ["/test.sh"] \ No newline at end of file diff --git a/discovery/src/test/resources/geth/test.sh b/discovery/src/test/resources/geth/test.sh new file mode 100644 index 000000000..3f5ed130c --- /dev/null +++ b/discovery/src/test/resources/geth/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +#set -e +export TEST_DURATION=60 +echo "Run interop_test for ${TEST_DURATION} seconds" +cd /go/go-ethereum/p2p/discover && go test -v -run TestNodesServer diff --git a/discovery/src/test/resources/geth/v5_interop_test.go b/discovery/src/test/resources/geth/v5_interop_test.go new file mode 100644 index 000000000..21926ce9c --- /dev/null +++ b/discovery/src/test/resources/geth/v5_interop_test.go @@ -0,0 +1,111 @@ +package discover + +import ( + "crypto/ecdsa" + "fmt" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/internal/testlog" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/p2p/enode" + "github.com/ethereum/go-ethereum/p2p/enr" + "net" + "os" + "strconv" + "testing" + "time" +) + +// Start two servers on 30302 and 30303 ports, run for $TEST_DURATION seconds, exit +func TestNodesServer(t *testing.T) { + var nodes []*UDPv5 + startPort := 30302 + for i := 0; i < 2; i++ { + var cfg Config + if len(nodes) > 0 { + bn := nodes[0].Self() + cfg.Bootnodes = []*enode.Node{bn} + } + var key *ecdsa.PrivateKey + if i == 1 { + // Predefined key for server on 30303 port, so we could easily connect to it outside the test + key, _ = crypto.HexToECDSA("fb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736") + } + curnode := startLocalhostOnPortV5(t, cfg, startPort+i, key) + fmt.Printf("port %v: %v\n", startPort+i, curnode.localNode.Node()) + fillTable(curnode.tab, Map(nodes, func(pv5 *UDPv5) *node { + return wrapNode(pv5.localNode.Node()) + })) // force fill buckets + <-curnode.tab.initDone + nodes = append(nodes, curnode) + defer curnode.Close() + } + + // Wait for interop call + fmt.Printf("Waiting for external nodes query\n") + c1 := make(chan string, 1) + + // Run your long running function in it's own goroutine and pass back it's + // response into our channel. + go func() { + text := LongRunningProcess() + c1 <- text + }() + + // Listen on our channel AND a timeout channel - which ever happens first. + duration, err := strconv.Atoi(os.Getenv("TEST_DURATION")) + if err != nil { + panic("TEST_DURATION env not set or failed to be parsed" + err.Error()) + } + select { + case res := <-c1: + fmt.Println(res) + case <-time.After(time.Duration(duration) * time.Second): + fmt.Println("out of time :(") + } +} + +func LongRunningProcess() string { + time.Sleep(9000 * time.Hour) + return "You will never see this :)" +} + +func Map(vs []*UDPv5, f func(*UDPv5) *node) []*node { + vsm := make([]*node, len(vs)) + for i, v := range vs { + vsm[i] = f(v) + } + return vsm +} + +func startLocalhostOnPortV5(t *testing.T, cfg Config, port int, key *ecdsa.PrivateKey) *UDPv5 { + if key == nil { + cfg.PrivateKey = newkey() + } else { + cfg.PrivateKey = key + } + db, _ := enode.OpenDB("") + ln := enode.NewLocalNode(db, cfg.PrivateKey) + + // Prefix logs with node ID. + lprefix := fmt.Sprintf("(%s)", ln.ID().TerminalString()) + lfmt := log.TerminalFormat(false) + cfg.Log = testlog.Logger(t, log.LvlTrace) + cfg.Log.SetHandler(log.FuncHandler(func(r *log.Record) error { + t.Logf("%s %s", lprefix, lfmt.Format(r)) + return nil + })) + + // Listen. + socket, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP{127, 0, 0, 1}, Port: port}) + if err != nil { + t.Fatal(err) + } + realaddr := socket.LocalAddr().(*net.UDPAddr) + ln.SetStaticIP(realaddr.IP) + ln.Set(enr.UDP(realaddr.Port)) + udp, err := ListenV5(socket, ln, cfg) + if err != nil { + t.Fatal(err) + } + return udp +} diff --git a/discovery/src/test/resources/log4j2.xml b/discovery/src/test/resources/log4j2.xml new file mode 100644 index 000000000..6e8add2a1 --- /dev/null +++ b/discovery/src/test/resources/log4j2.xml @@ -0,0 +1,23 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level - %msg%n + + + + + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index 2318a5e94..b3f574263 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,8 @@ include 'core' include 'crypto' // DB persistence core interfaces include 'db:core' +// Discovery v5 protocol implementation +include 'discovery' // PoW (Proof of Work) interfaces etc include 'pow:core' // PoW made with EthereumJ @@ -43,5 +45,5 @@ include 'validator:core' include 'validator:embedded' // Validator server-side api include 'validator:server' -// Wire API mock +// Wire API include 'wire' diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/ArrayWrappingBytes16.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/ArrayWrappingBytes16.java new file mode 100644 index 000000000..ee0b316da --- /dev/null +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/ArrayWrappingBytes16.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.artemis.util.bytes; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * An implementation of {@link Bytes16} backed by a byte array ({@code byte[]}). + */ +class ArrayWrappingBytes16 extends ArrayWrappingBytesValue implements Bytes16 { + + ArrayWrappingBytes16(byte[] bytes) { + this(checkLength(bytes), 0); + } + + ArrayWrappingBytes16(byte[] bytes, int offset) { + super(checkLength(bytes, offset), offset, SIZE); + } + + // Ensures a proper error message. + private static byte[] checkLength(byte[] bytes) { + checkArgument(bytes.length == SIZE, "Expected %s bytes but got %s", SIZE, bytes.length); + return bytes; + } + + // Ensures a proper error message. + private static byte[] checkLength(byte[] bytes, int offset) { + checkArgument(bytes.length - offset >= SIZE, + "Expected at least %s bytes from offset %s but got only %s", SIZE, offset, + bytes.length - offset); + return bytes; + } + + @Override + public Bytes16 copy() { + // Because MutableArrayWrappingBytesValue overrides this, we know we are immutable. We may + // retain more than necessary however. + if (offset == 0 && length == bytes.length) + return this; + + return new ArrayWrappingBytes16(arrayCopy()); + } + + @Override + public MutableBytes16 mutableCopy() { + return new MutableArrayWrappingBytes16(arrayCopy()); + } +} diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/Bytes16.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/Bytes16.java new file mode 100644 index 000000000..d084dcfc5 --- /dev/null +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/Bytes16.java @@ -0,0 +1,187 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.artemis.util.bytes; + +import tech.pegasys.artemis.util.uint.Int256; +import tech.pegasys.artemis.util.uint.UInt256; +import tech.pegasys.artemis.util.uint.UInt256Bytes; + +import java.util.Random; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * A {@link BytesValue} that is guaranteed to contain exactly 16 bytes. + */ +public interface Bytes16 extends BytesValue { + int SIZE = 16; + Bytes16 ZERO = wrap(new byte[16]); + + /** + * Wraps the provided byte array, which must be of length 16, as a {@link Bytes16}. + * + *

Note that value is not copied, only wrapped, and thus any future update to {@code value} + * will be reflected in the returned value. + * + * @param bytes The bytes to wrap. + * @return A {@link Bytes16} wrapping {@code value}. + * @throws IllegalArgumentException if {@code value.length != 16}. + */ + static Bytes16 wrap(byte[] bytes) { + checkArgument(bytes.length == SIZE, "Expected %s bytes but got %s", SIZE, bytes.length); + return wrap(bytes, 0); + } + + /** + * Wraps a slice/sub-part of the provided array as a {@link Bytes16}. + * + *

Note that value is not copied, only wrapped, and thus any future update to {@code value} + * within the wrapped parts will be reflected in the returned value. + * + * @param bytes The bytes to wrap. + * @param offset The index (inclusive) in {@code value} of the first byte exposed by the returned + * value. In other words, you will have {@code wrap(value, i).get(0) == value[i]}. + * @return A {@link Bytes16} that exposes the bytes of {@code value} from {@code offset} + * (inclusive) to {@code offset + 16} (exclusive). + * @throws IndexOutOfBoundsException if {@code offset < 0 || (value.length > 0 && offset >= + * value.length)}. + * @throws IllegalArgumentException if {@code length < 0 || offset + 16 > value.length}. + */ + static Bytes16 wrap(byte[] bytes, int offset) { + return new ArrayWrappingBytes16(bytes, offset); + } + + /** + * Wraps a slice/sub-part of the provided value as a {@link Bytes16}. + * + *

Note that value is not copied, only wrapped, and thus any future update to {@code value} + * within the wrapped parts will be reflected in the returned value. + * + * @param bytes The bytes to wrap. + * @param offset The index (inclusive) in {@code value} of the first byte exposed by the returned + * value. In other words, you will have {@code wrap(value, i).get(0) == value.get(i)}. + * @return A {@link Bytes16} that exposes the bytes of {@code value} from {@code offset} + * (inclusive) to {@code offset + 16} (exclusive). + * @throws IndexOutOfBoundsException if {@code offset < 0 || (value.size() > 0 && offset >= + * value.size())}. + * @throws IllegalArgumentException if {@code length < 0 || offset + 16 > value.size()}. + */ + static Bytes16 wrap(BytesValue bytes, int offset) { + BytesValue slice = bytes.slice(offset, Bytes16.SIZE); + return slice instanceof Bytes16 ? (Bytes16) slice : new WrappingBytes16(slice); + } + + /** + * Left pad a {@link BytesValue} with zero bytes to create a {@link Bytes16} + * + * @param value The bytes value pad. + * @return A {@link Bytes16} that exposes the left-padded bytes of {@code value}. + * @throws IllegalArgumentException if {@code value.size() > 16}. + */ + static Bytes16 leftPad(BytesValue value) { + checkArgument( + value.size() <= SIZE, "Expected at most %s bytes but got only %s", SIZE, value.size()); + + MutableBytes16 bytes = MutableBytes16.create(); + value.copyTo(bytes, SIZE - value.size()); + return bytes; + } + + /** + * Right pad a {@link BytesValue} with zero bytes to create a {@link Bytes16} + * + * @param value The bytes value pad. + * @return A {@link Bytes16} that exposes the right-padded bytes of {@code value}. + * @throws IllegalArgumentException if {@code value.size() > 16}. + */ + static Bytes16 rightPad(BytesValue value) { + checkArgument( + value.size() <= SIZE, "Expected at most %s bytes but got only %s", SIZE, value.size()); + + MutableBytes16 bytes = MutableBytes16.create(); + value.copyTo(bytes, 0); + return bytes; + } + + /** + * Parse an hexadecimal string into a {@link Bytes16}. + * + *

This method is lenient in that {@code str} may of an odd length, in which case it will + * behave exactly as if it had an additional 0 in front. + * + * @param str The hexadecimal string to parse, which may or may not start with "0x". That + * representation may contain less than 16 bytes, in which case the result is left padded with + * zeros (see {@link #fromHexStringStrict} if this is not what you want). + * @return The value corresponding to {@code str}. + * @throws IllegalArgumentException if {@code str} does not correspond to valid hexadecimal + * representation or contains more than 16 bytes. + */ + static Bytes16 fromHexStringLenient(String str) { + return wrap(BytesValues.fromRawHexString(str, SIZE, true)); + } + + /** + * Parse an hexadecimal string into a {@link Bytes16}. + * + *

This method is strict in that {@code str} must of an even length. + * + * @param str The hexadecimal string to parse, which may or may not start with "0x". That + * representation may contain less than 16 bytes, in which case the result is left padded with + * zeros (see {@link #fromHexStringStrict} if this is not what you want). + * @return The value corresponding to {@code str}. + * @throws IllegalArgumentException if {@code str} does not correspond to valid hexadecimal + * representation, is of an odd length, or contains more than 16 bytes. + */ + static Bytes16 fromHexString(String str) { + return wrap(BytesValues.fromRawHexString(str, SIZE, false)); + } + + /** + * Parse an hexadecimal string into a {@link Bytes16}. + * + *

This method is extra strict in that {@code str} must of an even length and the provided + * representation must have exactly 16 bytes. + * + * @param str The hexadecimal string to parse, which may or may not start with "0x". + * @return The value corresponding to {@code str}. + * @throws IllegalArgumentException if {@code str} does not correspond to valid hexadecimal + * representation, is of an odd length or does not contain exactly 16 bytes. + */ + static Bytes16 fromHexStringStrict(String str) { + return wrap(BytesValues.fromRawHexString(str, -1, false)); + } + + /** + * Constructs a randomly generated value. + * + * @param rnd random number generator. + * @return random value. + */ + static Bytes16 random(Random rnd) { + byte[] randomBytes = new byte[SIZE]; + rnd.nextBytes(randomBytes); + return wrap(randomBytes); + } + + @Override + default int size() { + return SIZE; + } + + @Override + Bytes16 copy(); + + @Override + MutableBytes16 mutableCopy(); +} diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/BytesValue.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/BytesValue.java index b9a774322..4c7f0f773 100644 --- a/types/src/main/java/tech/pegasys/artemis/util/bytes/BytesValue.java +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/BytesValue.java @@ -293,10 +293,16 @@ static BytesValue fromHexString(String str, int destinationSize) { */ byte get(int i); + /** @return bit at `bitIndex`, with 0 index and bitIndex(0) of 0x01 == 1 */ default boolean getBit(int bitIndex) { return ((get(bitIndex / 8) >> (bitIndex % 8)) & 1) == 1; } + /** @return bit at `bitIndex`, with 0 index and bitIndex(7) of 0x01 == 1 */ + default boolean getHighBit(int bitIndex) { + return ((get(bitIndex / 8) >> (7 - (bitIndex % 8))) & 1) == 1; + } + /** * Retrieves the 4 bytes starting at the provided index in this value as an integer. * diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableArrayWrappingBytes16.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableArrayWrappingBytes16.java new file mode 100644 index 000000000..3916dbaaf --- /dev/null +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableArrayWrappingBytes16.java @@ -0,0 +1,39 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.artemis.util.bytes; + +/** + * An implementation of {@link MutableBytes16} backed by a byte array ({@code byte[]}). + */ +class MutableArrayWrappingBytes16 extends MutableArrayWrappingBytesValue implements MutableBytes16 { + + MutableArrayWrappingBytes16(byte[] bytes) { + this(bytes, 0); + } + + MutableArrayWrappingBytes16(byte[] bytes, int offset) { + super(bytes, offset, SIZE); + } + + @Override + public Bytes16 copy() { + // We *must* override this method because ArrayWrappingBytes16 assumes that it is the case. + return new ArrayWrappingBytes16(arrayCopy()); + } + + @Override + public MutableBytes16 mutableCopy() { + return new MutableArrayWrappingBytes16(arrayCopy()); + } +} diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableBytes16.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableBytes16.java new file mode 100644 index 000000000..4edb6b540 --- /dev/null +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/MutableBytes16.java @@ -0,0 +1,145 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.artemis.util.bytes; + +import java.security.MessageDigest; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * A mutable {@link Bytes16}, that is a mutable {@link BytesValue} of exactly 16 bytes. + */ +public interface MutableBytes16 extends MutableBytesValue, Bytes16 { + + /** + * Wraps a 16 bytes array as a mutable 16 bytes value. + * + *

+ * This method behave exactly as {@link Bytes16#wrap(byte[])} except that the result is a mutable. + * + * @param value The value to wrap. + * @return A {@link MutableBytes16} wrapping {@code value}. + * @throws IllegalArgumentException if {@code value.length != 16}. + */ + static MutableBytes16 wrap(byte[] value) { + return new MutableArrayWrappingBytes16(value); + } + + /** + * Creates a new mutable 16 bytes value. + * + * @return A newly allocated {@link MutableBytesValue}. + */ + static MutableBytes16 create() { + return new MutableArrayWrappingBytes16(new byte[SIZE]); + } + + /** + * Wraps an existing {@link MutableBytesValue} of size 16 as a mutable 16 bytes value. + * + *

+ * This method does no copy the provided bytes and so any mutation on {@code value} will also be + * reflected in the value returned by this method. If a copy is desirable, this can be simply + * achieved with calling {@link BytesValue#copyTo(MutableBytesValue)} with a newly created + * {@link MutableBytes16} as destination to the copy. + * + * @param value The value to wrap. + * @return A {@link MutableBytes16} wrapping {@code value}. + * @throws IllegalArgumentException if {@code value.size() != 16}. + */ + static MutableBytes16 wrap(MutableBytesValue value) { + checkArgument(value.size() == SIZE, "Expected %s bytes but got %s", SIZE, value.size()); + return new MutableBytes16() { + @Override + public void set(int i, byte b) { + value.set(i, b); + } + + @Override + public MutableBytesValue mutableSlice(int i, int length) { + return value.mutableSlice(i, length); + } + + @Override + public byte get(int i) { + return value.get(i); + } + + @Override + public BytesValue slice(int index) { + return value.slice(index); + } + + @Override + public BytesValue slice(int index, int length) { + return value.slice(index, length); + } + + @Override + public Bytes16 copy() { + return Bytes16.wrap(value.extractArray()); + } + + @Override + public MutableBytes16 mutableCopy() { + return MutableBytes16.wrap(value.extractArray()); + } + + @Override + public void copyTo(MutableBytesValue destination) { + value.copyTo(destination); + } + + @Override + public void copyTo(MutableBytesValue destination, int destinationOffset) { + value.copyTo(destination, destinationOffset); + } + + @Override + public int commonPrefixLength(BytesValue other) { + return value.commonPrefixLength(other); + } + + @Override + public BytesValue commonPrefix(BytesValue other) { + return value.commonPrefix(other); + } + + @Override + public void update(MessageDigest digest) { + value.update(digest); + } + + @Override + public boolean isZero() { + return value.isZero(); + } + + @Override + public boolean equals(Object other) { + return value.equals(other); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return value.toString(); + } + }; + } +} diff --git a/types/src/main/java/tech/pegasys/artemis/util/bytes/WrappingBytes16.java b/types/src/main/java/tech/pegasys/artemis/util/bytes/WrappingBytes16.java new file mode 100644 index 000000000..c547f10a7 --- /dev/null +++ b/types/src/main/java/tech/pegasys/artemis/util/bytes/WrappingBytes16.java @@ -0,0 +1,62 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.artemis.util.bytes; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * A simple class to wrap another {@link BytesValue} of exactly 16 bytes as a {@link Bytes16}. + */ +class WrappingBytes16 extends AbstractBytesValue implements Bytes16 { + + private final BytesValue value; + + WrappingBytes16(BytesValue value) { + checkArgument(value.size() == SIZE, "Expected value to be %s bytes, but is %s bytes", SIZE, + value.size()); + this.value = value; + } + + @Override + public byte get(int i) { + return value.get(i); + } + + @Override + public BytesValue slice(int index, int length) { + return value.slice(index, length); + } + + @Override + public MutableBytes16 mutableCopy() { + MutableBytes16 copy = MutableBytes16.create(); + value.copyTo(copy); + return copy; + } + + @Override + public Bytes16 copy() { + return mutableCopy(); + } + + @Override + public byte[] getArrayUnsafe() { + return value.getArrayUnsafe(); + } + + @Override + public int size() { + return value.size(); + } +} diff --git a/util/src/main/java/org/ethereum/beacon/util/ExpirationScheduler.java b/util/src/main/java/org/ethereum/beacon/util/ExpirationScheduler.java new file mode 100644 index 000000000..4dfcfa178 --- /dev/null +++ b/util/src/main/java/org/ethereum/beacon/util/ExpirationScheduler.java @@ -0,0 +1,52 @@ +package org.ethereum.beacon.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Schedules `runnable` in delay which is set by constructor. When runnable is renewed by putting it + * in map again, old task is cancelled and removed. Task are equalled by the + */ +public class ExpirationScheduler { + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final long delay; + private final TimeUnit timeUnit; + private Map expirationTasks = new ConcurrentHashMap<>(); + + public ExpirationScheduler(long delay, TimeUnit timeUnit) { + this.delay = delay; + this.timeUnit = timeUnit; + } + + /** + * Puts scheduled task and renews (cancelling old) timeout for the task associated with the key + * + * @param key Task key + * @param runnable Task + */ + public void put(Key key, Runnable runnable) { + cancel(key); + ScheduledFuture future = + scheduler.schedule( + () -> { + runnable.run(); + expirationTasks.remove(key); + }, + delay, + timeUnit); + expirationTasks.put(key, future); + } + + /** Cancels task for key and removes it from storage */ + public void cancel(Key key) { + synchronized (this) { + if (expirationTasks.containsKey(key)) { + expirationTasks.remove(key).cancel(true); + } + } + } +} diff --git a/util/src/main/java/org/ethereum/beacon/util/Utils.java b/util/src/main/java/org/ethereum/beacon/util/Utils.java index 668498c23..9f3d4c46b 100644 --- a/util/src/main/java/org/ethereum/beacon/util/Utils.java +++ b/util/src/main/java/org/ethereum/beacon/util/Utils.java @@ -1,5 +1,6 @@ package org.ethereum.beacon.util; +import java.math.BigInteger; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -19,7 +20,8 @@ public static Function> nullableFlatMap(Function func) return n -> n != null ? Stream.of(func.apply(n)) : Stream.empty(); } - public static void futureForward(CompletableFuture result, CompletableFuture forwardToFuture) { + public static void futureForward( + CompletableFuture result, CompletableFuture forwardToFuture) { result.whenComplete( (res, t) -> { if (t != null) { @@ -31,10 +33,37 @@ public static void futureForward(CompletableFuture result, CompletableFut } public static Set newLRUSet(int size) { - return Collections.newSetFromMap(new LinkedHashMap() { - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > size; - } - }); + return Collections.newSetFromMap( + new LinkedHashMap() { + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > size; + } + }); + } + + /** + * @param size required size, in bytes + * @return byte array representation of BigInteger for unsigned numeric + *

{@link BigInteger#toByteArray()} adds a bit for the sign. If you work with unsigned + * numerics it's always a 0. But if an integer uses exactly 8-some bits, sign bit will add an + * extra 0 byte to the result, which could broke some things. This method removes this + * redundant prefix byte when extracting byte array from BigInteger + */ + public static byte[] extractBytesFromUnsignedBigInt(BigInteger bigInteger, int size) { + byte[] bigIntBytes = bigInteger.toByteArray(); + if (bigIntBytes.length == size) { + return bigIntBytes; + } else if (bigIntBytes.length == (size + 1)) { + byte[] res = new byte[size]; + System.arraycopy(bigIntBytes, 1, res, 0, res.length); + return res; + } else if (bigIntBytes.length < size) { + byte[] res = new byte[size]; + System.arraycopy(bigIntBytes, 0, res, size - bigIntBytes.length, bigIntBytes.length); + return res; + } else { + throw new RuntimeException( + String.format("Cannot extract bytes of size %s from BigInteger [%s]", size, bigInteger)); + } } } diff --git a/util/src/test/java/org/ethereum/beacon/util/ExpirationSchedulerTest.java b/util/src/test/java/org/ethereum/beacon/util/ExpirationSchedulerTest.java new file mode 100644 index 000000000..093ff6852 --- /dev/null +++ b/util/src/test/java/org/ethereum/beacon/util/ExpirationSchedulerTest.java @@ -0,0 +1,66 @@ +package org.ethereum.beacon.util; + +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class ExpirationSchedulerTest { + @Test + public void test() throws Exception { + ExpirationScheduler scheduler1 = new ExpirationScheduler(500, TimeUnit.MILLISECONDS); + ExpirationScheduler scheduler2 = new ExpirationScheduler(500, TimeUnit.MILLISECONDS); + CountDownLatch first = new CountDownLatch(1); + CountDownLatch second = new CountDownLatch(1); + CountDownLatch third = new CountDownLatch(1); + CountDownLatch fourth = new CountDownLatch(1); + CountDownLatch fifth = new CountDownLatch(1); + scheduler1.put(5, fifth::countDown); + Thread.sleep(100); + scheduler1.put( + 1, + () -> { + first.countDown(); + assert second.getCount() == 1; + assert third.getCount() == 1; + assert fourth.getCount() == 1; + assert fifth.getCount() == 1; + }); + scheduler2.put( + 2, + () -> { + second.countDown(); + assert first.getCount() == 0; + assert third.getCount() == 1; + assert fourth.getCount() == 1; + assert fifth.getCount() == 1; + }); + scheduler2.put( + 3, + () -> { + third.countDown(); + assert first.getCount() == 0; + assert second.getCount() == 0; + assert fourth.getCount() == 1; + assert fifth.getCount() == 1; + }); + scheduler2.put( + 4, + () -> { + fourth.countDown(); + assert first.getCount() == 0; + assert second.getCount() == 0; + assert third.getCount() == 0; + assert fifth.getCount() == 1; + }); + + Thread.sleep(50); + scheduler1.put(5, fifth::countDown); + + assert first.await(1, TimeUnit.SECONDS); + assert second.await(100, TimeUnit.MILLISECONDS); + assert third.await(100, TimeUnit.MILLISECONDS); + assert fourth.await(100, TimeUnit.MILLISECONDS); + assert fifth.await(100, TimeUnit.MILLISECONDS); + } +} diff --git a/versions.gradle b/versions.gradle index a026a41c1..5f77512a1 100644 --- a/versions.gradle +++ b/versions.gradle @@ -11,6 +11,7 @@ dependencyManagement { dependency "org.apache.logging.log4j:log4j-api:${log4j2Version}" dependency "org.apache.logging.log4j:log4j-core:${log4j2Version}" dependency 'org.ethereum:ethereumj-core:1+' + dependency "org.web3j:core:4.2.0" dependency 'org.bouncycastle:bcprov-jdk15on:1.60' dependency 'org.miracl.milagro.amcl:milagro-crypto-java:0.4.0'