diff --git a/wkd-java/build.gradle b/wkd-java/build.gradle index 33f1ac94..d45fbbf0 100644 --- a/wkd-java/build.gradle +++ b/wkd-java/build.gradle @@ -17,6 +17,7 @@ dependencies { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" // Logging + api "org.slf4j:slf4j-api:$slf4jVersion" testImplementation "ch.qos.logback:logback-classic:$logbackVersion" // Z-Base32 diff --git a/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java b/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java new file mode 100644 index 00000000..7252f881 --- /dev/null +++ b/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.wkd; + +import java.io.IOException; +import java.io.InputStream; + +public interface IWKDFetcher { + + InputStream fetch(WKDAddress address) throws IOException; +} diff --git a/wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java b/wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java new file mode 100644 index 00000000..95be3374 --- /dev/null +++ b/wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.wkd; + +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JavaHttpRequestWKDFetcher implements IWKDFetcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(JavaHttpRequestWKDFetcher.class); + + @Override + public InputStream fetch(WKDAddress address) throws IOException { + URI advanced = address.getAdvancedMethodURI(); + IOException advancedException; + try { + return tryFetchUri(advanced); + } catch (IOException e) { + advancedException = e; + LOGGER.debug("Could not fetch key using advanced method from " + advanced.toString(), e); + } + + URI direct = address.getDirectMethodURI(); + try { + return tryFetchUri(direct); + } catch (IOException e) { + advancedException.addSuppressed(e); + LOGGER.debug("Could not fetch key using direct method from " + direct.toString(), e); + throw advancedException; + } + } + + private InputStream tryFetchUri(URI uri) throws IOException { + HttpURLConnection con = getConnection(uri); + con.setRequestMethod("GET"); + + con.setConnectTimeout(5000); + con.setReadTimeout(5000); + con.setInstanceFollowRedirects(false); + + int status = con.getResponseCode(); + if (status != 200) { + throw new ConnectException("Connection was unsuccessful"); + } + LOGGER.debug("Successfully fetched key from " + uri); + return con.getInputStream(); + } + + private HttpURLConnection getConnection(URI uri) throws IOException { + URL url = uri.toURL(); + return (HttpURLConnection) url.openConnection(); + } + + public static void main(String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException("Expect a single argument email address"); + } + + String email = args[0]; + WKDAddress address = WKDAddress.fromEmail(email); + + JavaHttpRequestWKDFetcher fetch = new JavaHttpRequestWKDFetcher(); + try { + InputStream inputStream = fetch.fetch(address); + byte[] buf = new byte[4096]; + int read; + while ((read = inputStream.read(buf)) != -1) { + System.out.write(buf, 0, read); + } + inputStream.close(); + System.exit(0); + } catch (IOException e) { + LOGGER.debug("Could not fetch key.", e); + System.exit(1); + } + } +} diff --git a/wkd-java/src/main/java/pgp/wkd/WKDAddress.java b/wkd-java/src/main/java/pgp/wkd/WKDAddress.java index fc9f235d..23c59565 100644 --- a/wkd-java/src/main/java/pgp/wkd/WKDAddress.java +++ b/wkd-java/src/main/java/pgp/wkd/WKDAddress.java @@ -4,6 +4,8 @@ package pgp.wkd; +import org.apache.commons.codec.binary.ZBase32; + import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLEncoder; @@ -13,89 +15,73 @@ import java.security.NoSuchAlgorithmException; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.apache.commons.codec.binary.ZBase32; +public class WKDAddress { -/** - * Transform an email address into a WKD address. - * - * @see OpenPGP Web Key Directory - */ -public final class WKDAddress { + private static final String SCHEME = "https://"; + private static final String ADV_SUBDOMAIN = "openpgpkey."; + private static final String DIRECT_WELL_KNOWN = "/.well-known/openpgpkey/hu/"; + private static String ADV_WELL_KNOWN(String domain) { + return "/.well-known/openpgpkey/" + domain + "/hu/"; + } // RegEx for Email Addresses. // https://www.baeldung.com/java-email-validation-regex#regular-expression-by-rfc-5322-for-email-validation // Modified by adding capture groups '()' for local and domain part private static final Pattern PATTERN_EMAIL = Pattern.compile("^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)@([a-zA-Z0-9.-]+)$"); - // Firstname Lastname [Optional Comment] - // we are only interested in "email@address" - private static final Pattern PATTERN_USER_ID = Pattern.compile("^.*\\<([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+)\\>.*"); - - private static final ZBase32 zBase32 = new ZBase32(); private static final Charset utf8 = Charset.forName("UTF8"); + private static final ZBase32 zBase32 = new ZBase32(); - private static final String SCHEME = "https://"; - private static final String SUBDOMAIN = "openpgpkey"; - private static final String PATH = "/.well-known/openpgpkey/"; - private static final String HU = "/hu/"; - private static final String PATH_HU = "/.well-known/openpgpkey/hu/"; + private final String localPart; + private final String domainPart; + private final String zbase32LocalPart; + private final String percentEncodedLocalPart; - private WKDAddress() { + public WKDAddress(String localPart, String domainPart) { + this.localPart = localPart; + this.domainPart = domainPart.toLowerCase(); + this.zbase32LocalPart = zbase32(this.localPart); + this.percentEncodedLocalPart = percentEncode(this.localPart); } - public static String emailFromUserId(String userId) { - Matcher matcher = PATTERN_USER_ID.matcher(userId); - if (!matcher.matches()) { - throw new IllegalArgumentException("User-ID does not follow excepted pattern \"Firstname Lastname [Optional Comment]\""); + public static WKDAddress fromEmail(String email) { + MailAddress mailAddress = parseMailAddress(email); + return new WKDAddress(mailAddress.getLocalPart(), mailAddress.getDomainPart()); + } + + public URI getDirectMethodURI() { + return URI.create(SCHEME + domainPart + DIRECT_WELL_KNOWN + zbase32LocalPart + "?l=" + percentEncodedLocalPart); + } + + public URI getAdvancedMethodURI() { + return URI.create(SCHEME + ADV_SUBDOMAIN + domainPart + ADV_WELL_KNOWN(domainPart) + zbase32LocalPart + "?l=" + percentEncodedLocalPart); + } + + private String zbase32(String localPart) { + MessageDigest sha1; + try { + sha1 = MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + // SHA-1 is a MUST on JVM implementations + throw new AssertionError(e); } + sha1.update(localPart.toLowerCase().getBytes(utf8)); + byte[] digest = sha1.digest(); - String email = matcher.group(1); - return email; + String base32KeyHandle = zBase32.encodeAsString(digest); + return base32KeyHandle; } - public static URI directFromUserId(String userId) { - String email = emailFromUserId(userId); - return directFromEmail(email); + private String percentEncode(String localPart) { + try { + return URLEncoder.encode(localPart, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF8 is a MUST on JVM implementations + throw new AssertionError(e); + } } - public static URI directFromEmail(String email) { - MailAddress mailAddress = parseMailAddress(email); - - return URI.create(SCHEME + mailAddress.getDomainPart() + PATH_HU + mailAddress.getHashedLocalPart() + "?l=" + mailAddress.getPercentEncodedLocalPart()); - } - - /** - * Extract the email address from a user-id. - * The user-id is expected to correspond to a RFC2822 name-addr. - * The email address is expected to be framed by angle brackets. - * - * @see RFC2822 - Internet Message Format ยง3.4: Address Specification - * @param userId user-id name-addr - * @return WKD URI - * - * @throws IllegalArgumentException in case the user-id does not match the expected format - */ - public static URI advancedFromUserId(String userId) { - String email = emailFromUserId(userId); - return advancedFromEmail(email); - } - - /** - * Translate an email address (localpart@domainpart) to a WKD URI. - * - * @param email email address - * @return WKD URI - * @throws IllegalArgumentException in case of a malformed email address - */ - public static URI advancedFromEmail(String email) { - MailAddress mailAddress = parseMailAddress(email); - - return URI.create( - SCHEME + SUBDOMAIN + "." + mailAddress.getDomainPart() + PATH + mailAddress.getDomainPart() - + HU + mailAddress.getHashedLocalPart() + "?l=" + mailAddress.getPercentEncodedLocalPart() - ); - } private static MailAddress parseMailAddress(String email) { Matcher matcher = PATTERN_EMAIL.matcher(email); @@ -121,36 +107,8 @@ public final class WKDAddress { return localPart; } - public String getLowerCaseLocalPart() { - return getLocalPart().toLowerCase(); - } - - public String getPercentEncodedLocalPart() { - try { - return URLEncoder.encode(getLocalPart(), "UTF-8"); - } catch (UnsupportedEncodingException e) { - // UTF8 is a MUST on JVM implementations - throw new AssertionError(e); - } - } - - public String getHashedLocalPart() { - MessageDigest sha1; - try { - sha1 = MessageDigest.getInstance("SHA1"); - } catch (NoSuchAlgorithmException e) { - // SHA-1 is a MUST on JVM implementations - throw new AssertionError(e); - } - sha1.update(getLowerCaseLocalPart().getBytes(utf8)); - byte[] digest = sha1.digest(); - - String base32KeyHandle = zBase32.encodeAsString(digest); - return base32KeyHandle; - } - public String getDomainPart() { - return domainPart.toLowerCase(); + return domainPart; } } } diff --git a/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java b/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java new file mode 100644 index 00000000..0be0bfeb --- /dev/null +++ b/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.wkd; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class WKDAddressHelper { + + // Firstname Lastname [Optional Comment] + // we are only interested in "email@address" + private static final Pattern PATTERN_USER_ID = Pattern.compile("^.*\\<([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+)\\>.*"); + + public static String emailFromUserId(String userId) { + Matcher matcher = PATTERN_USER_ID.matcher(userId); + if (!matcher.matches()) { + throw new IllegalArgumentException("User-ID does not follow excepted pattern \"Firstname Lastname [Optional Comment]\""); + } + + String email = matcher.group(1); + return email; + } + + public static WKDAddress wkdAddressFromUserId(String userId) { + String email = emailFromUserId(userId); + return WKDAddress.fromEmail(email); + } +} diff --git a/wkd-java/src/test/java/pgp/wkd/WKDAddressTest.java b/wkd-java/src/test/java/pgp/wkd/WKDAddressTest.java index 23fbfa99..7eec691e 100644 --- a/wkd-java/src/test/java/pgp/wkd/WKDAddressTest.java +++ b/wkd-java/src/test/java/pgp/wkd/WKDAddressTest.java @@ -19,15 +19,18 @@ public class WKDAddressTest { String userId = "Joe Doe [Work Address]"; URI expectedURI = URI.create("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); - URI actual = WKDAddress.advancedFromUserId(userId); + WKDAddress address = WKDAddressHelper.wkdAddressFromUserId(userId); + URI actual = address.getAdvancedMethodURI(); assertEquals(expectedURI, actual); } @Test - public void testDirectFromUserId2() { + public void testDirectFromUserId() { String userId = ""; URI expected = URI.create("https://pgpainless.org/.well-known/openpgpkey/hu/kei1q4tipxxu1yj79k9kfukdhfy631xe?l=alice"); - URI actual = WKDAddress.directFromUserId(userId); + + WKDAddress address = WKDAddressHelper.wkdAddressFromUserId(userId); + URI actual = address.getDirectMethodURI(); assertEquals(expected, actual); } @@ -36,7 +39,8 @@ public class WKDAddressTest { String mailAddress = "Joe.Doe@Example.ORG"; URI expected = URI.create("https://example.org/.well-known/openpgpkey/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); - URI actual = WKDAddress.directFromEmail(mailAddress); + WKDAddress address = WKDAddress.fromEmail(mailAddress); + URI actual = address.getDirectMethodURI(); assertEquals(expected, actual); } @@ -45,7 +49,8 @@ public class WKDAddressTest { String mailAddress = "Joe.Doe@Example.ORG"; URI expected = URI.create("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); - URI actual = WKDAddress.advancedFromEmail(mailAddress); + WKDAddress address = WKDAddress.fromEmail(mailAddress); + URI actual = address.getAdvancedMethodURI(); assertEquals(expected, actual); } @@ -61,16 +66,14 @@ public class WKDAddressTest { "John Doe [The Real One]", "")) { - assertThrows(IllegalArgumentException.class, () -> WKDAddress.directFromUserId(brokenUserId)); - assertThrows(IllegalArgumentException.class, () -> WKDAddress.advancedFromUserId(brokenUserId)); + assertThrows(IllegalArgumentException.class, () -> WKDAddressHelper.wkdAddressFromUserId(brokenUserId)); } } @Test public void testFromInvalidEmail() { for (String brokenEmail : Arrays.asList("john.doe", "@example.org", "john doe@example.org", "john.doe@example org")) { - assertThrows(IllegalArgumentException.class, () -> WKDAddress.directFromEmail(brokenEmail)); - assertThrows(IllegalArgumentException.class, () -> WKDAddress.advancedFromEmail(brokenEmail)); + assertThrows(IllegalArgumentException.class, () -> WKDAddress.fromEmail(brokenEmail)); } } }