From 87dd99d968c13c4d2f290aa80caa48fdec4c673a Mon Sep 17 00:00:00 2001 From: Patrick Kuo Date: Mon, 24 Apr 2017 21:16:00 +0800 Subject: [PATCH] legal name validator for doorman node registration (#532) --- .../core/utilities/LegalNameValidator.kt | 101 +++++++++++++++++ .../core/utilities/LegalNameValidatorTest.kt | 107 ++++++++++++++++++ docs/source/permissioning.rst | 28 ++++- 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 core/src/main/kotlin/net/corda/core/utilities/LegalNameValidator.kt create mode 100644 core/src/test/kotlin/net/corda/core/utilities/LegalNameValidatorTest.kt diff --git a/core/src/main/kotlin/net/corda/core/utilities/LegalNameValidator.kt b/core/src/main/kotlin/net/corda/core/utilities/LegalNameValidator.kt new file mode 100644 index 0000000000..d874265096 --- /dev/null +++ b/core/src/main/kotlin/net/corda/core/utilities/LegalNameValidator.kt @@ -0,0 +1,101 @@ +@file:JvmName("LegalNameValidator") + +package net.corda.core.utilities + +import java.lang.Character.UnicodeScript.* +import java.text.Normalizer +import java.util.regex.Pattern +import javax.security.auth.x500.X500Principal + +/** + * The validation function will validate the input string using the following rules: + * - No blacklisted words like "node", "server". + * - Restrict names to Latin scripts for now to avoid right-to-left issues, debugging issues when we can't pronounce names over the phone, and character confusability attacks. + * - Should start with a capital letter. + * - No commas or equals signs. + * - No dollars or quote marks, we might need to relax the quote mark constraint in future to handle Irish company names. + */ +fun validateLegalName(normalizedLegalName: String) { + rules.forEach { it.validate(normalizedLegalName) } +} + +/** + * The normalize function will trim the input string, replace any multiple spaces with a single space, + * and normalize the string according to NFKC normalization form. + */ +fun normaliseLegalName(legalName: String): String { + val trimmedLegalName = legalName.trim().replace(Regex("\\s+"), " ") + return Normalizer.normalize(trimmedLegalName, Normalizer.Form.NFKC) +} + +private val rules: List> = listOf( + UnicodeNormalizationRule(), + CharacterRule(',', '=', '$', '"', '\'', '\\'), + WordRule("node", "server"), + LengthRule(maxLength = 255), + // TODO: Implement confusable character detection if we add more scripts. + UnicodeRangeRule(LATIN, COMMON, INHERITED), + CapitalLetterRule(), + X500NameRule() +) + +private class UnicodeNormalizationRule : Rule { + override fun validate(legalName: String) { + require(legalName == normaliseLegalName(legalName)) { "Legal name must be normalized. Please use 'normaliseLegalName' to normalize the legal name before validation." } + } +} + +private class UnicodeRangeRule(vararg supportScripts: Character.UnicodeScript) : Rule { + private val pattern = supportScripts.map { "\\p{Is$it}" }.joinToString(separator = "", prefix = "[", postfix = "]*").let { Pattern.compile(it) } + + override fun validate(legalName: String) { + require(pattern.matcher(legalName).matches()) { + val illegalChars = legalName.replace(pattern.toRegex(), "").toSet() + if (illegalChars.size > 1) { + "Illegal characters $illegalChars in \"$legalName\"." + } else { + "Illegal character $illegalChars in \"$legalName\"." + } + } + } +} + +private class CharacterRule(vararg val bannedChars: Char) : Rule { + override fun validate(legalName: String) { + bannedChars.forEach { + require(!legalName.contains(it, true)) { "Illegal character: $it" } + } + } +} + +private class WordRule(vararg val bannedWords: String) : Rule { + override fun validate(legalName: String) { + bannedWords.forEach { + require(!legalName.contains(it, ignoreCase = true)) { "Illegal word: $it" } + } + } +} + +private class LengthRule(val maxLength: Int) : Rule { + override fun validate(legalName: String) { + require(legalName.length <= maxLength) { "Legal name longer then $maxLength characters." } + } +} + +private class CapitalLetterRule : Rule { + override fun validate(legalName: String) { + val capitalizedLegalName = legalName.split(" ").map(String::capitalize).joinToString(" ") + require(legalName == capitalizedLegalName) { "Legal name should be capitalized. i.e. '$capitalizedLegalName'" } + } +} + +private class X500NameRule : Rule { + override fun validate(legalName: String) { + // This will throw IllegalArgumentException if the name does not comply with X500 name format. + X500Principal("CN=$legalName") + } +} + +private interface Rule { + fun validate(legalName: T) +} diff --git a/core/src/test/kotlin/net/corda/core/utilities/LegalNameValidatorTest.kt b/core/src/test/kotlin/net/corda/core/utilities/LegalNameValidatorTest.kt new file mode 100644 index 0000000000..8ec34f752d --- /dev/null +++ b/core/src/test/kotlin/net/corda/core/utilities/LegalNameValidatorTest.kt @@ -0,0 +1,107 @@ +package net.corda.core.utilities + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LegalNameValidatorTest { + @Test + fun `no double spaces`() { + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Test Legal Name") + } + validateLegalName(normaliseLegalName("Test Legal Name")) + } + + @Test + fun `no trailing white space`() { + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Test ") + } + } + + @Test + fun `no prefixed white space`() { + assertFailsWith(IllegalArgumentException::class) { + validateLegalName(" Test") + } + } + + @Test + fun `blacklisted words`() { + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Test Server") + } + } + + @Test + fun `blacklisted characters`() { + validateLegalName("Test") + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("\$Test") + } + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("\"Test") + } + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("\'Test") + } + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("=Test") + } + } + + @Test + fun `unicode range`() { + validateLegalName("Test A") + assertFailsWith(IllegalArgumentException::class) { + // Greek letter A. + validateLegalName("Test Α") + } + } + + @Test + fun `legal name length less then 256 characters`() { + val longLegalName = StringBuilder() + while (longLegalName.length < 255) { + longLegalName.append("A") + } + validateLegalName(longLegalName.toString()) + + assertFailsWith(IllegalArgumentException::class) { + validateLegalName(longLegalName.append("A").toString()) + } + } + + @Test + fun `legal name should be capitalized`() { + validateLegalName("Good Legal Name") + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("bad name") + } + + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Bad name") + } + + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("bad Name") + } + } + + @Test + fun `correctly handle whitespaces`() { + assertEquals("Legal Name With Tab", normaliseLegalName("Legal Name With\tTab")) + assertEquals("Legal Name With Unicode Whitespaces", normaliseLegalName("Legal Name\u2004With\u0009Unicode\u0020Whitespaces")) + assertEquals("Legal Name With Line Breaks", normaliseLegalName("Legal Name With\n\rLine\nBreaks")) + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Legal Name With\tTab") + } + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Legal Name\u2004With\u0009Unicode\u0020Whitespaces") + } + assertFailsWith(IllegalArgumentException::class) { + validateLegalName("Legal Name With\n\rLine\nBreaks") + } + } +} \ No newline at end of file diff --git a/docs/source/permissioning.rst b/docs/source/permissioning.rst index 79c27229ff..1a75104a7f 100644 --- a/docs/source/permissioning.rst +++ b/docs/source/permissioning.rst @@ -17,7 +17,7 @@ The following information from the node configuration file is needed to generate :myLegalName: Your company's legal name. e.g. "Mega Corp LLC". This needs to be unique on the network. If another node has already been permissioned with this name then the permissioning server will automatically reject the request. The - request will also be rejected if the name contains a ``=`` or ``,``. + request will also be rejected if it violates legal name rules, see `Legal Name Constraints`_ for more information. .. note:: In a future version the uniqueness requirement will be relaxed to a X.500 name. This will allow differentiation between entities with the same name. @@ -38,6 +38,32 @@ Once the request has been approved and the certificates downloaded from the serv This process only is needed when the node connects to the network for the first time, or when the certificate expires. +Legal Name Constraints +---------------------- +The legal name is the unique identifier in the Corda network, so constraints have been set out to prevent encoding attacks and visual spoofing. + +The legal name validator (see ``LegalNameValidator.kt``) is used to enforce rules on Corda's legal names, it is intended to be used by the network operator and Corda node during the node registration process. +It has two functions, a function to normalize legal names, and a function to validate legal names. + +The normalize function performs the following transformations: + +* Remove leading and trailing whitespaces. + +* Replace multiple whitespaces with a single space. + +* Normalize the string according to `NFKC normalization form `_. + +The validation function will validate the input string using the following rules: + +* No blacklisted words like "node", "server". + +* Restrict names to Latin scripts for now to avoid right-to-left issues, debugging issues when we can't pronounce names over the phone, and character confusability attacks. + +* Should start with a capital letter. + +* No commas or equals signs. + +* No dollars or quote marks, although we may relax the quote mark constraint in future to handle Irish company names. Starting the Registration -------------------------