mirror of
https://github.com/pgpainless/pgpainless.git
synced 2025-12-13 07:41:08 +01:00
Implement experimental WKD fetcher
This commit is contained in:
parent
aeb2bb21aa
commit
1207374be1
6 changed files with 193 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
13
wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java
Normal file
13
wkd-java/src/main/java/pgp/wkd/IWKDFetcher.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <a href="https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/">OpenPGP Web Key Directory</a>
|
||||
*/
|
||||
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 <email@address> [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 <email.address> [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 <a href="https://datatracker.ietf.org/doc/html/rfc2822#section-3.4">RFC2822 - Internet Message Format §3.4: Address Specification</a>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java
Normal file
30
wkd-java/src/main/java/pgp/wkd/WKDAddressHelper.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pgp.wkd;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class WKDAddressHelper {
|
||||
|
||||
// Firstname Lastname <email@address> [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 <email.address> [Optional Comment]\"");
|
||||
}
|
||||
|
||||
String email = matcher.group(1);
|
||||
return email;
|
||||
}
|
||||
|
||||
public static WKDAddress wkdAddressFromUserId(String userId) {
|
||||
String email = emailFromUserId(userId);
|
||||
return WKDAddress.fromEmail(email);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,15 +19,18 @@ public class WKDAddressTest {
|
|||
String userId = "Joe Doe <Joe.Doe@Example.ORG> [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 = "<alice@pgpainless.org>";
|
||||
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]",
|
||||
"<John Doe",
|
||||
"Don Joeh>")) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue