Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platform-sdk/consensus-otter-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ testModuleInfo {
testIntegrationModuleInfo {
requires("com.swirlds.common.test.fixtures")
requires("com.swirlds.logging")
requires("org.hiero.base.crypto")
requires("org.hiero.otter.fixtures")
requires("org.assertj.core")
requires("org.junit.jupiter.params")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: Apache-2.0
package org.hiero.otter.fixtures;

import static org.assertj.core.api.Assertions.assertThat;

import com.hedera.hapi.node.state.roster.Roster;
import com.hedera.pbj.runtime.io.buffer.Bytes;
import com.swirlds.platform.crypto.KeyGeneratingException;
import com.swirlds.platform.crypto.KeysAndCertsGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.security.cert.CertificateEncodingException;
import java.util.List;
import org.hiero.consensus.model.node.KeysAndCerts;
import org.hiero.otter.fixtures.turtle.TurtleTestEnvironment;
import org.junit.jupiter.api.Test;

/**
* Tests for verifying that the network roster correctly incorporates node parameters into the network roster.
*/
public class NetworkRosterTests {
/**
* Tests that when nodes are created with overridden keys and certificates, the network roster correctly reflects
* those certificates.
*/
@Test
void testCertificates()
throws NoSuchAlgorithmException, KeyGeneratingException, NoSuchProviderException,
CertificateEncodingException {
final TurtleTestEnvironment env = new TurtleTestEnvironment();
try {
// Create a network with 2 nodes
final Network network = env.network();
final List<Node> nodes = network.addNodes(2);
final Node node0 = nodes.get(0);
final Node node1 = nodes.get(1);

// Override the keys and certs for each node
final SecureRandom secureRandom = SecureRandom.getInstanceStrong();
final KeysAndCerts kac0 = KeysAndCertsGenerator.generate(node0.selfId(), secureRandom, secureRandom);
final KeysAndCerts kac1 = KeysAndCertsGenerator.generate(node0.selfId(), secureRandom, secureRandom);
node0.keysAndCerts(kac0);
node1.keysAndCerts(kac1);

// Start the network so that the roster is created
network.start();

// Verify that the roster uses the overridden certificates
final Roster roster = network.roster();
assertThat(roster.rosterEntries().size()).isEqualTo(2);
assertThat(roster.rosterEntries().get(0).gossipCaCertificate())
.isEqualTo(Bytes.wrap(kac0.sigCert().getEncoded()));
assertThat(roster.rosterEntries().get(1).gossipCaCertificate())
.isEqualTo(Bytes.wrap(kac1.sigCert().getEncoded()));
} finally {
env.destroy();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package org.hiero.otter.fixtures;

import com.hedera.hapi.node.base.SemanticVersion;
import com.hedera.hapi.node.state.roster.Roster;
import com.swirlds.common.test.fixtures.WeightGenerator;
import com.swirlds.common.test.fixtures.WeightGenerators;
import edu.umd.cs.findbugs.annotations.NonNull;
Expand Down Expand Up @@ -129,6 +130,16 @@ default long totalWeight() {
return nodes().stream().mapToLong(Node::weight).sum();
}

/**
* Gets the roster of the network. This method can only be called after the network has been started, because the
* roster is created during startup.
*
* @return the roster of the network
* @throws IllegalStateException if the network has not been started yet
*/
@NonNull
Roster roster();

/**
* Start the network with the currently configured setup.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import edu.umd.cs.findbugs.annotations.Nullable;
import java.nio.file.Path;
import java.time.Duration;
import org.hiero.consensus.model.node.KeysAndCerts;
import org.hiero.consensus.model.node.NodeId;
import org.hiero.consensus.model.quiescence.QuiescenceCommand;
import org.hiero.consensus.model.status.PlatformStatus;
Expand Down Expand Up @@ -140,6 +141,14 @@ default void startSyntheticBottleneck() {
*/
void weight(long weight);

/**
* Sets the keys and certificates of the node. These signing certificates will become part of the new roster. This
* method can only be called while the node has not been started yet.
*
* @param keysAndCerts the new keys and certificates
*/
void keysAndCerts(@NonNull KeysAndCerts keysAndCerts);

/**
* Returns the status of the platform while the node is running or {@code null} if not.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ protected enum Lifecycle {

protected WeightGenerator weightGenerator = WeightGenerators.REAL_NETWORK_GAUSSIAN;

protected Roster roster;

@Nullable
private PartitionImpl remainingNetworkPartition;

Expand Down Expand Up @@ -203,6 +205,14 @@ public void nodeWeight(final long weight) {
nodes().forEach(n -> n.weight(weight));
}

@Override
public @NotNull Roster roster() {
if (lifecycle == Lifecycle.INIT) {
throw new IllegalStateException("The roster is not available before the network is started.");
}
return roster;
}

/**
* Creates a new node with the given ID and keys and certificates. This is a factory method that subclasses must
* implement to create nodes specific to the environment.
Expand Down Expand Up @@ -292,7 +302,7 @@ private void doStart(@NonNull final Duration timeout) {
throwIfInLifecycle(Lifecycle.RUNNING, "Network is already running.");
log.info("Starting network...");

final Roster roster = createRoster();
roster = createRoster();
preStartHook(roster);

lifecycle = Lifecycle.RUNNING;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public enum LifeCycle {
private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(1);

protected final NodeId selfId;
protected final KeysAndCerts keysAndCerts;
protected KeysAndCerts keysAndCerts;

private Roster roster;
private long weight = UNSET_WEIGHT;
Expand Down Expand Up @@ -185,6 +185,12 @@ public void weight(final long weight) {
this.weight = weight;
}

@Override
public void keysAndCerts(@NonNull final KeysAndCerts keysAndCerts) {
throwIsNotInLifecycle(LifeCycle.INIT, "KeysAndCerts can only be set during initialization");
this.keysAndCerts = requireNonNull(keysAndCerts);
}

/**
* {@inheritDoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@
import static org.hiero.otter.fixtures.assertions.StatusProgressionStep.target;

import com.hedera.hapi.node.base.SemanticVersion;
import com.swirlds.platform.crypto.KeyGeneratingException;
import com.swirlds.platform.crypto.KeysAndCertsGenerator;
import com.swirlds.platform.state.snapshot.SavedStateMetadata;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hiero.base.crypto.internal.DetRandomProvider;
import org.hiero.consensus.model.node.NodeId;
import org.hiero.otter.fixtures.Network;
import org.hiero.otter.fixtures.Node;
import org.hiero.otter.fixtures.OtterSpecs;
import org.hiero.otter.fixtures.OtterTest;
import org.hiero.otter.fixtures.TestEnvironment;
Expand Down Expand Up @@ -88,4 +99,53 @@ void migrationTest(final int numberOfNodes, @NonNull final TestEnvironment env)

assertThat(network.newEventStreamResults()).haveEqualFiles();
}

/**
* Tests that the network can start from a saved state when all node keys and certificates have been changed. This
* simulates a scenario where the nodes have been rekeyed, and ensures that the network can still reach consensus.
*/
@OtterTest
@OtterSpecs(randomNodeIds = false)
void keysChangeTest(@NonNull final TestEnvironment env)
throws NoSuchAlgorithmException, KeyGeneratingException, NoSuchProviderException, IOException {
final Network network = env.network();
network.addNodes(4); // same as saved state
final Path savedStatePath = Path.of("previous-version-state");
network.savedStateDirectory(savedStatePath);

// Determine the round of the saved state
final long savedStateRound;
try (final Stream<Path> stream = Files.walk(OtterSavedStateUtils.findSaveState(savedStatePath))) {
final Path metadataFile = stream.filter(
p -> p.getFileName().toString().equals(SavedStateMetadata.FILE_NAME))
.findAny()
.orElseThrow();
savedStateRound = SavedStateMetadata.parse(metadataFile).round();
}

// Override the keys and certificates for all nodes
// Otter will automatically update the roster history with the new certs
final SecureRandom secureRandom = DetRandomProvider.getDetRandom();
secureRandom.setSeed(new byte[] {1, 2, 3});
for (final Node node : network.nodes()) {
node.keysAndCerts(KeysAndCertsGenerator.generate(node.selfId(), secureRandom, secureRandom));
}

// Setup continuous assertions
assertContinuouslyThat(network.newLogResults()).haveNoErrorLevelMessages();
assertContinuouslyThat(network.newConsensusResults())
.haveEqualCommonRounds()
.haveConsistentRounds();
assertContinuouslyThat(network.newReconnectResults()).doNotAttemptToReconnect();
assertContinuouslyThat(network.newMarkerFileResults()).haveNoMarkerFiles();

// Start the network
network.start();

// Wait for the nodes to advance 20 rounds, indicating that the network is working correctly with the new keys
env.timeManager()
.waitForCondition(
() -> network.newConsensusResults().allNodesAdvancedToRound(savedStateRound + 20),
Duration.ofSeconds(120L));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,28 +131,51 @@ public static KeysAndCerts generate(
final byte[] memberId,
final PublicStores publicStores)
throws NoSuchAlgorithmException, NoSuchProviderException, KeyStoreException, KeyGeneratingException {
final KeyPairGenerator sigKeyGen;
final KeyPairGenerator agrKeyGen;

final SecureRandom sigDetRandom; // deterministic CSPRNG, used briefly then discarded
final SecureRandom agrDetRandom; // deterministic CSPRNG, used briefly then discarded

sigKeyGen = KeyPairGenerator.getInstance(CryptoConstants.SIG_TYPE1, CryptoConstants.SIG_PROVIDER);
agrKeyGen = KeyPairGenerator.getInstance(CryptoConstants.AGR_TYPE, CryptoConstants.AGR_PROVIDER);

sigDetRandom = DetRandomProvider.getDetRandom(); // deterministic, not shared
agrDetRandom = DetRandomProvider.getDetRandom(); // deterministic, not shared

// deterministic CSPRNG, used briefly then discarded
final SecureRandom sigDetRandom = DetRandomProvider.getDetRandom();
sigDetRandom.setSeed(masterKey);
sigDetRandom.setSeed(swirldId);
sigDetRandom.setSeed(memberId);
sigDetRandom.setSeed(SIG_SEED);
sigKeyGen.initialize(CryptoConstants.SIG_KEY_SIZE_BITS, sigDetRandom);

// deterministic CSPRNG, used briefly then discarded
final SecureRandom agrDetRandom = DetRandomProvider.getDetRandom();
agrDetRandom.setSeed(masterKey);
agrDetRandom.setSeed(swirldId);
agrDetRandom.setSeed(memberId);
agrDetRandom.setSeed(AGR_SEED);

final KeysAndCerts keysAndCerts = generate(nodeId, sigDetRandom, agrDetRandom);

// add to the trust store (which have references stored here and in the caller)
publicStores.setCertificate(KeyCertPurpose.SIGNING, keysAndCerts.sigCert(), nodeId);
publicStores.setCertificate(KeyCertPurpose.AGREEMENT, keysAndCerts.agrCert(), nodeId);

return keysAndCerts;
}

/**
* Generated keys using the supplied randomness and creates certificates with those keys. The signing key pair is
* used to sign both certs.
*
* @param nodeId the node ID used for the certificate distinguished names
* @param sigDetRandom the source of randomness for generating the signing key pair
* @param agrDetRandom the source of randomness for generating the agreement key pair
* @return the generated keys and certs
*/
@NonNull
public static KeysAndCerts generate(
@NonNull final NodeId nodeId,
@NonNull final SecureRandom sigDetRandom,
@NonNull final SecureRandom agrDetRandom)
throws NoSuchAlgorithmException, NoSuchProviderException, KeyGeneratingException {
final KeyPairGenerator sigKeyGen =
KeyPairGenerator.getInstance(CryptoConstants.SIG_TYPE1, CryptoConstants.SIG_PROVIDER);
final KeyPairGenerator agrKeyGen =
KeyPairGenerator.getInstance(CryptoConstants.AGR_TYPE, CryptoConstants.AGR_PROVIDER);

sigKeyGen.initialize(CryptoConstants.SIG_KEY_SIZE_BITS, sigDetRandom);
agrKeyGen.initialize(CryptoConstants.AGR_KEY_SIZE_BITS, agrDetRandom);

final KeyPair sigKeyPair = sigKeyGen.generateKeyPair();
Expand All @@ -168,11 +191,6 @@ public static KeysAndCerts generate(
CryptoStatic.generateCertificate(dnS, sigKeyPair, dnS, sigKeyPair, sigDetRandom);
final X509Certificate agrCert =
CryptoStatic.generateCertificate(dnA, agrKeyPair, dnS, sigKeyPair, agrDetRandom);

// add to the 3 trust stores (which have references stored here and in the caller)
publicStores.setCertificate(KeyCertPurpose.SIGNING, sigCert, nodeId);
publicStores.setCertificate(KeyCertPurpose.AGREEMENT, agrCert, nodeId);

return new KeysAndCerts(sigKeyPair, agrKeyPair, sigCert, agrCert);
}

Expand Down
Loading