diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bb5876..62cbbf55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,9 @@ SPDX-License-Identifier: CC0-1.0 - Make countermeasures against [KOpenPGP](https://kopenpgp.com/) attacks configurable - Countermeasures are now disabled by default since they are costly and have a specific threat model - Can be enabled by calling `Policy.setEnableKeyParameterValidation(true)` +- Add support for parsing RegularExpressions + - Add module `hsregex` which uses `tcl-regex-java` implementing Henry Spencers Regular Expression library + ## 1.4.0-rc2 - Bump `bcpg-jdk15to18` to `1.72.3` diff --git a/hsregex/README.md b/hsregex/README.md new file mode 100644 index 00000000..ad5fcddc --- /dev/null +++ b/hsregex/README.md @@ -0,0 +1,18 @@ + + +# Evaluate Regular Expressions in OpenPGP Signatures using TCL-Regex + +RFC4880 specifies contains a section about RegularExpression subpackets on signatures. +Within this section, the syntax of the RegularExpression subpackets is defined to be the same as Henry Spencer's "almost public domain" regular expression package. + +Since Java's `java.util.regex` syntax is too powerful, this module exists to implement regex evaluation using [tcl-regex](https://github.com/basis-technology-corp/tcl-regex-java) +which appears to be a Java port of Henry Spencers regex package. + +To make use of this implementation, simply call +```java +RegexInterpreterFactory.setInstance(new HSRegexInterpreterFactory()); +``` diff --git a/hsregex/build.gradle b/hsregex/build.gradle new file mode 100644 index 00000000..d8953b40 --- /dev/null +++ b/hsregex/build.gradle @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id 'java-library' +} + +group 'org.pgpainless' + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + implementation(project(":pgpainless-core")) + + // Henry Spencers Regular Expression (RegEx packets) + implementation 'com.basistech.tclre:tcl-regex:0.14.5' +} + +test { + useJUnitPlatform() +} diff --git a/hsregex/src/main/java/org/pgpainless/algorithm/HSRegexInterpreterFactory.java b/hsregex/src/main/java/org/pgpainless/algorithm/HSRegexInterpreterFactory.java new file mode 100644 index 00000000..8d8a3068 --- /dev/null +++ b/hsregex/src/main/java/org/pgpainless/algorithm/HSRegexInterpreterFactory.java @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import com.basistech.tclre.HsrePattern; +import com.basistech.tclre.PatternFlags; +import com.basistech.tclre.RePattern; +import com.basistech.tclre.RegexException; + +public class HSRegexInterpreterFactory extends RegexInterpreterFactory { + + public Regex instantiate(String regex) { + return new Regex() { + + private final RePattern pattern; + + { + try { + pattern = HsrePattern.compile(regex, PatternFlags.ADVANCED); + } catch (RegexException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public boolean matches(String string) { + return pattern.matcher(string).find(); + } + }; + } +} diff --git a/hsregex/src/main/java/org/pgpainless/algorithm/package-info.java b/hsregex/src/main/java/org/pgpainless/algorithm/package-info.java new file mode 100644 index 00000000..5dc78706 --- /dev/null +++ b/hsregex/src/main/java/org/pgpainless/algorithm/package-info.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Regex interpreter implementation based on Henry Spencers Regular Expression library. + * + * @see RFC4880 - §8. Regular Expressions + */ +package org.pgpainless.algorithm; diff --git a/hsregex/src/test/java/org/pgpainless/algorithm/HSRegexInterpreterFactoryTest.java b/hsregex/src/test/java/org/pgpainless/algorithm/HSRegexInterpreterFactoryTest.java new file mode 100644 index 00000000..e203a8dc --- /dev/null +++ b/hsregex/src/test/java/org/pgpainless/algorithm/HSRegexInterpreterFactoryTest.java @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HSRegexInterpreterFactoryTest { + + @Test + public void dummyRegexTest() { + HSRegexInterpreterFactory factory = new HSRegexInterpreterFactory(); + RegexInterpreterFactory.setInstance(factory); + Regex regex = RegexInterpreterFactory.create("Alice|Bob"); + + assertTrue(regex.matches("Alice")); + assertTrue(regex.matches("Bob")); + assertFalse(regex.matches("Charlie")); + } +} diff --git a/hsregex/src/test/java/org/pgpainless/algorithm/package-info.java b/hsregex/src/test/java/org/pgpainless/algorithm/package-info.java new file mode 100644 index 00000000..5dc78706 --- /dev/null +++ b/hsregex/src/test/java/org/pgpainless/algorithm/package-info.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Regex interpreter implementation based on Henry Spencers Regular Expression library. + * + * @see RFC4880 - §8. Regular Expressions + */ +package org.pgpainless.algorithm; diff --git a/pgpainless-core/build.gradle b/pgpainless-core/build.gradle index bab6ecf1..3c07c7d0 100644 --- a/pgpainless-core/build.gradle +++ b/pgpainless-core/build.gradle @@ -26,6 +26,9 @@ dependencies { // @Nullable, @Nonnull annotations implementation "com.google.code.findbugs:jsr305:3.0.2" + + // HSRE regex for tests + testImplementation project(":hsregex") } // https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_modular_auto diff --git a/pgpainless-core/src/main/java/org/pgpainless/algorithm/Regex.java b/pgpainless-core/src/main/java/org/pgpainless/algorithm/Regex.java new file mode 100644 index 00000000..f28bca75 --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/algorithm/Regex.java @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import org.pgpainless.key.util.UserId; + +public interface Regex { + + /** + * Return true, if the regex matches the given user-id. + * + * @param userId userId + * @return true if matches, false otherwise + */ + default boolean matches(UserId userId) { + return matches(userId.toString()); + } + + /** + * Return true, if the regex matches the given string. + * + * @param string string + * @return true if matches, false otherwise + */ + boolean matches(String string); + + static Regex wildcard() { + return new Regex() { + @Override + public boolean matches(String string) { + return true; + } + }; + } +} diff --git a/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexInterpreterFactory.java b/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexInterpreterFactory.java new file mode 100644 index 00000000..426b6c49 --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexInterpreterFactory.java @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import java.util.regex.Pattern; +import javax.annotation.Nonnull; + +public abstract class RegexInterpreterFactory { + + private static RegexInterpreterFactory INSTANCE; + + public static RegexInterpreterFactory getInstance() { + if (INSTANCE == null) { + INSTANCE = new JavaRegexInterpreterFactory(); + } + return INSTANCE; + } + + public static void setInstance(@Nonnull RegexInterpreterFactory instance) { + INSTANCE = instance; + } + + public static Regex create(String regex) { + return getInstance().instantiate(regex); + } + + /** + * Regex that matches any mail address on the given mail server. + * For example, calling this method with parameter
pgpainless.org
will return a regex + * that matches any of the following user ids: + *
+     *     Alice 
+     *     
+     *     Issuer (code signing) 
+     * 
+ * It will however not match the following mail addresses: + *
+     *     Alice 
+     *     alice@pgpainless.org
+     *     alice@pgpainless.org 
+     *     Bob 
+     * 
+ * Note: This method will not validate the given domain string, so that is your responsibility! + * + * @param mailDomain domain + * @return regex matching the domain + */ + public static Regex createDefaultMailDomainRegex(String mailDomain) { + String escaped = mailDomain.replace(".", "\\."); + return create("<[^>]+[@.]" + escaped + ">$"); + } + + public abstract Regex instantiate(String regex) throws IllegalArgumentException; + + public static class JavaRegexInterpreterFactory extends RegexInterpreterFactory { + + @Override + public Regex instantiate(String regex) { + return new Regex() { + + private final Pattern pattern = Pattern.compile(regex); + + @Override + public boolean matches(String string) { + return pattern.matcher(string).find(); + } + }; + } + } +} diff --git a/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexSet.java b/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexSet.java new file mode 100644 index 00000000..e4639e03 --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/algorithm/RegexSet.java @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public final class RegexSet { + + private final Set regexSet = new HashSet<>(); + + private RegexSet(Collection regexes) { + this.regexSet.addAll(regexes); + } + + public static RegexSet matchAnything() { + return new RegexSet(Collections.singleton(Regex.wildcard())); + } + + public static RegexSet matchNothing() { + return new RegexSet(Collections.emptySet()); + } + + public static RegexSet matchSome(Collection regexes) { + return new RegexSet(regexes); + } + + public boolean matches(String userId) { + for (Regex regex : regexSet) { + if (regex.matches(userId)) { + return true; + } + } + return false; + } +} diff --git a/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexSetTest.java b/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexSetTest.java new file mode 100644 index 00000000..ff8b12fe --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexSetTest.java @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +public class RegexSetTest { + + @Test + public void matchNothingTest() { + RegexSet set = RegexSet.matchNothing(); + assertFalse(set.matches("")); + assertFalse(set.matches("Alice")); + assertFalse(set.matches("Alice ")); + assertFalse(set.matches("")); + } + + @Test + public void matchAnything() { + RegexSet set = RegexSet.matchAnything(); + assertTrue(set.matches("Alice")); + assertTrue(set.matches("")); + assertTrue(set.matches("Alice ")); + assertTrue(set.matches("Alice ")); + assertTrue(set.matches("")); + } + + @Test + public void matchSome() { + Regex pgpainless_org = RegexInterpreterFactory.createDefaultMailDomainRegex("pgpainless.org"); + Regex example_org = RegexInterpreterFactory.createDefaultMailDomainRegex("example.org"); + + RegexSet set = RegexSet.matchSome(Arrays.asList(pgpainless_org, example_org)); + assertTrue(set.matches("Alice ")); + assertTrue(set.matches("")); + assertTrue(set.matches("Bob ")); + assertTrue(set.matches("")); + assertFalse(set.matches("Bob ")); + assertFalse(set.matches("Alice ")); + assertFalse(set.matches("alice@pgpainless.org")); + assertFalse(set.matches("Alice")); + assertFalse(set.matches("")); + } +} diff --git a/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexTest.java b/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexTest.java new file mode 100644 index 00000000..f1692569 --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/algorithm/RegexTest.java @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.algorithm; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.pgpainless.key.util.UserId; + +public class RegexTest { + private static Stream provideRegexInterpreterFactories() { + return Stream.of( + Arguments.of(Named.of("Default JavaRegexInterpreterFactory", + new RegexInterpreterFactory.JavaRegexInterpreterFactory())), + Arguments.of(Named.of("HSRegexInterpreterFactory", + new HSRegexInterpreterFactory())) + ); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void simpleTest(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("Alice|Bob"); + assertTrue(regex.matches("Alice")); + assertTrue(regex.matches("Bob")); + assertFalse(regex.matches("Charlie")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testEmailRegexMatchesDomain(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("<[^>]+[@.]pgpainless\\.org>$"); + assertTrue(regex.matches("Alice ")); + assertTrue(regex.matches("Bob ")); + assertFalse(regex.matches("Alice "), "wrong domain"); + assertFalse(regex.matches("Bob "), "wrong domain"); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testEmailRegexMatchesOnlyWrappedAddresses(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("<[^>]+[@.]pgpainless\\.org>$"); + assertTrue(regex.matches("")); + assertFalse(regex.matches("alice@pgpainless.org"), "only match mails in <>"); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testCaseSensitivity(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("<[^>]+[@.]pgpainless\\.org>$"); + assertFalse(regex.matches("Alice ")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testWildCard(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate(".*"); + assertTrue(regex.matches("")); + assertTrue(regex.matches("Alice")); + assertTrue(regex.matches("")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testExclusion(RegexInterpreterFactory factory) { + // Test [^>] matches all but '>' + Regex regex = factory.instantiate("<[^>]+[@.]pgpainless\\.org>$"); + assertFalse(regex.matches("appendix@pgpainless.org>")); + assertFalse(regex.matches("<>alice@pgpainless.org>")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testOnlyMatchAtTheEnd(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("<[^>]+[@.]pgpainless\\.org>$"); + assertFalse(regex.matches("Alice ")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testRanges(RegexInterpreterFactory factory) { + Regex regex = factory.instantiate("<[^>]+[0-9][@.]pgpainless\\.org>$"); + + for (int i = 0; i < 10; i++) { + String mail = ""; + assertTrue(regex.matches(mail)); + } + + assertFalse(regex.matches("")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testExactMailMatch(RegexInterpreterFactory factory) { + Regex exactMail = factory.instantiate("$"); + assertTrue(exactMail.matches("")); + assertTrue(exactMail.matches("Exact Match ")); + assertFalse(exactMail.matches("")); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testSetInstance(RegexInterpreterFactory factory) { + RegexInterpreterFactory before = RegexInterpreterFactory.getInstance(); + RegexInterpreterFactory.setInstance(factory); + + Regex regex = RegexInterpreterFactory.create("<[^>]+[@.]pgpainless\\.org>$"); + assertTrue(regex.matches(UserId.nameAndEmail("Alice", "alice@pgpainless.org"))); + + RegexInterpreterFactory.setInstance(before); + } + + @ParameterizedTest + @MethodSource("provideRegexInterpreterFactories") + public void testInvalidRegex(RegexInterpreterFactory factory) { + assertThrows(IllegalArgumentException.class, () -> factory.instantiate("[ab")); + } +} diff --git a/settings.gradle b/settings.gradle index aea19392..9fe2061a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,5 +6,6 @@ rootProject.name = 'PGPainless' include 'pgpainless-core', 'pgpainless-sop', - 'pgpainless-cli' + 'pgpainless-cli', + 'hsregex'