mirror of
https://github.com/corda/corda.git
synced 2024-12-18 20:47:57 +00:00
CORDA 2131 - Extend Network Bootstrapper to enable registration of Java Package Namespaces. (#4116)
* Package Ownership Network Parameters: add register / unregister CLI options to network bootstrapper. * Fix 2 failing unit tests. * Fix failing unit tests. * Added changelog, documentation and cosmetic changes. * Fixed exception message. * Address PR review feedback. * Fix typo. * Resolve conflicts. * Rebase, resolve conflicts and remove PackageOwner class. * Address latest PR review feedback. * Fix incorrect imports. * Fix broken JUnit * Add support for key store passwords including delimiter characters. * Updated and improved documentation. * Minor doc update. * Documentation changes following PR review feedback * Replace Bank Of Corda with Example CorDapp. Remove references to locally built network bootstrapper.
This commit is contained in:
parent
e52f4bc2a7
commit
015a36dad6
@ -2,6 +2,7 @@ package net.corda.core.node
|
||||
|
||||
import net.corda.core.CordaRuntimeException
|
||||
import net.corda.core.KeepForDJVM
|
||||
import net.corda.core.crypto.toStringShort
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.serialization.CordaSerializable
|
||||
@ -140,7 +141,7 @@ data class NetworkParameters(
|
||||
modifiedTime=$modifiedTime
|
||||
epoch=$epoch,
|
||||
packageOwnership= {
|
||||
${packageOwnership.keys.joinToString()}}
|
||||
${packageOwnership.entries.joinToString("\n ") { "$it.key -> ${it.value.toStringShort()}" }}
|
||||
}
|
||||
}"""
|
||||
}
|
||||
@ -172,7 +173,7 @@ class ZoneVersionTooLowException(message: String) : CordaRuntimeException(messag
|
||||
@CordaSerializable
|
||||
data class JavaPackageName(val name: String) {
|
||||
init {
|
||||
require(isPackageValid(name)) { "Attempting to whitelist illegal java package: $name" }
|
||||
require(isPackageValid(name)) { "Invalid Java package name: $name" }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -182,7 +183,9 @@ data class JavaPackageName(val name: String) {
|
||||
* Note: The ownership check is ignoring case to prevent people from just releasing a jar with: "com.megaCorp.megatoken" and pretend they are MegaCorp.
|
||||
* By making the check case insensitive, the node will require that the jar is signed by MegaCorp, so the attack fails.
|
||||
*/
|
||||
fun owns(fullClassName: String) = fullClassName.startsWith("${name}.", ignoreCase = true)
|
||||
fun owns(fullClassName: String) = fullClassName.startsWith("$name.", ignoreCase = true)
|
||||
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
// Check if a string is a legal Java package name.
|
||||
|
@ -28,6 +28,10 @@ infix fun Int.exactAdd(b: Int): Int = Math.addExact(this, b)
|
||||
/** Like the + operator but throws [ArithmeticException] in case of integer overflow. */
|
||||
infix fun Long.exactAdd(b: Long): Long = Math.addExact(this, b)
|
||||
|
||||
/** There is no special case function for filtering null values out of a map in the stdlib */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <K, V> Map<K, V?>.filterNotNullValues() = filterValues { it != null } as Map<K, V>
|
||||
|
||||
/**
|
||||
* Usually you won't need this method:
|
||||
* * If you're in a companion object, use [contextLogger]
|
||||
|
@ -7,6 +7,9 @@ release, see :doc:`upgrade-notes`.
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
* Introduced new optional network bootstrapper command line options (--register-package-owner, --unregister-package-owner)
|
||||
to register/unregister a java package namespace with an associated owner in the network parameter packageOwnership whitelist.
|
||||
|
||||
* New "validate-configuration" sub-command to `corda.jar`, allowing to validate the actual node configuration without starting the node.
|
||||
|
||||
* Introduced new optional network bootstrapper command line option (--minimum-platform-version) to set as a network parameter
|
||||
|
@ -247,6 +247,54 @@ To give the following:
|
||||
|
||||
.. note:: The whitelist can only ever be appended to. Once added a contract implementation can never be removed.
|
||||
|
||||
Package namespace ownership
|
||||
----------------------------
|
||||
|
||||
Package namespace ownership is a Corda security feature that allows a compatibility zone to give ownership of parts of the Java package namespace to registered users (e.g. CorDapp development organisations).
|
||||
The exact mechanism used to claim a namespace is up to the zone operator. A typical approach would be to accept an SSL
|
||||
certificate with the domain in it as proof of domain ownership, or to accept an email from that domain.
|
||||
|
||||
.. note:: Read more about *Package ownership* :doc:`here<design/data-model-upgrades/package-namespace-ownership>`.
|
||||
|
||||
A Java package namespace is case insensitive and cannot be a sub-package of an existing registered namespace.
|
||||
See `Naming a Package <https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html>`_ and `Naming Conventions <https://www.oracle.com/technetwork/java/javase/documentation/codeconventions-135099.html#28840 for guidelines and conventions>`_ for guidelines on naming conventions.
|
||||
|
||||
Registration of a java package namespace requires creation of a signed certificate as generated by the
|
||||
`Java keytool <https://docs.oracle.com/javase/8/docs/technotes/tools/windows/keytool.html>`_.
|
||||
|
||||
The following four items are passed as a semi-colon separated string to the ``--register-package-owner`` command:
|
||||
|
||||
1. Java package name (e.g `com.my_company` ).
|
||||
2. Keystore file refers to the full path of the file containing the signed certificate.
|
||||
3. Password refers to the key store password (not to be confused with the key password).
|
||||
4. Alias refers to the name associated with a certificate containing the public key to be associated with the package namespace.
|
||||
|
||||
Let's use the `Example CorDapp <https://github.com/corda/cordapp-example>`_ to initialise a simple network, and then register and unregister a package namespace.
|
||||
Checkout the Example CorDapp and follow the instructions to build it `here <https://docs.corda.net/tutorial-cordapp.html#building-the-example-cordapp>`_.
|
||||
|
||||
.. note:: You can point to any existing bootstrapped corda network (this will have the effect of updating the associated network parameters file).
|
||||
|
||||
1. Create a new public key to use for signing the java package namespace we wish to register:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$JAVA_HOME/bin/keytool -genkeypair -keystore _teststore -storepass MyStorePassword -keyalg RSA -alias MyKeyAlias -keypass MyKeyPassword -dname "O=Alice Corp, L=Madrid, C=ES"
|
||||
|
||||
This will generate a key store file called ``_teststore`` in the current directory.
|
||||
|
||||
2. Register the package namespace to be claimed by the public key generated above:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Register the java package namespace using the bootstrapper tool
|
||||
java -jar network-bootstrapper.jar --dir build/nodes --register-package-owner com.example;./_teststore;MyStorePassword;MyKeyAlias
|
||||
|
||||
3. Unregister the package namespace:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Unregister the java package namespace using the bootstrapper tool
|
||||
java -jar network-bootstrapper.jar --dir build/nodes --unregister-package-owner com.example
|
||||
|
||||
Command-line options
|
||||
--------------------
|
||||
@ -256,7 +304,10 @@ The network bootstrapper can be started with the following command-line options:
|
||||
.. code-block:: shell
|
||||
|
||||
bootstrapper [-hvV] [--no-copy] [--dir=<dir>] [--logging-level=<loggingLevel>]
|
||||
[--minimum-platform-version=<minimumPlatformVersion>] [COMMAND]
|
||||
[--minimum-platform-version=<minimumPlatformVersion>]
|
||||
[--register-package-owner java-package-namespace=keystore-file:password:alias]
|
||||
[--unregister-package-owner java-package-namespace]
|
||||
[COMMAND]
|
||||
|
||||
* ``--dir=<dir>``: Root directory containing the node configuration files and CorDapp JARs that will form the test network.
|
||||
It may also contain existing node directories. Defaults to the current directory.
|
||||
@ -266,8 +317,11 @@ The network bootstrapper can be started with the following command-line options:
|
||||
* ``--help``, ``-h``: Show this help message and exit.
|
||||
* ``--version``, ``-V``: Print version information and exit.
|
||||
* ``--minimum-platform-version``: The minimum platform version to use in the generated network-parameters.
|
||||
* ``--register-package-owner``: Register a java package namespace with its owners public key.
|
||||
* ``--unregister-package-owner``: Unregister a java package namespace.
|
||||
|
||||
Sub-commands
|
||||
^^^^^^^^^^^^
|
||||
|
||||
``install-shell-extensions``: Install ``bootstrapper`` alias and auto completion for bash and zsh. See :doc:`cli-application-shell-extensions` for more info.
|
||||
``install-shell-extensions``: Install ``bootstrapper`` alias and auto completion for bash and zsh. See :doc:`cli-application-shell-extensions` for more info.
|
||||
|
||||
|
@ -2,11 +2,13 @@ package net.corda.nodeapi.internal.network
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import net.corda.core.crypto.toStringShort
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.core.internal.concurrent.fork
|
||||
import net.corda.core.internal.concurrent.transpose
|
||||
import net.corda.core.node.JavaPackageName
|
||||
import net.corda.core.node.NetworkParameters
|
||||
import net.corda.core.node.NodeInfo
|
||||
import net.corda.core.node.NotaryInfo
|
||||
@ -17,6 +19,7 @@ import net.corda.core.serialization.deserialize
|
||||
import net.corda.core.serialization.internal.SerializationEnvironment
|
||||
import net.corda.core.serialization.internal._contextSerializationEnv
|
||||
import net.corda.core.utilities.days
|
||||
import net.corda.core.utilities.filterNotNullValues
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.core.utilities.seconds
|
||||
import net.corda.nodeapi.internal.*
|
||||
@ -30,6 +33,7 @@ import net.corda.serialization.internal.amqp.amqpMagic
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
|
||||
import java.security.PublicKey
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
@ -168,14 +172,14 @@ internal constructor(private val initSerEnv: Boolean,
|
||||
}
|
||||
|
||||
/** Entry point for the tool */
|
||||
fun bootstrap(directory: Path, copyCordapps: Boolean, minimumPlatformVersion: Int) {
|
||||
fun bootstrap(directory: Path, copyCordapps: Boolean, minimumPlatformVersion: Int, packageOwnership : Map<JavaPackageName, PublicKey?> = emptyMap()) {
|
||||
require(minimumPlatformVersion <= PLATFORM_VERSION) { "Minimum platform version cannot be greater than $PLATFORM_VERSION" }
|
||||
// Don't accidently include the bootstrapper jar as a CorDapp!
|
||||
val bootstrapperJar = javaClass.location.toPath()
|
||||
val cordappJars = directory.list { paths ->
|
||||
paths.filter { it.toString().endsWith(".jar") && !it.isSameAs(bootstrapperJar) && it.fileName.toString() != "corda.jar" }.toList()
|
||||
}
|
||||
bootstrap(directory, cordappJars, copyCordapps, fromCordform = false, minimumPlatformVersion = minimumPlatformVersion)
|
||||
bootstrap(directory, cordappJars, copyCordapps, fromCordform = false, minimumPlatformVersion = minimumPlatformVersion, packageOwnership = packageOwnership)
|
||||
}
|
||||
|
||||
private fun bootstrap(
|
||||
@ -183,7 +187,8 @@ internal constructor(private val initSerEnv: Boolean,
|
||||
cordappJars: List<Path>,
|
||||
copyCordapps: Boolean,
|
||||
fromCordform: Boolean,
|
||||
minimumPlatformVersion: Int = PLATFORM_VERSION
|
||||
minimumPlatformVersion: Int = PLATFORM_VERSION,
|
||||
packageOwnership : Map<JavaPackageName, PublicKey?> = emptyMap()
|
||||
) {
|
||||
directory.createDirectories()
|
||||
println("Bootstrapping local test network in $directory")
|
||||
@ -223,7 +228,7 @@ internal constructor(private val initSerEnv: Boolean,
|
||||
val notaryInfos = gatherNotaryInfos(nodeInfoFiles, configs)
|
||||
println("Generating contract implementations whitelist")
|
||||
val newWhitelist = generateWhitelist(existingNetParams, readExcludeWhitelist(directory), cordappJars.filter { !isSigned(it) }.map(contractsJarConverter))
|
||||
val newNetParams = installNetworkParameters(notaryInfos, newWhitelist, existingNetParams, nodeDirs, minimumPlatformVersion)
|
||||
val newNetParams = installNetworkParameters(notaryInfos, newWhitelist, existingNetParams, nodeDirs, minimumPlatformVersion, packageOwnership)
|
||||
if (newNetParams != existingNetParams) {
|
||||
println("${if (existingNetParams == null) "New" else "Updated"} $newNetParams")
|
||||
} else {
|
||||
@ -355,17 +360,31 @@ internal constructor(private val initSerEnv: Boolean,
|
||||
whitelist: Map<String, List<AttachmentId>>,
|
||||
existingNetParams: NetworkParameters?,
|
||||
nodeDirs: List<Path>,
|
||||
minimumPlatformVersion: Int
|
||||
minimumPlatformVersion: Int,
|
||||
packageOwnership : Map<JavaPackageName, PublicKey?>
|
||||
): NetworkParameters {
|
||||
// TODO Add config for minimumPlatformVersion, maxMessageSize and maxTransactionSize
|
||||
// TODO Add config for maxMessageSize and maxTransactionSize
|
||||
val netParams = if (existingNetParams != null) {
|
||||
if (existingNetParams.whitelistedContractImplementations == whitelist && existingNetParams.notaries == notaryInfos) {
|
||||
if (existingNetParams.whitelistedContractImplementations == whitelist && existingNetParams.notaries == notaryInfos &&
|
||||
existingNetParams.packageOwnership.entries.containsAll(packageOwnership.entries)) {
|
||||
existingNetParams
|
||||
} else {
|
||||
var updatePackageOwnership = mutableMapOf(*existingNetParams.packageOwnership.map { Pair(it.key,it.value) }.toTypedArray())
|
||||
packageOwnership.forEach { key, value ->
|
||||
if (value == null) {
|
||||
if (updatePackageOwnership.remove(key) != null)
|
||||
println("Unregistering package $key")
|
||||
}
|
||||
else {
|
||||
if (updatePackageOwnership.put(key, value) == null)
|
||||
println("Registering package $key for owner ${value.toStringShort()}")
|
||||
}
|
||||
}
|
||||
existingNetParams.copy(
|
||||
notaries = notaryInfos,
|
||||
modifiedTime = Instant.now(),
|
||||
whitelistedContractImplementations = whitelist,
|
||||
packageOwnership = updatePackageOwnership,
|
||||
epoch = existingNetParams.epoch + 1
|
||||
)
|
||||
}
|
||||
@ -377,6 +396,7 @@ internal constructor(private val initSerEnv: Boolean,
|
||||
maxMessageSize = 10485760,
|
||||
maxTransactionSize = 10485760,
|
||||
whitelistedContractImplementations = whitelist,
|
||||
packageOwnership = packageOwnership.filterNotNullValues(),
|
||||
epoch = 1,
|
||||
eventHorizon = 30.days
|
||||
)
|
||||
|
@ -5,29 +5,28 @@ import net.corda.core.crypto.secureRandomBytes
|
||||
import net.corda.core.crypto.sha256
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.core.node.JavaPackageName
|
||||
import net.corda.core.node.NetworkParameters
|
||||
import net.corda.core.node.NodeInfo
|
||||
import net.corda.core.serialization.serialize
|
||||
import net.corda.node.services.config.NotaryConfig
|
||||
import net.corda.nodeapi.internal.DEV_ROOT_CA
|
||||
import net.corda.nodeapi.internal.NODE_INFO_DIRECTORY
|
||||
import net.corda.core.internal.PLATFORM_VERSION
|
||||
import net.corda.nodeapi.internal.SignedNodeInfo
|
||||
import net.corda.nodeapi.internal.config.parseAs
|
||||
import net.corda.nodeapi.internal.config.toConfig
|
||||
import net.corda.nodeapi.internal.network.NodeInfoFilesCopier.Companion.NODE_INFO_FILE_NAME_PREFIX
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.BOB_NAME
|
||||
import net.corda.testing.core.DUMMY_NOTARY_NAME
|
||||
import net.corda.testing.core.SerializationEnvironmentRule
|
||||
import net.corda.testing.core.*
|
||||
import net.corda.testing.internal.createNodeInfoAndSigned
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.nio.file.Path
|
||||
import java.security.PublicKey
|
||||
import kotlin.streams.toList
|
||||
|
||||
class NetworkBootstrapperTest {
|
||||
@ -35,6 +34,10 @@ class NetworkBootstrapperTest {
|
||||
@JvmField
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val expectedEx: ExpectedException = ExpectedException.none()
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val testSerialization = SerializationEnvironmentRule()
|
||||
@ -208,6 +211,80 @@ class NetworkBootstrapperTest {
|
||||
assertThat(networkParameters.epoch).isEqualTo(2)
|
||||
}
|
||||
|
||||
private val ALICE = TestIdentity(ALICE_NAME, 70)
|
||||
private val BOB = TestIdentity(BOB_NAME, 80)
|
||||
|
||||
private val alicePackageName = JavaPackageName("com.example.alice")
|
||||
private val bobPackageName = JavaPackageName("com.example.bob")
|
||||
|
||||
@Test
|
||||
fun `register new package namespace in existing network`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register additional package namespace in existing network`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
// register additional package name
|
||||
createNodeConfFile("bob", bobConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(bobPackageName, BOB.publicKey)))
|
||||
assertContainsPackageOwner("bob", mapOf(Pair(alicePackageName, ALICE.publicKey), Pair(bobPackageName, BOB.publicKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attempt to register overlapping namespaces in existing network`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
val greedyNamespace = JavaPackageName("com.example")
|
||||
bootstrap(packageOwnership = mapOf(Pair(greedyNamespace, ALICE.publicKey)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(greedyNamespace, ALICE.publicKey)))
|
||||
// register overlapping package name
|
||||
createNodeConfFile("bob", bobConfig)
|
||||
expectedEx.expect(IllegalArgumentException::class.java)
|
||||
expectedEx.expectMessage("multiple packages added to the packageOwnership overlap.")
|
||||
bootstrap(packageOwnership = mapOf(Pair(bobPackageName, BOB.publicKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unregister single package namespace in network of one`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
// unregister package name
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, null)))
|
||||
assertContainsPackageOwner("alice", emptyMap())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unregister single package namespace in network of many`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey), Pair(bobPackageName, BOB.publicKey)))
|
||||
// unregister package name
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, null)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(bobPackageName, BOB.publicKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unregister all package namespaces in existing network`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey), Pair(bobPackageName, BOB.publicKey)))
|
||||
// unregister all package names
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, null), Pair(bobPackageName, null)))
|
||||
assertContainsPackageOwner("alice", emptyMap())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register and unregister sample package namespace in network`() {
|
||||
createNodeConfFile("alice", aliceConfig)
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, ALICE.publicKey), Pair(alicePackageName, null)))
|
||||
assertContainsPackageOwner("alice", emptyMap())
|
||||
bootstrap(packageOwnership = mapOf(Pair(alicePackageName, null), Pair(alicePackageName, ALICE.publicKey)))
|
||||
assertContainsPackageOwner("alice", mapOf(Pair(alicePackageName, ALICE.publicKey)))
|
||||
}
|
||||
|
||||
private val rootDir get() = tempFolder.root.toPath()
|
||||
|
||||
private fun fakeFileBytes(writeToFile: Path? = null): ByteArray {
|
||||
@ -216,9 +293,9 @@ class NetworkBootstrapperTest {
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun bootstrap(copyCordapps: Boolean = true) {
|
||||
private fun bootstrap(copyCordapps: Boolean = true, packageOwnership : Map<JavaPackageName, PublicKey?> = emptyMap()) {
|
||||
providedCordaJar = (rootDir / "corda.jar").let { if (it.exists()) it.readAll() else null }
|
||||
bootstrapper.bootstrap(rootDir, copyCordapps, PLATFORM_VERSION)
|
||||
bootstrapper.bootstrap(rootDir, copyCordapps, PLATFORM_VERSION, packageOwnership)
|
||||
}
|
||||
|
||||
private fun createNodeConfFile(nodeDirName: String, config: FakeNodeConfig) {
|
||||
@ -286,5 +363,10 @@ class NetworkBootstrapperTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertContainsPackageOwner(nodeDirName: String, packageOwners: Map<JavaPackageName, PublicKey>) {
|
||||
val networkParams = (rootDir / nodeDirName).networkParameters
|
||||
assertThat(networkParams.packageOwnership).isEqualTo(packageOwners)
|
||||
}
|
||||
|
||||
data class FakeNodeConfig(val myLegalName: CordaX500Name, val notary: NotaryConfig? = null)
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ class NetworkParametersTest {
|
||||
JavaPackageName("com.!example.stuff") to key2
|
||||
)
|
||||
)
|
||||
}.withMessageContaining("Attempting to whitelist illegal java package")
|
||||
}.withMessageContaining("Invalid Java package name")
|
||||
|
||||
assertThatExceptionOfType(IllegalArgumentException::class.java).isThrownBy {
|
||||
NetworkParameters(1,
|
||||
|
@ -6,9 +6,6 @@ import com.google.common.jimfs.Configuration
|
||||
import com.google.common.jimfs.Jimfs
|
||||
import com.nhaarman.mockito_kotlin.doReturn
|
||||
import com.nhaarman.mockito_kotlin.whenever
|
||||
import net.corda.core.JarSignatureTestUtils.createJar
|
||||
import net.corda.core.JarSignatureTestUtils.generateKey
|
||||
import net.corda.core.JarSignatureTestUtils.signJar
|
||||
import net.corda.core.contracts.ContractAttachment
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.crypto.sha256
|
||||
@ -21,14 +18,17 @@ import net.corda.core.node.services.vault.Builder
|
||||
import net.corda.core.node.services.vault.Sort
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
||||
import net.corda.testing.internal.TestingNamedCacheFactory
|
||||
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseConfig
|
||||
import net.corda.testing.common.internal.testNetworkParameters
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.JarSignatureTestUtils.createJar
|
||||
import net.corda.testing.core.JarSignatureTestUtils.generateKey
|
||||
import net.corda.testing.core.JarSignatureTestUtils.signJar
|
||||
import net.corda.testing.internal.LogHelper
|
||||
import net.corda.testing.internal.rigorousMock
|
||||
import net.corda.testing.internal.TestingNamedCacheFactory
|
||||
import net.corda.testing.internal.configureDatabase
|
||||
import net.corda.testing.internal.rigorousMock
|
||||
import net.corda.testing.node.MockServices.Companion.makeTestDataSourceProperties
|
||||
import net.corda.testing.node.internal.InternalMockNetwork
|
||||
import net.corda.testing.node.internal.startFlow
|
||||
|
@ -1,4 +1,4 @@
|
||||
package net.corda.core
|
||||
package net.corda.testing.core
|
||||
|
||||
import net.corda.core.internal.JarSignatureCollector
|
||||
import net.corda.core.internal.div
|
||||
@ -25,8 +25,8 @@ object JarSignatureTestUtils {
|
||||
.waitFor())
|
||||
}
|
||||
|
||||
fun Path.generateKey(alias: String, password: String, name: String, keyalg: String = "RSA") =
|
||||
executeProcess("keytool", "-genkey", "-keystore", "_teststore", "-storepass", "storepass", "-keyalg", keyalg, "-alias", alias, "-keypass", password, "-dname", name)
|
||||
fun Path.generateKey(alias: String, storePassword: String, name: String, keyalg: String = "RSA", keyPassword: String = storePassword, storeName: String = "_teststore") =
|
||||
executeProcess("keytool", "-genkeypair", "-keystore" ,storeName, "-storepass", storePassword, "-keyalg", keyalg, "-alias", alias, "-keypass", keyPassword, "-dname", name)
|
||||
|
||||
fun Path.createJar(fileName: String, vararg contents: String) =
|
||||
executeProcess(*(arrayOf("jar", "cvf", fileName) + contents))
|
||||
@ -34,9 +34,9 @@ object JarSignatureTestUtils {
|
||||
fun Path.updateJar(fileName: String, vararg contents: String) =
|
||||
executeProcess(*(arrayOf("jar", "uvf", fileName) + contents))
|
||||
|
||||
fun Path.signJar(fileName: String, alias: String, password: String): PublicKey {
|
||||
executeProcess("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", password, fileName, alias)
|
||||
val ks = loadKeyStore(this.resolve("_teststore"), "storepass")
|
||||
fun Path.signJar(fileName: String, alias: String, storePassword: String, keyPassword: String = storePassword): PublicKey {
|
||||
executeProcess("jarsigner", "-keystore", "_teststore", "-storepass", storePassword, "-keypass", keyPassword, fileName, alias)
|
||||
val ks = loadKeyStore(this.resolve("_teststore"), storePassword)
|
||||
return ks.getCertificate(alias).publicKey
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
package net.corda.core.internal
|
||||
package net.corda.testing.core
|
||||
|
||||
import net.corda.core.JarSignatureTestUtils.createJar
|
||||
import net.corda.core.JarSignatureTestUtils.generateKey
|
||||
import net.corda.core.JarSignatureTestUtils.getJarSigners
|
||||
import net.corda.core.JarSignatureTestUtils.signJar
|
||||
import net.corda.core.JarSignatureTestUtils.updateJar
|
||||
import net.corda.testing.core.JarSignatureTestUtils.createJar
|
||||
import net.corda.testing.core.JarSignatureTestUtils.generateKey
|
||||
import net.corda.testing.core.JarSignatureTestUtils.getJarSigners
|
||||
import net.corda.testing.core.JarSignatureTestUtils.signJar
|
||||
import net.corda.testing.core.JarSignatureTestUtils.updateJar
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.BOB_NAME
|
||||
import net.corda.testing.core.CHARLIE_NAME
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
@ -34,8 +34,8 @@ class JarSignatureCollectorTest {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun beforeClass() {
|
||||
dir.generateKey(ALICE, ALICE_PASS, ALICE_NAME.toString())
|
||||
dir.generateKey(BOB, BOB_PASS, BOB_NAME.toString())
|
||||
dir.generateKey(ALICE, "storepass", ALICE_NAME.toString(), keyPassword = ALICE_PASS)
|
||||
dir.generateKey(BOB, "storepass", BOB_NAME.toString(), keyPassword = BOB_PASS)
|
||||
|
||||
(dir / "_signable1").writeLines(listOf("signable1"))
|
||||
(dir / "_signable2").writeLines(listOf("signable2"))
|
||||
@ -134,12 +134,12 @@ class JarSignatureCollectorTest {
|
||||
// and our JarSignatureCollector
|
||||
@Test
|
||||
fun `one signer with EC algorithm`() {
|
||||
dir.generateKey(CHARLIE, CHARLIE_PASS, CHARLIE_NAME.toString(), "EC")
|
||||
dir.generateKey(CHARLIE, "storepass", CHARLIE_NAME.toString(), "EC", CHARLIE_PASS)
|
||||
dir.createJar(FILENAME, "_signable1", "_signable2")
|
||||
val key = dir.signJar(FILENAME, CHARLIE, CHARLIE_PASS)
|
||||
val key = dir.signJar(FILENAME, CHARLIE, "storepass", CHARLIE_PASS)
|
||||
assertEquals(listOf(key), dir.getJarSigners(FILENAME)) // We only used CHARLIE's distinguished name, so the keys will be different.
|
||||
}
|
||||
|
||||
private fun signAsAlice() = dir.signJar(FILENAME, ALICE, ALICE_PASS)
|
||||
private fun signAsBob() = dir.signJar(FILENAME, BOB, BOB_PASS)
|
||||
private fun signAsAlice() = dir.signJar(FILENAME, ALICE, "storepass", ALICE_PASS)
|
||||
private fun signAsBob() = dir.signJar(FILENAME, BOB, "storepass", BOB_PASS)
|
||||
}
|
@ -3,10 +3,16 @@ package net.corda.bootstrapper
|
||||
import net.corda.cliutils.CordaCliWrapper
|
||||
import net.corda.cliutils.start
|
||||
import net.corda.core.internal.PLATFORM_VERSION
|
||||
import net.corda.core.node.JavaPackageName
|
||||
import net.corda.nodeapi.internal.crypto.loadKeyStore
|
||||
import net.corda.nodeapi.internal.network.NetworkBootstrapper
|
||||
import picocli.CommandLine
|
||||
import picocli.CommandLine.Option
|
||||
import java.io.IOException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.security.KeyStoreException
|
||||
import java.security.PublicKey
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
NetworkBootstrapperRunner().start(args)
|
||||
@ -20,16 +26,91 @@ class NetworkBootstrapperRunner : CordaCliWrapper("bootstrapper", "Bootstrap a l
|
||||
"It may also contain existing node directories."
|
||||
]
|
||||
)
|
||||
private var dir: Path = Paths.get(".")
|
||||
var dir: Path = Paths.get(".")
|
||||
|
||||
@Option(names = ["--no-copy"], description = ["""Don't copy the CorDapp JARs into the nodes' "cordapps" directories."""])
|
||||
private var noCopy: Boolean = false
|
||||
var noCopy: Boolean = false
|
||||
|
||||
@Option(names = ["--minimum-platform-version"], description = ["The minimumPlatformVersion to use in the network-parameters."])
|
||||
private var minimumPlatformVersion = PLATFORM_VERSION
|
||||
var minimumPlatformVersion = PLATFORM_VERSION
|
||||
|
||||
@Option(names = ["--register-package-owner"],
|
||||
converter = [PackageOwnerConverter::class],
|
||||
description = [
|
||||
"Register owner of Java package namespace in the network-parameters.",
|
||||
"Format: [java-package-namespace;keystore-file;password;alias]",
|
||||
" `java-package-namespace` is case insensitive and cannot be a sub-package of an existing registered namespace",
|
||||
" `keystore-file` refers to the location of key store file containing the signed certificate as generated by the Java 'keytool' tool (see https://docs.oracle.com/javase/8/docs/technotes/tools/windows/keytool.html)",
|
||||
" `password` to open the key store",
|
||||
" `alias` refers to the name associated with a certificate containing the public key to be associated with the package namespace"
|
||||
])
|
||||
var registerPackageOwnership: List<PackageOwner> = mutableListOf()
|
||||
|
||||
@Option(names = ["--unregister-package-owner"],
|
||||
converter = [JavaPackageNameConverter::class],
|
||||
description = [
|
||||
"Unregister owner of Java package namespace in the network-parameters.",
|
||||
"Format: [java-package-namespace]",
|
||||
" `java-package-namespace` is case insensitive and cannot be a sub-package of an existing registered namespace"
|
||||
])
|
||||
var unregisterPackageOwnership: List<JavaPackageName> = mutableListOf()
|
||||
|
||||
override fun runProgram(): Int {
|
||||
NetworkBootstrapper().bootstrap(dir.toAbsolutePath().normalize(), copyCordapps = !noCopy, minimumPlatformVersion = minimumPlatformVersion)
|
||||
NetworkBootstrapper().bootstrap(dir.toAbsolutePath().normalize(),
|
||||
copyCordapps = !noCopy,
|
||||
minimumPlatformVersion = minimumPlatformVersion,
|
||||
packageOwnership = registerPackageOwnership.map { Pair(it.javaPackageName, it.publicKey) }.toMap()
|
||||
.plus(unregisterPackageOwnership.map { Pair(it, null) })
|
||||
)
|
||||
return 0 //exit code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class PackageOwner(val javaPackageName: JavaPackageName, val publicKey: PublicKey)
|
||||
|
||||
/**
|
||||
* Converter from String to PackageOwner (JavaPackageName and PublicKey)
|
||||
*/
|
||||
class PackageOwnerConverter : CommandLine.ITypeConverter<PackageOwner> {
|
||||
override fun convert(packageOwner: String): PackageOwner {
|
||||
if (!packageOwner.isBlank()) {
|
||||
val packageOwnerSpec = packageOwner.split(";")
|
||||
if (packageOwnerSpec.size < 4)
|
||||
throw IllegalArgumentException("Package owner must specify 4 elements separated by semi-colon: 'java-package-namespace;keyStorePath;keyStorePassword;alias'")
|
||||
// java package name validation
|
||||
val javaPackageName = JavaPackageName(packageOwnerSpec[0])
|
||||
// cater for passwords that include the argument delimiter field
|
||||
val keyStorePassword =
|
||||
if (packageOwnerSpec.size > 4)
|
||||
packageOwnerSpec.subList(2, packageOwnerSpec.size-1).joinToString(";")
|
||||
else packageOwnerSpec[2]
|
||||
try {
|
||||
val ks = loadKeyStore(Paths.get(packageOwnerSpec[1]), keyStorePassword)
|
||||
try {
|
||||
val publicKey = ks.getCertificate(packageOwnerSpec[packageOwnerSpec.size-1]).publicKey
|
||||
return PackageOwner(javaPackageName,publicKey)
|
||||
}
|
||||
catch(kse: KeyStoreException) {
|
||||
throw IllegalArgumentException("Keystore has not been initialized for alias ${packageOwnerSpec[3]}")
|
||||
}
|
||||
}
|
||||
catch(kse: KeyStoreException) {
|
||||
throw IllegalArgumentException("Password is incorrect or the key store is damaged for keyStoreFilePath: ${packageOwnerSpec[1]} and keyStorePassword: $keyStorePassword")
|
||||
}
|
||||
catch(e: IOException) {
|
||||
throw IllegalArgumentException("Error reading the key store from the file for keyStoreFilePath: ${packageOwnerSpec[1]} and keyStorePassword: $keyStorePassword")
|
||||
}
|
||||
}
|
||||
else throw IllegalArgumentException("Must specify package owner argument: 'java-package-namespace;keyStorePath;keyStorePassword;alias'")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter from String to JavaPackageName.
|
||||
*/
|
||||
class JavaPackageNameConverter : CommandLine.ITypeConverter<JavaPackageName> {
|
||||
override fun convert(packageName: String): JavaPackageName {
|
||||
return JavaPackageName(packageName)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,167 @@
|
||||
package net.corda.bootstrapper
|
||||
|
||||
import net.corda.core.internal.deleteRecursively
|
||||
import net.corda.core.internal.div
|
||||
import net.corda.core.node.JavaPackageName
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.BOB_NAME
|
||||
import net.corda.testing.core.CHARLIE_NAME
|
||||
import net.corda.testing.core.JarSignatureTestUtils.generateKey
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.AfterClass
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
import picocli.CommandLine
|
||||
import java.nio.file.Files
|
||||
|
||||
class PackageOwnerParsingTest {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val expectedEx: ExpectedException = ExpectedException.none()
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ALICE = "alice"
|
||||
private const val ALICE_PASS = "alicepass"
|
||||
private const val BOB = "bob"
|
||||
private const val BOB_PASS = "bobpass"
|
||||
private const val CHARLIE = "charlie"
|
||||
private const val CHARLIE_PASS = "charliepass"
|
||||
|
||||
private val dirAlice = Files.createTempDirectory(ALICE)
|
||||
private val dirBob = Files.createTempDirectory(BOB)
|
||||
private val dirCharlie = Files.createTempDirectory(CHARLIE)
|
||||
|
||||
val networkBootstrapper = NetworkBootstrapperRunner()
|
||||
val commandLine = CommandLine(networkBootstrapper)
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun beforeClass() {
|
||||
dirAlice.generateKey(ALICE, ALICE_PASS, ALICE_NAME.toString())
|
||||
dirBob.generateKey(BOB, BOB_PASS, BOB_NAME.toString(), "EC")
|
||||
dirCharlie.generateKey(CHARLIE, CHARLIE_PASS, CHARLIE_NAME.toString(), "DSA")
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun afterClass() {
|
||||
dirAlice.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with single mapping`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath;$ALICE_PASS;$ALICE")
|
||||
commandLine.parse(*args)
|
||||
assertThat(networkBootstrapper.registerPackageOwnership[0].javaPackageName).isEqualTo(JavaPackageName("com.example.stuff"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with invalid arguments`() {
|
||||
val args = arrayOf("--register-package-owner", "com.!example.stuff")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("Package owner must specify 4 elements separated by semi-colon")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with incorrect keystore specification`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath$ALICE_PASS")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("Package owner must specify 4 elements separated by semi-colon")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with invalid java package name`() {
|
||||
val args = arrayOf("--register-package-owner", "com.!example.stuff;A;B;C")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("Invalid Java package name")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with invalid keystore file`() {
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;NONSENSE;B;C")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("Error reading the key store from the file")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with invalid keystore password`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath;BAD_PASSWORD;$ALICE")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("Error reading the key store from the file")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with invalid keystore alias`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath;$ALICE_PASS;BAD_ALIAS")
|
||||
expectedEx.expect(CommandLine.ParameterException::class.java)
|
||||
expectedEx.expectMessage("must not be null")
|
||||
commandLine.parse(*args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with multiple arguments`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val bobKeyStorePath = dirBob / "_teststore"
|
||||
val charlieKeyStorePath = dirCharlie / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath;$ALICE_PASS;$ALICE",
|
||||
"--register-package-owner", "com.example.more.stuff;$bobKeyStorePath;$BOB_PASS;$BOB",
|
||||
"--register-package-owner", "com.example.even.more.stuff;$charlieKeyStorePath;$CHARLIE_PASS;$CHARLIE")
|
||||
commandLine.parse(*args)
|
||||
assertThat(networkBootstrapper.registerPackageOwnership).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse registration request with delimiter inclusive passwords`() {
|
||||
val aliceKeyStorePath1 = dirAlice / "_alicestore1"
|
||||
dirAlice.generateKey("${ALICE}1", "passw;rd", ALICE_NAME.toString(), storeName = "_alicestore1")
|
||||
val aliceKeyStorePath2 = dirAlice / "_alicestore2"
|
||||
dirAlice.generateKey("${ALICE}2", "\"passw;rd\"", ALICE_NAME.toString(), storeName = "_alicestore2")
|
||||
val aliceKeyStorePath3 = dirAlice / "_alicestore3"
|
||||
dirAlice.generateKey("${ALICE}3", "passw;rd", ALICE_NAME.toString(), storeName = "_alicestore3")
|
||||
val aliceKeyStorePath4 = dirAlice / "_alicestore4"
|
||||
dirAlice.generateKey("${ALICE}4", "\'passw;rd\'", ALICE_NAME.toString(), storeName = "_alicestore4")
|
||||
val aliceKeyStorePath5 = dirAlice / "_alicestore5"
|
||||
dirAlice.generateKey("${ALICE}5", "\"\"passw;rd\"\"", ALICE_NAME.toString(), storeName = "_alicestore5")
|
||||
val packageOwnerSpecs = listOf("net.something0;$aliceKeyStorePath1;passw;rd;${ALICE}1",
|
||||
"net.something1;$aliceKeyStorePath2;\"passw;rd\";${ALICE}2",
|
||||
"\"net.something2;$aliceKeyStorePath3;passw;rd;${ALICE}3\"",
|
||||
"net.something3;$aliceKeyStorePath4;\'passw;rd\';${ALICE}4",
|
||||
"net.something4;$aliceKeyStorePath5;\"\"passw;rd\"\";${ALICE}5")
|
||||
packageOwnerSpecs.forEachIndexed { i, packageOwnerSpec ->
|
||||
commandLine.parse(*arrayOf("--register-package-owner", packageOwnerSpec))
|
||||
assertThat(networkBootstrapper.registerPackageOwnership[0].javaPackageName).isEqualTo(JavaPackageName("net.something$i"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse unregister request with single mapping`() {
|
||||
val args = arrayOf("--unregister-package-owner", "com.example.stuff")
|
||||
commandLine.parse(*args)
|
||||
assertThat(networkBootstrapper.unregisterPackageOwnership).contains(JavaPackageName("com.example.stuff"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse mixed register and unregister request`() {
|
||||
val aliceKeyStorePath = dirAlice / "_teststore"
|
||||
val args = arrayOf("--register-package-owner", "com.example.stuff;$aliceKeyStorePath;$ALICE_PASS;$ALICE",
|
||||
"--unregister-package-owner", "com.example.stuff2")
|
||||
commandLine.parse(*args)
|
||||
assertThat(networkBootstrapper.registerPackageOwnership.map { it.javaPackageName }).contains(JavaPackageName("com.example.stuff"))
|
||||
assertThat(networkBootstrapper.unregisterPackageOwnership).contains(JavaPackageName("com.example.stuff2"))
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user