diff --git a/wkd-java-cli/src/main/java/pgp/wkd/cli/MissingUserIdException.java b/wkd-java-cli/src/main/java/pgp/wkd/cli/MissingUserIdException.java index 394283c..d3ba9eb 100644 --- a/wkd-java-cli/src/main/java/pgp/wkd/cli/MissingUserIdException.java +++ b/wkd-java-cli/src/main/java/pgp/wkd/cli/MissingUserIdException.java @@ -4,6 +4,10 @@ package pgp.wkd.cli; +/** + * Exception that gets thrown when an OpenPGP certificate is not carrying a User-ID binding for the email address + * that was used to look the certificate up via WKD. + */ public class MissingUserIdException extends Exception { public MissingUserIdException() { diff --git a/wkd-java-cli/src/main/java/pgp/wkd/cli/command/Fetch.java b/wkd-java-cli/src/main/java/pgp/wkd/cli/command/Fetch.java index efd9d3f..265cc11 100644 --- a/wkd-java-cli/src/main/java/pgp/wkd/cli/command/Fetch.java +++ b/wkd-java-cli/src/main/java/pgp/wkd/cli/command/Fetch.java @@ -8,8 +8,8 @@ import org.bouncycastle.bcpg.ArmoredOutputStream; import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.pgpainless.PGPainless; import org.pgpainless.key.info.KeyRingInfo; -import pgp.wkd.IWKDFetcher; -import pgp.wkd.JavaHttpRequestWKDFetcher; +import pgp.wkd.AbstractWKDFetcher; +import pgp.wkd.HttpUrlConnectionWKDFetcher; import pgp.wkd.WKDAddress; import pgp.wkd.WKDAddressHelper; import pgp.wkd.cli.MissingUserIdException; @@ -40,7 +40,7 @@ public class Fetch implements Runnable { ) boolean armor = false; - IWKDFetcher fetcher = new JavaHttpRequestWKDFetcher(); + AbstractWKDFetcher fetcher = new HttpUrlConnectionWKDFetcher(); @Override public void run() { diff --git a/wkd-java/README.md b/wkd-java/README.md index 359a660..00b722a 100644 --- a/wkd-java/README.md +++ b/wkd-java/README.md @@ -6,4 +6,4 @@ SPDX-License-Identifier: Apache-2.0 # WKD-Java -API Implementation of the Web Key Directory Specification for Java. +API Implementation of the Key Discovery part of the Web Key Directory Specification for Java. diff --git a/wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java b/wkd-java/src/main/java/pgp/wkd/AbstractWKDFetcher.java similarity index 51% rename from wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java rename to wkd-java/src/main/java/pgp/wkd/AbstractWKDFetcher.java index 2d9c565..15b828b 100644 --- a/wkd-java/src/main/java/pgp/wkd/JavaHttpRequestWKDFetcher.java +++ b/wkd-java/src/main/java/pgp/wkd/AbstractWKDFetcher.java @@ -4,26 +4,34 @@ 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 { +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; - private static final Logger LOGGER = LoggerFactory.getLogger(JavaHttpRequestWKDFetcher.class); +/** + * Abstract class for fetching OpenPGP certificates from the WKD. + * This class can be extended to fetch files from remote servers using different HTTP clients. + */ +public abstract class AbstractWKDFetcher { - @Override + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractWKDFetcher.class); + + /** + * Attempt to fetch an OpenPGP certificate from the Web Key Directory. + * + * @param address WKDAddress object + * @return input stream containing the certificate in its binary representation + * + * @throws IOException in case of an error + */ public InputStream fetch(WKDAddress address) throws IOException { URI advanced = address.getAdvancedMethodURI(); IOException advancedException; try { - return tryFetchUri(advanced); + return fetchUri(advanced); } catch (IOException e) { advancedException = e; LOGGER.debug("Could not fetch key using advanced method from " + advanced.toString(), advancedException); @@ -31,32 +39,22 @@ public class JavaHttpRequestWKDFetcher implements IWKDFetcher { URI direct = address.getDirectMethodURI(); try { - return tryFetchUri(direct); + return fetchUri(direct); } catch (IOException e) { - // we would like to use addSuppressed eventually, but Android API 10 does no support it + // we would like to use addSuppressed eventually, but Android API 10 does not support it // e.addSuppressed(advancedException); LOGGER.debug("Could not fetch key using direct method from " + direct.toString(), e); throw e; } } - 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("Connecting to '" + uri + "' failed. Status: " + status); - } - return con.getInputStream(); - } - - private HttpURLConnection getConnection(URI uri) throws IOException { - URL url = uri.toURL(); - return (HttpURLConnection) url.openConnection(); - } + /** + * Fetch the contents of the file that the {@link URI} points to from the remote server. + * @param uri uri + * @return file contents + * + * @throws java.net.ConnectException in case the file or host does not exist + * @throws IOException in case of an IO-error + */ + protected abstract InputStream fetchUri(URI uri) throws IOException; } diff --git a/wkd-java/src/main/java/pgp/wkd/HttpUrlConnectionWKDFetcher.java b/wkd-java/src/main/java/pgp/wkd/HttpUrlConnectionWKDFetcher.java new file mode 100644 index 0000000..76db45f --- /dev/null +++ b/wkd-java/src/main/java/pgp/wkd/HttpUrlConnectionWKDFetcher.java @@ -0,0 +1,35 @@ +// 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; + +/** + * Implementation of {@link AbstractWKDFetcher} using Java's {@link HttpURLConnection}. + */ +public class HttpUrlConnectionWKDFetcher extends AbstractWKDFetcher { + + public InputStream fetchUri(URI uri) throws IOException { + URL url = uri.toURL(); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestMethod("GET"); + + con.setConnectTimeout(5000); + con.setReadTimeout(5000); + con.setInstanceFollowRedirects(false); + + int status = con.getResponseCode(); + if (status != 200) { + throw new ConnectException("Connecting to '" + uri + "' failed. Status: " + status); + } + return con.getInputStream(); + } + +} diff --git a/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java b/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java deleted file mode 100644 index 7252f88..0000000 --- a/wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java +++ /dev/null @@ -1,13 +0,0 @@ -// 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/WKDAddress.java b/wkd-java/src/main/java/pgp/wkd/WKDAddress.java index 23c5956..7b2039c 100644 --- a/wkd-java/src/main/java/pgp/wkd/WKDAddress.java +++ b/wkd-java/src/main/java/pgp/wkd/WKDAddress.java @@ -15,7 +15,13 @@ import java.security.NoSuchAlgorithmException; import java.util.regex.Matcher; import java.util.regex.Pattern; -public class WKDAddress { +/** + * Create {@link URI URIs} for discovery of certificates in the OpenPGP Web Key Directory. + * + * @see + * OpenPGP Web Key Directory - §3.1. Key Discovery + */ +public final class WKDAddress { private static final String SCHEME = "https://"; private static final String ADV_SUBDOMAIN = "openpgpkey."; @@ -24,12 +30,18 @@ public class WKDAddress { return "/.well-known/openpgpkey/" + domain + "/hu/"; } - // RegEx for Email Addresses. + // RegExs 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.-]+)$"); + // Validate just the local part + private static final Pattern PATTERN_LOCAL_PART = Pattern.compile("^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$"); + // Validate just the domain part + private static final Pattern PATTERN_DOMAIN_PART = Pattern.compile("[a-zA-Z0-9.-]+$"); + // Android API lvl 10 does not yet know StandardCharsets.UTF_8 :/ private static final Charset utf8 = Charset.forName("UTF8"); + // Z-Base32 encoding is described in https://www.rfc-editor.org/rfc/rfc6189.html#section-5.1.6 private static final ZBase32 zBase32 = new ZBase32(); private final String localPart; @@ -37,52 +49,125 @@ public class WKDAddress { private final String zbase32LocalPart; private final String percentEncodedLocalPart; - public WKDAddress(String localPart, String domainPart) { + /** + * Construct a {@link WKDAddress} from an email address' local part and domain part. + * + * @param localPart local part of the email address, case-sensitive + * @param domainPart domain part of the email address, case-insensitive + */ + private WKDAddress(String localPart, String domainPart) { this.localPart = localPart; this.domainPart = domainPart.toLowerCase(); - this.zbase32LocalPart = zbase32(this.localPart); + this.zbase32LocalPart = sha1AndZBase32Encode(this.localPart); this.percentEncodedLocalPart = percentEncode(this.localPart); } + /** + * Create a new {@link WKDAddress} from an email address' local part and domain part. + * + * @param localPart local part of the email address, case-sensitive + * @param domainPart domain part of the email address, case-insensitive + * + * @return WKD address + */ + public static WKDAddress fromLocalAndDomainPart(String localPart, String domainPart) { + if (!PATTERN_LOCAL_PART.matcher(localPart).matches()) { + throw new IllegalArgumentException("Invalid local part."); + } + if (!PATTERN_DOMAIN_PART.matcher(domainPart).matches()) { + throw new IllegalArgumentException("Invalid domain part."); + } + return new WKDAddress(localPart, domainPart); + } + + /** + * Transform an email address into a {@link WKDAddress} from which lookup {@link URI URIs} can be generated. + * + * @param email email address, case sensitive + * @return WKDAddress object + */ public static WKDAddress fromEmail(String email) { MailAddress mailAddress = parseMailAddress(email); return new WKDAddress(mailAddress.getLocalPart(), mailAddress.getDomainPart()); } + /** + * Get an {@link URI} pointing to the certificate using the direct lookup method. + * The direct method requires that a WKD is available on the same domain as the users mail server. + * + * Example URI (direct format) for email "Joe.Doe@Example.ORG": + *
https://example.org/.well-known/openpgpkey/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe
+ * + * @see + * OpenPGP Web Key Directory: §3.1. Key Discovery - Direct Method + * + * @return URI using the direct lookup method + */ public URI getDirectMethodURI() { return URI.create(SCHEME + domainPart + DIRECT_WELL_KNOWN + zbase32LocalPart + "?l=" + percentEncodedLocalPart); } + /** + * Get an {@link URI} pointing to the certificate using the advanced lookup method. + * The advanced method requires that a WKD is available on a special subdomain "openpgpkey" on the users mail server. + * + * Example URI (advanced format) for email "Joe.Doe@Example.ORG": + *
https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe
+ * + * @see + * OpenPGP Web Key Directory: §3.1. Key Discovery - Advanced Method + * + * @return URI using the advanced lookup method + */ public URI getAdvancedMethodURI() { return URI.create(SCHEME + ADV_SUBDOMAIN + domainPart + ADV_WELL_KNOWN(domainPart) + zbase32LocalPart + "?l=" + percentEncodedLocalPart); } - private String zbase32(String localPart) { - MessageDigest sha1; + /** + * Calculate the SHA-1 hash sum of the lower-case representation of the given string and encode that using Z-Base32. + * + * @param string string + * @return zbase32 encoded sha1 sum of the string + */ + private String sha1AndZBase32Encode(String string) { + String lowerCase = string.toLowerCase(); + byte[] bytes = lowerCase.getBytes(utf8); + + byte[] sha1; try { - sha1 = MessageDigest.getInstance("SHA1"); + MessageDigest digest = MessageDigest.getInstance("SHA1"); + digest.update(bytes); + sha1 = digest.digest(); } 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 base32KeyHandle = zBase32.encodeAsString(digest); + String base32KeyHandle = zBase32.encodeAsString(sha1); return base32KeyHandle; } - private String percentEncode(String localPart) { + /** + * Encode a string using percent / URL encoding. + * @param string string + * @return percent encoded string + */ + private String percentEncode(String string) { try { - return URLEncoder.encode(localPart, "UTF-8"); + return URLEncoder.encode(string, "UTF-8"); } catch (UnsupportedEncodingException e) { // UTF8 is a MUST on JVM implementations throw new AssertionError(e); } } - + /** + * Validate an email address string against the regex {@link #PATTERN_EMAIL} and split it into local and domain part. + * + * @param email email address string + * @return validated and split mail address + */ private static MailAddress parseMailAddress(String email) { Matcher matcher = PATTERN_EMAIL.matcher(email); if (!matcher.matches()) { @@ -94,19 +179,42 @@ public class WKDAddress { return new MailAddress(localPart, domainPart); } + /** + * Mail Address data class. + */ private static class MailAddress { private final String localPart; private final String domainPart; + /** + * Create a MailAddress object. + * For the email address "alice@pgpainless.org", the local part would be "alice", + * while the domain part would be "pgpainless.org". + * + * @param localPart local part + * @param domainPart domain part + */ MailAddress(String localPart, String domainPart) { this.localPart = localPart; this.domainPart = domainPart; } + /** + * Get the local part of the email address (the part before the '@'). + * Example: "pgpainless.org" + * + * @return local part + */ public String getLocalPart() { return localPart; } + /** + * Get the domain part of the email address (the part after the '@'). + * Example: "alice" + * + * @return domain part + */ public String getDomainPart() { return domainPart; } diff --git a/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java b/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java index 0be0bfe..2856347 100644 --- a/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java +++ b/wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java @@ -13,6 +13,24 @@ public class WKDAddressHelper { // we are only interested in "email@address" private static final Pattern PATTERN_USER_ID = Pattern.compile("^.*\\<([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+)\\>.*"); + /** + * Parse an email address from a user-id string. + * The user-id is herein expected to follow the mail name-addr format described in RFC2822. + * + * Example User ID (angle normally not escaped): + * "Slim Shady <sshady@marshall-mathers.lp> [Yes, the real Shady]" + * + * @see + * RFC4880 - OpenPGP Message Format - §5.11 User ID Packet + * @see + * RFC2882 - Internet Message Format - §3.4 Address Specification + * + * @param userId user-id + * @return email address + * + * @throws IllegalArgumentException in case the user-id does not match the expected format + * and does not contain an email address. + */ public static String emailFromUserId(String userId) { Matcher matcher = PATTERN_USER_ID.matcher(userId); if (!matcher.matches()) { @@ -23,6 +41,12 @@ public class WKDAddressHelper { return email; } + /** + * Create a {@link WKDAddress} by extracting an email address from the given user-id. + * + * @param userId user-id + * @return WKD address for the user-id's email address. + */ public static WKDAddress wkdAddressFromUserId(String userId) { String email = emailFromUserId(userId); return WKDAddress.fromEmail(email);