Modularize WKD discovery

This commit is contained in:
Paul Schaub 2022-03-10 16:56:46 +01:00
parent 30e8a55ef6
commit d1d953e802
Signed by: vanitasvitae
GPG key ID: 62BEE9264BF17311
24 changed files with 511 additions and 111 deletions

View file

@ -20,7 +20,7 @@ dependencies {
testImplementation 'com.ginsberg:junit5-system-exit:1.1.2'
testImplementation 'org.mockito:mockito-core:4.3.1'
implementation("org.pgpainless:pgpainless-core:$pgpainlessVersion")
implementation("org.pgpainless:pgpainless-cert-d:0.1.0")
implementation project(':wkd-java')
implementation "info.picocli:picocli:4.6.3"

View file

@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd.cli;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.pgpainless.PGPainless;
import org.pgpainless.certificate_store.CertificateFactory;
import org.pgpainless.key.info.KeyRingInfo;
import pgp.certificate_store.Certificate;
import pgp.wkd.CertificateAndUserIds;
import pgp.wkd.CertificateReader;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class CertificateReaderImpl implements CertificateReader {
@Override
public List<CertificateAndUserIds> read(InputStream inputStream) throws IOException {
List<CertificateAndUserIds> certificatesAndUserIds = new ArrayList<>();
try {
PGPPublicKeyRingCollection certificates = PGPainless.readKeyRing().publicKeyRingCollection(inputStream);
for (PGPPublicKeyRing certificate : certificates) {
KeyRingInfo info = PGPainless.inspectKeyRing(certificate);
Certificate parsedCert = CertificateFactory.certificateFromPublicKeyRing(certificate);
List<String> userIds = info.getValidAndExpiredUserIds();
certificatesAndUserIds.add(new CertificateAndUserIds(parsedCert, userIds));
}
return certificatesAndUserIds;
} catch (PGPException e) {
throw new IOException("Cannot parse certificates.", e);
}
}
}

View file

@ -0,0 +1,21 @@
package pgp.wkd.cli;
import pgp.wkd.AbstractDiscover;
import pgp.wkd.CertificateReader;
import pgp.wkd.HttpUrlConnectionWKDFetcher;
import pgp.wkd.WKDFetcher;
public class DiscoverImpl extends AbstractDiscover {
public DiscoverImpl() {
super(new CertificateReaderImpl(), new HttpUrlConnectionWKDFetcher());
}
public DiscoverImpl(WKDFetcher fetcher) {
super(new CertificateReaderImpl(), fetcher);
}
public DiscoverImpl(CertificateReader certificateReader, WKDFetcher fetcher) {
super(certificateReader, fetcher);
}
}

View file

@ -4,6 +4,7 @@
package pgp.wkd.cli;
import pgp.wkd.MissingUserIdException;
import pgp.wkd.cli.command.Fetch;
import picocli.CommandLine;

View file

@ -5,21 +5,21 @@
package pgp.wkd.cli.command;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.pgpainless.PGPainless;
import org.pgpainless.key.info.KeyRingInfo;
import pgp.wkd.AbstractWKDFetcher;
import org.bouncycastle.util.io.Streams;
import pgp.certificate_store.Certificate;
import pgp.wkd.Discover;
import pgp.wkd.HttpUrlConnectionWKDFetcher;
import pgp.wkd.MalformedUserIdException;
import pgp.wkd.WKDAddress;
import pgp.wkd.WKDAddressHelper;
import pgp.wkd.WKDDiscoveryResult;
import pgp.wkd.WKDFetcher;
import pgp.wkd.cli.CertNotFetchableException;
import pgp.wkd.cli.MissingUserIdException;
import pgp.wkd.cli.DiscoverImpl;
import picocli.CommandLine;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
@CommandLine.Command(
name = "fetch",
@ -42,47 +42,44 @@ public class Fetch implements Runnable {
boolean armor = false;
// TODO: Better way to inject fetcher implementation
public static AbstractWKDFetcher fetcher = new HttpUrlConnectionWKDFetcher();
public static WKDFetcher fetcher = new HttpUrlConnectionWKDFetcher();
@Override
public void run() {
String email;
try {
email = WKDAddressHelper.emailFromUserId(userId);
} catch (IllegalArgumentException e) {
email = userId;
Discover discover = new DiscoverImpl(fetcher);
WKDAddress address = addressFromUserId(userId);
WKDDiscoveryResult result = discover.discover(address);
if (!result.isSuccessful()) {
throw new CertNotFetchableException("Cannot fetch cert.");
}
WKDAddress address = WKDAddress.fromEmail(email);
try (InputStream inputStream = fetcher.fetch(address)) {
PGPPublicKeyRing cert = PGPainless.readKeyRing().publicKeyRing(inputStream);
if (cert == null) {
throw new CertNotFetchableException("Fetched data does not contain an OpenPGP certificate.");
}
KeyRingInfo info = PGPainless.inspectKeyRing(cert);
List<String> userIds = info.getValidAndExpiredUserIds();
boolean containsEmail = false;
for (String certUserId : userIds) {
if (certUserId.contains("<" + email + ">") || certUserId.equals(email)) {
containsEmail = true;
break;
}
}
if (!containsEmail) {
throw new MissingUserIdException("Fetched certificate does not contain email address " + email);
}
try {
if (armor) {
OutputStream out = new ArmoredOutputStream(System.out);
cert.encode(out);
for (Certificate certificate : result.getCertificates()) {
Streams.pipeAll(certificate.getInputStream(), out);
}
out.close();
} else {
cert.encode(System.out);
for (Certificate certificate : result.getCertificates()) {
Streams.pipeAll(certificate.getInputStream(), System.out);
}
}
} catch (IOException e) {
throw new CertNotFetchableException("Certificate cannot be fetched.", e);
}
}
private WKDAddress addressFromUserId(String userId) {
String email;
try {
email = WKDAddressHelper.emailFromUserId(userId);
} catch (MalformedUserIdException e) {
email = userId;
}
return WKDAddress.fromEmail(email);
}
}

View file

@ -4,7 +4,9 @@
package pgp.wkd.cli.test_suite;
import pgp.wkd.AbstractWKDFetcher;
import pgp.wkd.DiscoveryMethod;
import pgp.wkd.WKDAddress;
import pgp.wkd.WKDFetcher;
import java.io.File;
import java.io.FileInputStream;
@ -13,7 +15,7 @@ import java.io.InputStream;
import java.net.URI;
import java.nio.file.Path;
public class DirectoryBasedWkdFetcher extends AbstractWKDFetcher {
public class DirectoryBasedWkdFetcher implements WKDFetcher {
// The directory containing the .well-known subdirectory
private final Path rootPath;
@ -23,7 +25,8 @@ public class DirectoryBasedWkdFetcher extends AbstractWKDFetcher {
}
@Override
protected InputStream fetchUri(URI uri) throws IOException {
public InputStream fetch(WKDAddress address, DiscoveryMethod method) throws IOException {
URI uri = address.getUri(method);
String path = uri.getPath();
File file = rootPath.resolve(path.substring(1)).toFile(); // get rid of leading slash at start of path
FileInputStream fileIn = new FileInputStream(file);

View file

@ -19,6 +19,7 @@ dependencies {
// Logging
api "org.slf4j:slf4j-api:$slf4jVersion"
testImplementation "ch.qos.logback:logback-classic:$logbackVersion"
api "org.pgpainless:pgp-certificate-store:0.1.0"
// Z-Base32
implementation 'com.sandinh:zbase32-commons-codec:1.0.0'

View file

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import pgp.certificate_store.Certificate;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class AbstractDiscover implements Discover {
protected final CertificateReader reader;
protected final WKDFetcher fetcher;
public AbstractDiscover(CertificateReader reader, WKDFetcher fetcher) {
this.reader = reader;
this.fetcher = fetcher;
}
@Override
public WKDDiscoveryItem discover(DiscoveryMethod method, WKDAddress address) {
try {
InputStream inputStream = fetcher.fetch(address, method);
List<CertificateAndUserIds> fetchedCertificates = reader.read(inputStream);
List<RejectedCertificate> rejectedCertificates = new ArrayList<>();
List<Certificate> acceptableCertificates = new ArrayList<>();
String email = address.getEmail();
for (CertificateAndUserIds certAndUserIds : fetchedCertificates) {
Certificate certificate = certAndUserIds.getCertificate();
boolean containsEmail = false;
for (String userId : certAndUserIds.getUserIds()) {
if (userId.contains("<" + email + ">") || userId.equals(email)) {
containsEmail = true;
break;
}
}
if (!containsEmail) {
rejectedCertificates.add(new RejectedCertificate(certificate,
new MissingUserIdException("Certificate " + certificate.getFingerprint() +
" does not contain user-id with email '" + email + "'")));
} else {
acceptableCertificates.add(certificate);
}
}
return WKDDiscoveryItem.success(method, address, acceptableCertificates, rejectedCertificates);
} catch (IOException e) {
return WKDDiscoveryItem.failure(method, address, e);
}
}
}

View file

@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
public abstract class AbstractUriWKDFetcher implements WKDFetcher {
private static final Logger LOGGER = LoggerFactory.getLogger(WKDFetcher.class);
@Override
public InputStream fetch(WKDAddress address, DiscoveryMethod method) throws IOException {
URI uri = address.getUri(method);
try {
return fetchUri(uri);
} catch (IOException e) {
LOGGER.debug("Could not fetch key using " + method + " method from " + uri.toString(), e);
throw e;
}
}
/**
* 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;
}

View file

@ -1,60 +0,0 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
/**
* 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 {
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 fetchUri(advanced);
} catch (IOException e) {
advancedException = e;
LOGGER.debug("Could not fetch key using advanced method from " + advanced.toString(), advancedException);
}
URI direct = address.getDirectMethodURI();
try {
return fetchUri(direct);
} catch (IOException e) {
// 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;
}
}
/**
* 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;
}

View file

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import pgp.certificate_store.Certificate;
import java.util.ArrayList;
import java.util.List;
public class CertificateAndUserIds {
private final Certificate certificate;
private final List<String> userIds;
public CertificateAndUserIds(Certificate certificate, List<String> userIds) {
this.certificate = certificate;
this.userIds = userIds;
}
public List<String> getUserIds() {
return new ArrayList<>(userIds);
}
public Certificate getCertificate() {
return certificate;
}
}

View file

@ -0,0 +1,14 @@
// 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.util.List;
public interface CertificateReader {
List<CertificateAndUserIds> read(InputStream inputStream) throws IOException;
}

View file

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import java.util.ArrayList;
import java.util.List;
public interface Discover {
WKDDiscoveryItem discover(DiscoveryMethod method, WKDAddress address);
default WKDDiscoveryResult discover(WKDAddress address) {
List<WKDDiscoveryItem> results = new ArrayList<>();
// advanced method
WKDDiscoveryItem advanced = discover(DiscoveryMethod.advanced, address);
results.add(advanced);
if (advanced.isSuccessful()) {
return new WKDDiscoveryResult(results);
}
// direct method
results.add(discover(DiscoveryMethod.direct, address));
return new WKDDiscoveryResult(results);
}
default WKDDiscoveryResult discoverByEmail(String email) throws MalformedUserIdException {
return discover(WKDAddress.fromEmail(email));
}
default WKDDiscoveryResult discoverByUserId(String userId) throws MalformedUserIdException {
return discover(WKDAddressHelper.wkdAddressFromUserId(userId));
}
}

View file

@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
public enum DiscoveryMethod {
advanced,
direct,
;
}

View file

@ -12,9 +12,9 @@ import java.net.URI;
import java.net.URL;
/**
* Implementation of {@link AbstractWKDFetcher} using Java's {@link HttpURLConnection}.
* Implementation of {@link WKDFetcher} using Java's {@link HttpURLConnection}.
*/
public class HttpUrlConnectionWKDFetcher extends AbstractWKDFetcher {
public class HttpUrlConnectionWKDFetcher extends AbstractUriWKDFetcher {
public InputStream fetchUri(URI uri) throws IOException {
URL url = uri.toURL();

View file

@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
public class MalformedUserIdException extends RuntimeException {
public MalformedUserIdException(String message) {
super(message);
}
}

View file

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd.cli;
package pgp.wkd;
/**
* Exception that gets thrown when an OpenPGP certificate is not carrying a User-ID binding for the email address

View file

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import pgp.certificate_store.Certificate;
public class RejectedCertificate {
private final Certificate certificate;
private final Throwable failure;
public RejectedCertificate(Certificate certificate, Throwable failure) {
this.certificate = certificate;
this.failure = failure;
}
public Certificate getCertificate() {
return certificate;
}
public Throwable getFailure() {
return failure;
}
}

View file

@ -87,11 +87,24 @@ public final class WKDAddress {
* @param email email address, case sensitive
* @return WKDAddress object
*/
public static WKDAddress fromEmail(String email) {
public static WKDAddress fromEmail(String email) throws MalformedUserIdException {
MailAddress mailAddress = parseMailAddress(email);
return new WKDAddress(mailAddress.getLocalPart(), mailAddress.getDomainPart());
}
public URI getUri(DiscoveryMethod method) {
if (method == DiscoveryMethod.advanced) {
return getAdvancedMethodURI();
} else if (method == DiscoveryMethod.direct) {
return getDirectMethodURI();
}
throw new IllegalArgumentException("Invalid discovery method.");
}
public String getEmail() {
return localPart + '@' + domainPart;
}
/**
* 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.
@ -168,10 +181,10 @@ public final class WKDAddress {
* @param email email address string
* @return validated and split mail address
*/
private static MailAddress parseMailAddress(String email) {
private static MailAddress parseMailAddress(String email) throws MalformedUserIdException {
Matcher matcher = PATTERN_EMAIL.matcher(email);
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid email address.");
throw new MalformedUserIdException("Invalid email address.");
}
String localPart = matcher.group(1);

View file

@ -31,10 +31,10 @@ public class WKDAddressHelper {
* @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) {
public static String emailFromUserId(String userId) throws MalformedUserIdException {
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]\"");
throw new MalformedUserIdException("User-ID does not follow excepted pattern \"Firstname Lastname <email.address> [Optional Comment]\"");
}
String email = matcher.group(1);
@ -47,7 +47,7 @@ public class WKDAddressHelper {
* @param userId user-id
* @return WKD address for the user-id's email address.
*/
public static WKDAddress wkdAddressFromUserId(String userId) {
public static WKDAddress wkdAddressFromUserId(String userId) throws MalformedUserIdException {
String email = emailFromUserId(userId);
return WKDAddress.fromEmail(email);
}

View file

@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import pgp.certificate_store.Certificate;
import java.util.List;
public final class WKDDiscoveryItem {
private final DiscoveryMethod method;
private final WKDAddress address;
private final List<Certificate> certificates;
private final List<RejectedCertificate> rejectedCertificates;
private final Throwable failure;
/**
* Constructor for a {@link WKDDiscoveryItem} object.
* @param method discovery method
* @param address wkd address used for discovery
* @param certificates list of successfully fetched certificates
* @param rejectedCertificates list of invalid fetched certificates (e.g. missing user-id)
* @param failure general fetching error (e.g. connection error, 404...)
*/
private WKDDiscoveryItem(
DiscoveryMethod method,
WKDAddress address,
List<Certificate> certificates,
List<RejectedCertificate> rejectedCertificates,
Throwable failure) {
this.method = method;
this.address = address;
this.certificates = certificates;
this.rejectedCertificates = rejectedCertificates;
this.failure = failure;
}
public static WKDDiscoveryItem success(DiscoveryMethod method, WKDAddress address, List<Certificate> certificates, List<RejectedCertificate> rejectedCertificates) {
return new WKDDiscoveryItem(method, address, certificates, rejectedCertificates, null);
}
public static WKDDiscoveryItem failure(DiscoveryMethod method, WKDAddress address, Throwable failure) {
return new WKDDiscoveryItem(method, address, null, null, failure);
}
public DiscoveryMethod getMethod() {
return method;
}
public WKDAddress getAddress() {
return address;
}
public boolean isSuccessful() {
return !hasFailure();
}
public List<Certificate> getCertificates() {
return certificates;
}
public List<RejectedCertificate> getRejectedCertificates() {
return rejectedCertificates;
}
public Throwable getFailure() {
return failure;
}
public boolean hasCertificates() {
return certificates != null && !certificates.isEmpty();
}
public boolean hasFailure() {
return failure != null;
}
}

View file

@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package pgp.wkd;
import pgp.certificate_store.Certificate;
import java.util.ArrayList;
import java.util.List;
public class WKDDiscoveryResult {
private List<WKDDiscoveryItem> items;
public WKDDiscoveryResult(List<WKDDiscoveryItem> items) {
this.items = items;
}
public List<Certificate> getCertificates() {
List<Certificate> certificates = new ArrayList<>();
for (WKDDiscoveryItem item : items) {
if (item.isSuccessful()) {
certificates.addAll(item.getCertificates());
}
}
return certificates;
}
public boolean isSuccessful() {
for (WKDDiscoveryItem item : items) {
if (item.isSuccessful() && item.hasCertificates()) {
return true;
}
}
return false;
}
public List<WKDDiscoveryItem> getItems() {
return items;
}
public List<WKDDiscoveryItem> getFailedItems() {
List<WKDDiscoveryItem> fails = new ArrayList<>();
for (WKDDiscoveryItem item : items) {
if (!item.isSuccessful()) {
fails.add(item);
}
}
return fails;
}
}

View file

@ -0,0 +1,25 @@
// 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;
/**
* 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 interface WKDFetcher {
/**
* 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
*/
InputStream fetch(WKDAddress address, DiscoveryMethod method) throws IOException;
}

View file

@ -66,14 +66,14 @@ public class WKDAddressTest {
"John Doe [The Real One]",
"<John Doe",
"Don Joeh>")) {
assertThrows(IllegalArgumentException.class, () -> WKDAddressHelper.wkdAddressFromUserId(brokenUserId));
assertThrows(MalformedUserIdException.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.fromEmail(brokenEmail));
assertThrows(MalformedUserIdException.class, () -> WKDAddress.fromEmail(brokenEmail));
}
}
}