Add ability to search for keys using WKD protocol
If a search pattern that looks like an email address is found an additional query using Web Key Directory will be performed. Implements basic flow described in "Key Discovery" [0] I-D. Querying SRV records is not supported. Fixes partially #2270. [0]: https://tools.ietf.org/html/draft-koch-openpgp-webkey-service-05#section-3.1
This commit is contained in:
@@ -51,6 +51,9 @@ public class CloudSearch {
|
|||||||
if (cloudPrefs.searchFacebook) {
|
if (cloudPrefs.searchFacebook) {
|
||||||
servers.add(FacebookKeyserverClient.getInstance());
|
servers.add(FacebookKeyserverClient.getInstance());
|
||||||
}
|
}
|
||||||
|
if (cloudPrefs.searchWebKeyDirectory) {
|
||||||
|
servers.add(WebKeyDirectoryClient.getInstance());
|
||||||
|
}
|
||||||
|
|
||||||
int numberOfServers = servers.size();
|
int numberOfServers = servers.size();
|
||||||
final ImportKeysList results = new ImportKeysList(numberOfServers);
|
final ImportKeysList results = new ImportKeysList(numberOfServers);
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2017 Schürmann & Breitmoser GbR
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.sufficientlysecure.keychain.keyimport;
|
||||||
|
|
||||||
|
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.sufficientlysecure.keychain.network.OkHttpClientFactory;
|
||||||
|
import org.sufficientlysecure.keychain.pgp.UncachedKeyRing;
|
||||||
|
import org.sufficientlysecure.keychain.pgp.exception.PgpGeneralException;
|
||||||
|
import org.sufficientlysecure.keychain.util.ParcelableProxy;
|
||||||
|
import org.sufficientlysecure.keychain.util.ZBase32;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import timber.log.Timber;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for keys using Web Key Directory protocol.
|
||||||
|
*
|
||||||
|
* @see <a href="https://tools.ietf.org/html/draft-koch-openpgp-webkey-service-05#section-3.1">Key Discovery</a>
|
||||||
|
*/
|
||||||
|
public class WebKeyDirectoryClient implements KeyserverClient {
|
||||||
|
|
||||||
|
public static WebKeyDirectoryClient getInstance() {
|
||||||
|
return new WebKeyDirectoryClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebKeyDirectoryClient() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern EMAIL_PATTERN = Pattern.compile("^\\s*(.+)@(.+)\\s*$");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ImportKeysListEntry> search(String name, ParcelableProxy proxy)
|
||||||
|
throws QueryFailedException {
|
||||||
|
URL webKeyDirectoryURL = toWebKeyDirectoryURL(name);
|
||||||
|
|
||||||
|
if (webKeyDirectoryURL == null) {
|
||||||
|
Timber.d("Name not supported by Web Key Directory Client: " + name);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Web Key Directory import: " + name + " using Proxy: " + proxy.getProxy());
|
||||||
|
|
||||||
|
byte[] data = query(webKeyDirectoryURL, proxy.getProxy());
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
Timber.d("No Web Key Directory endpoint for: " + name);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we're here that means key retrieval succeeded,
|
||||||
|
// would have thrown an exception otherwise
|
||||||
|
try {
|
||||||
|
UncachedKeyRing ring = UncachedKeyRing.decodeFromData(data);
|
||||||
|
return Collections.singletonList(new ImportKeysListEntry(null, ring));
|
||||||
|
} catch (PgpGeneralException | IOException e) {
|
||||||
|
Timber.e(e, "Failed parsing key from Web Key Directory during search");
|
||||||
|
throw new QueryFailedException("No valid key found on Web Key Directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String get(String name, ParcelableProxy proxy) {
|
||||||
|
throw new UnsupportedOperationException("Returning armored key from Web Key Directory not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private byte[] query(URL url, Proxy proxy) throws QueryFailedException {
|
||||||
|
try {
|
||||||
|
Timber.d("fetching from Web Key Directory with: %s proxy: %s", url, proxy);
|
||||||
|
|
||||||
|
Request request = new Request.Builder().url(url).build();
|
||||||
|
|
||||||
|
OkHttpClient client = OkHttpClientFactory.getClientPinnedIfAvailable(url, proxy);
|
||||||
|
Response response = client.newCall(request).execute();
|
||||||
|
|
||||||
|
if (response.isSuccessful()) {
|
||||||
|
return response.body().bytes();
|
||||||
|
} else if (response.code() == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new QueryFailedException("Error while fetching key from Web Key Directory. " +
|
||||||
|
"Response:" + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
Timber.e(e, "Unknown host at Web Key Directory key download");
|
||||||
|
return null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Timber.e(e, "IOException at Web Key Directory key download");
|
||||||
|
throw new QueryFailedException("Cannot connect to Web Key Directory. "
|
||||||
|
+ "Check your Internet connection!"
|
||||||
|
+ (proxy == Proxy.NO_PROXY ? "" : " Using proxy " + proxy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void add(String armoredKey, ParcelableProxy proxy) {
|
||||||
|
throw new UnsupportedOperationException("Uploading keys to Web Key Directory is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static URL toWebKeyDirectoryURL(String name) {
|
||||||
|
Matcher matcher = EMAIL_PATTERN.matcher(name);
|
||||||
|
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String localPart = matcher.group(1);
|
||||||
|
String encodedPart = ZBase32.encode(toSHA1(localPart.toLowerCase().getBytes()));
|
||||||
|
String domain = matcher.group(2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL("https://" + domain + "/.well-known/openpgpkey/hu/" + encodedPart);
|
||||||
|
} catch (MalformedURLException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] toSHA1(byte[] input) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-1").digest(input);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new AssertionError("SHA-1 should always be available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,8 +83,10 @@ public class ImportKeysListCloudLoader
|
|||||||
|
|
||||||
// Now we have all the data needed to build the parcelable key ring for this key
|
// Now we have all the data needed to build the parcelable key ring for this key
|
||||||
for (ImportKeysListEntry e : mEntryList) {
|
for (ImportKeysListEntry e : mEntryList) {
|
||||||
e.setParcelableKeyRing(ParcelableKeyRing.createFromReference(e.getFingerprint(), e.getKeyIdHex(),
|
if (e.getParcelableKeyRing() == null) {
|
||||||
e.getKeybaseName(), e.getFbUsername()));
|
e.setParcelableKeyRing(ParcelableKeyRing.createFromReference(e.getFingerprint(), e.getKeyIdHex(),
|
||||||
|
e.getKeybaseName(), e.getFbUsername()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mEntryListWrapper;
|
return mEntryListWrapper;
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ public class ImportKeysActivity extends BaseActivity implements ImportKeysListen
|
|||||||
String fbUsername = FacebookKeyserverClient.getUsernameFromUri(dataUri);
|
String fbUsername = FacebookKeyserverClient.getUsernameFromUri(dataUri);
|
||||||
|
|
||||||
Preferences.CloudSearchPrefs cloudSearchPrefs =
|
Preferences.CloudSearchPrefs cloudSearchPrefs =
|
||||||
new Preferences.CloudSearchPrefs(false, true, true, null);
|
new Preferences.CloudSearchPrefs(false, true, true, false, null);
|
||||||
// search immediately
|
// search immediately
|
||||||
startListFragment(null, null, fbUsername, cloudSearchPrefs);
|
startListFragment(null, null, fbUsername, cloudSearchPrefs);
|
||||||
break;
|
break;
|
||||||
@@ -213,7 +213,7 @@ public class ImportKeysActivity extends BaseActivity implements ImportKeysListen
|
|||||||
HkpKeyserverAddress keyserver = HkpKeyserverAddress.createFromUri(
|
HkpKeyserverAddress keyserver = HkpKeyserverAddress.createFromUri(
|
||||||
dataUri.getScheme() + "://" + dataUri.getAuthority());
|
dataUri.getScheme() + "://" + dataUri.getAuthority());
|
||||||
Preferences.CloudSearchPrefs cloudSearchPrefs = new Preferences.CloudSearchPrefs(
|
Preferences.CloudSearchPrefs cloudSearchPrefs = new Preferences.CloudSearchPrefs(
|
||||||
true, false, false, keyserver);
|
true, false, false, false, keyserver);
|
||||||
Timber.d("Using keyserver: " + keyserver);
|
Timber.d("Using keyserver: " + keyserver);
|
||||||
|
|
||||||
// process URL to get operation and query
|
// process URL to get operation and query
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ public class Preferences {
|
|||||||
return new CloudSearchPrefs(mSharedPreferences.getBoolean(Pref.SEARCH_KEYSERVER, true),
|
return new CloudSearchPrefs(mSharedPreferences.getBoolean(Pref.SEARCH_KEYSERVER, true),
|
||||||
mSharedPreferences.getBoolean(Pref.SEARCH_KEYBASE, true),
|
mSharedPreferences.getBoolean(Pref.SEARCH_KEYBASE, true),
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
getPreferredKeyserver());
|
getPreferredKeyserver());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +366,7 @@ public class Preferences {
|
|||||||
public final boolean searchKeyserver;
|
public final boolean searchKeyserver;
|
||||||
public final boolean searchKeybase;
|
public final boolean searchKeybase;
|
||||||
public final boolean searchFacebook;
|
public final boolean searchFacebook;
|
||||||
|
public final boolean searchWebKeyDirectory;
|
||||||
public final HkpKeyserverAddress keyserver;
|
public final HkpKeyserverAddress keyserver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,10 +375,12 @@ public class Preferences {
|
|||||||
* @param keyserver the keyserver url authority to search on
|
* @param keyserver the keyserver url authority to search on
|
||||||
*/
|
*/
|
||||||
public CloudSearchPrefs(boolean searchKeyserver, boolean searchKeybase,
|
public CloudSearchPrefs(boolean searchKeyserver, boolean searchKeybase,
|
||||||
boolean searchFacebook, HkpKeyserverAddress keyserver) {
|
boolean searchFacebook, boolean searchWebKeyDirectory,
|
||||||
|
HkpKeyserverAddress keyserver) {
|
||||||
this.searchKeyserver = searchKeyserver;
|
this.searchKeyserver = searchKeyserver;
|
||||||
this.searchKeybase = searchKeybase;
|
this.searchKeybase = searchKeybase;
|
||||||
this.searchFacebook = searchFacebook;
|
this.searchFacebook = searchFacebook;
|
||||||
|
this.searchWebKeyDirectory = searchWebKeyDirectory;
|
||||||
this.keyserver = keyserver;
|
this.keyserver = keyserver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +388,7 @@ public class Preferences {
|
|||||||
searchKeyserver = in.readByte() != 0x00;
|
searchKeyserver = in.readByte() != 0x00;
|
||||||
searchKeybase = in.readByte() != 0x00;
|
searchKeybase = in.readByte() != 0x00;
|
||||||
searchFacebook = in.readByte() != 0x00;
|
searchFacebook = in.readByte() != 0x00;
|
||||||
|
searchWebKeyDirectory = in.readByte() != 0x00;
|
||||||
keyserver = in.readParcelable(HkpKeyserverAddress.class.getClassLoader());
|
keyserver = in.readParcelable(HkpKeyserverAddress.class.getClassLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +402,7 @@ public class Preferences {
|
|||||||
dest.writeByte((byte) (searchKeyserver ? 0x01 : 0x00));
|
dest.writeByte((byte) (searchKeyserver ? 0x01 : 0x00));
|
||||||
dest.writeByte((byte) (searchKeybase ? 0x01 : 0x00));
|
dest.writeByte((byte) (searchKeybase ? 0x01 : 0x00));
|
||||||
dest.writeByte((byte) (searchFacebook ? 0x01 : 0x00));
|
dest.writeByte((byte) (searchFacebook ? 0x01 : 0x00));
|
||||||
|
dest.writeByte((byte) (searchWebKeyDirectory ? 0x01 : 0x00));
|
||||||
dest.writeParcelable(keyserver, flags);
|
dest.writeParcelable(keyserver, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.sufficientlysecure.keychain.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for handling ZBase-32 encoding.
|
||||||
|
*
|
||||||
|
* @see <a href="https://tools.ietf.org/html/rfc6189#section-5.1.6">Z-Base32 encoding as used in RFC 6189</a>
|
||||||
|
*/
|
||||||
|
public final class ZBase32 {
|
||||||
|
|
||||||
|
private static final char[] ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769".toCharArray();
|
||||||
|
private static final int SHIFT = Integer.numberOfTrailingZeros(ALPHABET.length);
|
||||||
|
private static final int MASK = ALPHABET.length - 1;
|
||||||
|
|
||||||
|
public static String encode(byte[] data) {
|
||||||
|
if (data.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
int buffer = data[0];
|
||||||
|
int index = 1;
|
||||||
|
int bitsLeft = 8;
|
||||||
|
while (bitsLeft > 0 || index < data.length) {
|
||||||
|
if (bitsLeft < SHIFT) {
|
||||||
|
if (index < data.length) {
|
||||||
|
buffer <<= 8;
|
||||||
|
buffer |= (data[index++] & 0xff);
|
||||||
|
bitsLeft += 8;
|
||||||
|
} else {
|
||||||
|
int pad = SHIFT - bitsLeft;
|
||||||
|
buffer <<= pad;
|
||||||
|
bitsLeft += pad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitsLeft -= SHIFT;
|
||||||
|
result.append(ALPHABET[MASK & (buffer >> bitsLeft)]);
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ZBase32() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.sufficientlysecure.keychain.util;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
public class ZBase32Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void encode() {
|
||||||
|
assertEquals("yyyoryar", ZBase32.encode(new byte[]{0, 1, 2, 3, 4}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user