diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt index 8268e6ca2b..483363b944 100644 --- a/Documentation/rest-api-accounts.txt +++ b/Documentation/rest-api-accounts.txt @@ -684,6 +684,119 @@ Deletes an SSH key of a user. HTTP/1.1 204 No Content ---- +[[list-gpg-keys]] +=== List GPG Keys +-- +'GET /accounts/link:#account-id[\{account-id\}]/gpgkeys' +-- + +Returns the GPG keys of an account. + +.Request +---- + GET /accounts/self/gpgkeys HTTP/1.0 +---- + +As a response, the GPG keys of the account are returned as a map of +link:#gpg-key-info[GpgKeyInfo] entities, keyed by ID. + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "AFC8A49B": { + "fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B", + "user_ids": [ + "John Doe \u003cjohn.doe@example.com\u003e" + ], + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0" + } + } +---- + +[[get-gpg-key]] +=== Get GPG Key +-- +'GET /accounts/link:#account-id[\{account-id\}]/gpgkeys/link:#gpg-key-id[\{gpg-key-id\}]' +-- + +Retrieves a GPG key of a user. + +.Request +---- + GET /accounts/self/gpgkeys/AFC8A49B HTTP/1.0 +---- + +As a response, a link:#gpg-key-info[GpgKeyInfo] entity is returned that +describes the GPG key. + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "id": "AFC8A49B", + "fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B", + "user_ids": [ + "John Doe \u003cjohn.doe@example.com\u003e" + ], + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0" + } +---- + +[[add-gpg-keys]] +=== Add GPG Keys +-- +'POST /accounts/link:#account-id[\{account-id\}]/gpgkeys' +-- + +Add one or more GPG keys for a user. + +The new keys must be provided in the request body as a +link:#gpg-key-input[GpgKeyInput] entity. Each GPG key is provided in +ASCII armored format, and must contain a self-signed certification +matching a registered email or other identity of the user. + +.Request +---- + POST /accounts/link:#account-id[\{account-id\}]/gpgkeys + Content-Type: application/json + + { + "add": [ + "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v1\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0yZO5AQ0E\nVdSk1wEIALUycrH2HK9zQYdR/KJo1yJJuaextLWsYYn881yDQo/p06U5vXOZ28lG\nAq/Xs96woVZPbgME6FyQzhf20Z2sbr+5bNo3OcEKaKX3Eo/sWwSJ7bXbGLDxMf4S\netfY1WDC+4rTqE30JuC++nQviPRdCcZf0AEgM6TxVhYEMVYwV787YO1IH62EBICM\nSkIONOfnusNZ4Skgjq9OzakOOpROZ4tki5cH/5oSDgdcaGPy1CFDpL9fG6er2zzk\nsw3qCbraqZrrlgpinWcAduiao67U/dV18O6OjYzrt33fTKZ0+bXhk1h1gloC21MQ\nya0CXlnfR/FOQhvuK0RlbR3cMfhZQscAEQEAAYkBHwQYAQIACQUCVdSk1wIbDAAK\nCRCTUJ5Lr8ikm8+QB/4uE+AlvFQFh9W8koPdfk7CJF7wdgZZ2NDtktvLL71WuMK8\nPOmf9f5JtcLCX4iJxGzcWogAR5ed20NgUoHUg7jn9Xm3fvP+kiqL6WqPhjazd89h\nk06v9hPE65kp4wb0fQqDrtWfP1lFGuh77rQgISt3Y4QutDl49vXS183JAfGPxFxx\n8FgGcfNwL2LVObvqCA0WLqeIrQVbniBPFGocE3yA/0W9BB/xtolpKfgMMsqGRMeu\n9oIsNxB2oE61OsqjUtGsnKQi8k5CZbhJaql4S89vwS+efK0R+mo+0N55b0XxRlCS\nfaURgAcjarQzJnG0hUps2GNO/+nM7UyyJAGfHlh5\n=EdXO\n-----END PGP PUBLIC KEY BLOCK-----\n" + ] + }' +---- + +As a response, the added GPG keys are returned as a map of +link:#gpg-key-info[GpgKeyInfo] entities, keyed by ID. + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "AFC8A49B": { + "fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B", + "user_ids": [ + "John Doe \u003cjohn.doe@example.com\u003e" + ], + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0" + } + } +---- + [[list-account-capabilities]] === List Account Capabilities -- @@ -1292,6 +1405,12 @@ The user name. === \{ssh-key-id\} The sequence number of the SSH key. +[[gpg-key-id]] +=== \{gpg-key-id\} +A GPG key identifier, either the 8-character hex key reported by +`gpg --list-keys`, or the 40-character hex fingerprint (whitespace is +ignored) reported by `gpg --list-keys --with-fingerprint`. + [[json-entities]] == JSON Entities @@ -1578,6 +1697,31 @@ Only Gerrit administrators are allowed to add email addresses without confirmation. |============================== +[[gpg-key-info]] +=== GpgKeyInfo +The `GpgKeyInfo` entity contains information about a GPG public key. + +[options="header",cols="1,^1,5"] +|======================== +|Field Name ||Description +|`id` |Not set in map context|The 8-char hex GPG key ID. +|`fingerprint`||The 40-char (plus spaces) hex GPG key fingerprint. +|`user_ids` || +link:https://tools.ietf.org/html/rfc4880#section-5.11[OpenPGP User IDs] +associated with the public key. +|`key` ||ASCII armored public key material. +|======================== + +[[gpg-key-input]] +=== GpgKeyInput +The `GpgKeyInput` entity contains information for adding GPG keys. + +[options="header",cols="1,6"] +|======================== +|Field Name|Description +|`add` |List of ASCII armored public key strings to add. +|======================== + [[http-password-input]] === HttpPasswordInput The `HttpPasswordInput` entity contains information for setting/generating diff --git a/gerrit-acceptance-tests/BUCK b/gerrit-acceptance-tests/BUCK index 219575cf58..0888216ed0 100644 --- a/gerrit-acceptance-tests/BUCK +++ b/gerrit-acceptance-tests/BUCK @@ -27,16 +27,18 @@ java_library( '//lib:truth', '//lib/auto:auto-value', - '//lib/httpcomponents:fluent-hc', - '//lib/httpcomponents:httpclient', - '//lib/httpcomponents:httpcore', - '//lib/log:impl_log4j', - '//lib/log:log4j', + '//lib/bouncycastle:bcpg', + '//lib/bouncycastle:bcprov', '//lib/guice:guice', '//lib/guice:guice-assistedinject', '//lib/guice:guice-servlet', + '//lib/httpcomponents:fluent-hc', + '//lib/httpcomponents:httpclient', + '//lib/httpcomponents:httpcore', '//lib/jgit:jgit', '//lib/jgit:junit', + '//lib/log:impl_log4j', + '//lib/log:log4j', '//lib/mina:sshd', ], visibility = [ diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java index 12e26b47c7..18ee2ef8e8 100644 --- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java +++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java @@ -43,6 +43,7 @@ import com.google.gerrit.server.AnonymousUser; import com.google.gerrit.server.GerritPersonIdent; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.OutputFormat; +import com.google.gerrit.server.account.AccountCache; import com.google.gerrit.server.account.GroupCache; import com.google.gerrit.server.config.AllProjectsName; import com.google.gerrit.server.config.CanonicalWebUrl; @@ -113,6 +114,9 @@ public abstract class AbstractDaemonTest { @Inject protected AcceptanceTestRequestScope atrScope; + @Inject + protected AccountCache accountCache; + @Inject private IdentifiedUser.GenericFactory identifiedUserFactory; @@ -238,6 +242,11 @@ public abstract class AbstractDaemonTest { toClose = Collections.synchronizedList(new ArrayList()); admin = accounts.admin(); user = accounts.user(); + + // Evict cached user state in case tests modify it. + accountCache.evict(admin.getId()); + accountCache.evict(user.getId()); + adminSession = new RestSession(server, admin); userSession = new RestSession(server, user); initSsh(admin); diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java index 0ecbc7e266..6d11bb22b9 100644 --- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java +++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java @@ -14,20 +14,68 @@ package com.google.gerrit.acceptance.api.accounts; +import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.server.git.gpg.PublicKeyStore.fingerprintToString; +import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString; +import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.collect.ImmutableList; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.extensions.api.accounts.EmailInput; import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.common.GpgKeyInfo; import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.reviewdb.client.AccountExternalId; +import com.google.gerrit.server.git.gpg.PublicKeyStore; +import com.google.gerrit.server.git.gpg.TestKey; +import com.google.inject.Inject; +import com.google.inject.Provider; +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.eclipse.jgit.transport.PushCertificateIdent; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.util.List; +import java.util.Map; public class AccountIT extends AbstractDaemonTest { + @Inject + private Provider publicKeyStoreProvider; + + private List savedExternalIds; + + @Before + public void saveExternalIds() throws Exception { + savedExternalIds = new ArrayList<>(); + savedExternalIds.addAll(getExternalIds(admin)); + savedExternalIds.addAll(getExternalIds(user)); + } + + @After + public void restoreExternalIds() throws Exception { + db.accountExternalIds().delete(getExternalIds(admin)); + db.accountExternalIds().delete(getExternalIds(user)); + db.accountExternalIds().insert(savedExternalIds); + } + + private List getExternalIds(TestAccount account) + throws Exception { + return db.accountExternalIds().byAccount(account.getId()).toList(); + } + @Test public void get() throws Exception { AccountInfo info = gApi @@ -103,4 +151,140 @@ public class AccountIT extends AbstractDaemonTest { exception.expectMessage("invalid email address"); gApi.accounts().self().addEmail(input); } + + @Test + public void addGpgKey() throws Exception { + TestKey key = TestKey.key1(); + String id = keyIdToString(key.getKeyId()); + addExternalIdEmail(admin, "test1@example.com"); + + GpgKeyInfo info = gApi.accounts().self() + .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored())) + .get(id); + info.id = id; + assertKeyEquals(key, info); + assertKeyEquals(key, gApi.accounts().self().gpgKey(id).get()); + + PGPPublicKey stored = getOnlyKeyFromStore(key); + assertThat(stored.getFingerprint()) + .isEqualTo(key.getPublicKey().getFingerprint()); + + setApiUser(user); + exception.expect(ResourceNotFoundException.class); + exception.expectMessage(id); + gApi.accounts().self().gpgKey(id).get(); + } + + @Test + public void reAddExistingGpgKey() throws Exception { + addExternalIdEmail(admin, "test5@example.com"); + TestKey key = TestKey.key5(); + String id = keyIdToString(key.getKeyId()); + PGPPublicKey pk = key.getPublicKey(); + + GpgKeyInfo info = gApi.accounts().self() + .putGpgKeys(ImmutableList.of(armor(pk))) + .get(id); + assertThat(info.userIds).hasSize(2); + assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs()); + + pk = PGPPublicKey.removeCertification(pk, "foo:myId"); + info = gApi.accounts().self() + .putGpgKeys(ImmutableList.of(armor(pk))) + .get(id); + assertThat(info.userIds).hasSize(1); + assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs()); + } + + @Test + public void addOtherUsersGpgKey_Conflict() throws Exception { + // Both users have a matching external ID for this key. + addExternalIdEmail(admin, "test5@example.com"); + AccountExternalId extId = new AccountExternalId( + user.getId(), new AccountExternalId.Key("foo:myId")); + + db.accountExternalIds().insert(Collections.singleton(extId)); + + TestKey key = TestKey.key5(); + String id = keyIdToString(key.getKeyId()); + gApi.accounts().self() + .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored())) + .get(id); + setApiUser(user); + + exception.expect(ResourceConflictException.class); + exception.expectMessage("GPG key already associated with another account"); + gApi.accounts().self() + .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored())) + .get(id); + } + + @Test + public void listGpgKeys() throws Exception { + List keys = TestKey.allValidKeys(); + List toAdd = new ArrayList<>(keys.size()); + for (TestKey key : keys) { + addExternalIdEmail(admin, + PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress()); + toAdd.add(key.getPublicKeyArmored()); + } + gApi.accounts().self().putGpgKeys(toAdd); + + Map actual = gApi.accounts().self().listGpgKeys(); + assertThat(actual).hasSize(keys.size()); + for (TestKey k : keys) { + String id = keyIdToString(k.getKeyId()); + GpgKeyInfo info = actual.get(id); + assertThat(info).named(id).isNotNull(); + assertThat(info.id).named(id).isNull(); + info.id = id; + assertKeyEquals(k, info); + } + } + + private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception { + try (PublicKeyStore store = publicKeyStoreProvider.get()) { + Iterable keys = store.get(key.getKeyId()); + assertThat(keys).hasSize(1); + return keys.iterator().next().getPublicKey(); + } + } + + private static String armor(PGPPublicKey key) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(4096); + try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) { + key.encode(aout); + } + return new String(out.toByteArray(), UTF_8); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void assertIteratorSize(int size, Iterator it) { + assertThat(ImmutableList.copyOf(it)).hasSize(size); + } + + private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) { + String id = keyIdToString(expected.getKeyId()); + assertThat(actual.id).named(id).isEqualTo(id); + assertThat(actual.fingerprint).named(id).isEqualTo( + fingerprintToString(expected.getPublicKey().getFingerprint())); + @SuppressWarnings("unchecked") + List userIds = + ImmutableList.copyOf(expected.getPublicKey().getUserIDs()); + assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds); + assertThat(actual.key).named(id) + .startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"); + } + + private void addExternalIdEmail(TestAccount account, String email) + throws Exception { + checkNotNull(email); + AccountExternalId extId = new AccountExternalId( + account.getId(), new AccountExternalId.Key(name("test"), email)); + extId.setEmailAddress(email); + db.accountExternalIds().insert(Collections.singleton(extId)); + // Clear saved AccountState and AccountExternalIds. + accountCache.evict(account.getId()); + setApiUser(account); + } } diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java index d9d1cebfa1..b6fc0bcf5d 100644 --- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java +++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java @@ -20,16 +20,11 @@ import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAcco import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.RestResponse; import com.google.gerrit.reviewdb.client.Account; -import com.google.gerrit.server.account.AccountCache; import com.google.gerrit.server.account.GetDetail.AccountDetailInfo; -import com.google.inject.Inject; import org.junit.Test; public class GetAccountDetailIT extends AbstractDaemonTest { - @Inject - private AccountCache accountCache; - @Test public void getDetail() throws Exception { RestResponse r = adminSession.get("/accounts/" + admin.username + "/detail/"); diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java index a4abfe645b..0d10316eb9 100644 --- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java @@ -15,9 +15,13 @@ package com.google.gerrit.extensions.api.accounts; import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.common.GpgKeyInfo; import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; +import java.util.List; +import java.util.Map; + public interface AccountApi { AccountInfo get() throws RestApiException; @@ -25,6 +29,10 @@ public interface AccountApi { void unstarChange(String id) throws RestApiException; void addEmail(EmailInput input) throws RestApiException; + Map listGpgKeys() throws RestApiException; + Map putGpgKeys(List add) throws RestApiException; + GpgKeyApi gpgKey(String id) throws RestApiException; + /** * A default implementation which allows source compatibility * when adding new methods to the interface. @@ -49,5 +57,21 @@ public interface AccountApi { public void addEmail(EmailInput input) throws RestApiException { throw new NotImplementedException(); } + + @Override + public Map putGpgKeys(List add) + throws RestApiException { + throw new NotImplementedException(); + } + + @Override + public GpgKeyApi gpgKey(String id) throws RestApiException { + throw new NotImplementedException(); + } + + @Override + public Map listGpgKeys() throws RestApiException { + throw new NotImplementedException(); + } } } diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java new file mode 100644 index 0000000000..fe93a8eb7c --- /dev/null +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java @@ -0,0 +1,34 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.extensions.api.accounts; + +import com.google.gerrit.extensions.common.GpgKeyInfo; +import com.google.gerrit.extensions.restapi.NotImplementedException; +import com.google.gerrit.extensions.restapi.RestApiException; + +public interface GpgKeyApi { + GpgKeyInfo get() throws RestApiException; + + /** + * A default implementation which allows source compatibility + * when adding new methods to the interface. + */ + public class NotImplemented implements GpgKeyApi { + @Override + public GpgKeyInfo get() throws RestApiException { + throw new NotImplementedException(); + } + } +} diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java new file mode 100644 index 0000000000..443ef078c9 --- /dev/null +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java @@ -0,0 +1,24 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.extensions.common; + +import java.util.List; + +public class GpgKeyInfo { + public String id; + public String fingerprint; + public List userIds; + public String key; +} diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java index 8f9c726a9e..4133679121 100644 --- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java +++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java @@ -36,6 +36,9 @@ public final class AccountExternalId { /** Scheme for the username used to authenticate an account, e.g. over SSH. */ public static final String SCHEME_USERNAME = "username:"; + /** Scheme used for GPG public keys. */ + public static final String SCHEME_GPGKEY = "gpgkey:"; + /** Scheme for external auth used during authentication, e.g. OAuth Token */ public static final String SCHEME_EXTERNAL = "external:"; diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK index c7bd8c9442..ac3e2913e1 100644 --- a/gerrit-server/BUCK +++ b/gerrit-server/BUCK @@ -88,6 +88,7 @@ java_sources( TESTUTIL = glob([ 'src/test/java/com/google/gerrit/testutil/**/*.java', 'src/test/java/com/google/gerrit/server/project/Util.java', + 'src/test/java/com/google/gerrit/server/git/gpg/TestKey.java', ]) java_library( name = 'testutil', @@ -103,6 +104,8 @@ java_library( '//lib:h2', '//lib:truth', '//lib/auto:auto-value', + '//lib/bouncycastle:bcpg', + '//lib/bouncycastle:bcprov', '//lib/guice:guice', '//lib/guice:guice-servlet', '//lib/jgit:jgit', diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java index 75e5ae5185..7b5beadb75 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java @@ -22,6 +22,8 @@ import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.change.ChangeResource; import com.google.inject.TypeLiteral; +import org.bouncycastle.openpgp.PGPPublicKeyRing; + public class AccountResource implements RestResource { public static final TypeLiteral> ACCOUNT_KIND = new TypeLiteral>() {}; @@ -35,6 +37,9 @@ public class AccountResource implements RestResource { public static final TypeLiteral> SSH_KEY_KIND = new TypeLiteral>() {}; + public static final TypeLiteral> GPG_KEY_KIND = + new TypeLiteral>() {}; + public static final TypeLiteral> STARRED_CHANGE_KIND = new TypeLiteral>() {}; @@ -96,6 +101,19 @@ public class AccountResource implements RestResource { } } + public static class GpgKey extends AccountResource { + private final PGPPublicKeyRing keyRing; + + public GpgKey(IdentifiedUser user, PGPPublicKeyRing keyRing) { + super(user); + this.keyRing = keyRing; + } + + public PGPPublicKeyRing getKeyRing() { + return keyRing; + } + } + public static class StarredChange extends AccountResource { private final ChangeResource change; diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GpgKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GpgKeys.java new file mode 100644 index 0000000000..84bd17f75c --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GpgKeys.java @@ -0,0 +1,216 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.account; + +import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; +import com.google.common.base.Predicate; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import com.google.gerrit.extensions.common.GpgKeyInfo; +import com.google.gerrit.extensions.registration.DynamicMap; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.ChildCollection; +import com.google.gerrit.extensions.restapi.IdString; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.RestReadView; +import com.google.gerrit.extensions.restapi.RestView; +import com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.AccountExternalId; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.account.AccountResource.GpgKey; +import com.google.gerrit.server.git.gpg.PublicKeyStore; +import com.google.gerrit.server.util.BouncyCastleUtil; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +@Singleton +public class GpgKeys implements + ChildCollection { + private static final Logger log = LoggerFactory.getLogger(GpgKeys.class); + + public static String MIME_TYPE = "application/pgp-keys"; + + private final DynamicMap> views; + private final Provider db; + private final Provider storeProvider; + + @Inject + GpgKeys(DynamicMap> views, + Provider db, + Provider storeProvider) { + this.views = views; + this.db = db; + this.storeProvider = storeProvider; + } + + @Override + public ListGpgKeys list() + throws ResourceNotFoundException, AuthException { + checkEnabled(); + return new ListGpgKeys(); + } + + @Override + public GpgKey parse(AccountResource parent, IdString id) + throws ResourceNotFoundException, PGPException, OrmException, + IOException { + checkEnabled(); + String str = CharMatcher.WHITESPACE.removeFrom(id.get()).toUpperCase(); + if ((str.length() != 8 && str.length() != 40) + || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) { + throw new ResourceNotFoundException(id); + } + + byte[] fp = null; + for (AccountExternalId extId : getGpgExtIds(parent)) { + String fpStr = extId.getSchemeRest(); + if (!fpStr.endsWith(str)) { + continue; + } else if (fp != null) { + throw new ResourceNotFoundException("Multiple keys found for " + id); + } + fp = BaseEncoding.base16().decode(fpStr); + if (str.length() == 40) { + break; + } + } + if (fp == null) { + throw new ResourceNotFoundException(id); + } + + try (PublicKeyStore store = storeProvider.get()) { + long keyId = keyId(fp); + for (PGPPublicKeyRing keyRing : store.get(keyId)) { + PGPPublicKey key = keyRing.getPublicKey(); + if (Arrays.equals(key.getFingerprint(), fp)) { + return new AccountResource.GpgKey(parent.getUser(), keyRing); + } + } + } + + throw new ResourceNotFoundException(id); + } + + @Override + public DynamicMap> views() { + return views; + } + + public class ListGpgKeys implements RestReadView { + @Override + public Map apply(AccountResource rsrc) + throws OrmException, PGPException, IOException { + Map keys = new HashMap<>(); + try (PublicKeyStore store = storeProvider.get()) { + for (AccountExternalId extId : getGpgExtIds(rsrc)) { + String fpStr = extId.getSchemeRest(); + byte[] fp = BaseEncoding.base16().decode(fpStr); + boolean found = false; + for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) { + if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) { + found = true; + GpgKeyInfo info = toJson(keyRing); + keys.put(info.id, info); + info.id = null; + break; + } + } + if (!found) { + log.warn("No public key stored for fingerprint {}", fp); + } + } + } + return keys; + } + } + + @Singleton + public static class Get implements RestReadView { + @Override + public GpgKeyInfo apply(GpgKey rsrc) throws IOException { + return toJson(rsrc.getKeyRing()); + } + } + + @VisibleForTesting + public static Iterable getGpgExtIds(ReviewDb db, + Account.Id accountId) throws OrmException { + return FluentIterable + .from(db.accountExternalIds().byAccount(accountId)) + .filter(new Predicate() { + @Override + public boolean apply(AccountExternalId in) { + return in.isScheme(SCHEME_GPGKEY); + } + }); + } + + private Iterable getGpgExtIds(AccountResource rsrc) + throws OrmException { + return getGpgExtIds(db.get(), rsrc.getUser().getAccountId()); + } + + private static long keyId(byte[] fp) { + return ByteBuffer.wrap(fp).getLong(fp.length - 8); + } + + static void checkEnabled() throws ResourceNotFoundException { + if (!BouncyCastleUtil.havePGP()) { + throw new ResourceNotFoundException("GPG not enabled"); + } + } + + static GpgKeyInfo toJson(PGPPublicKeyRing keyRing) throws IOException { + PGPPublicKey key = keyRing.getPublicKey(); + GpgKeyInfo info = new GpgKeyInfo(); + info.id = PublicKeyStore.keyIdToString(key.getKeyID()); + info.fingerprint = PublicKeyStore.fingerprintToString(key.getFingerprint()); + @SuppressWarnings("unchecked") + Iterator userIds = key.getUserIDs(); + info.userIds = ImmutableList.copyOf(userIds); + try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096); + ArmoredOutputStream aout = new ArmoredOutputStream(out)) { + // This is not exactly the key stored in the store, but is equivalent. In + // particular, it will have a Bouncy Castle version string. The armored + // stream reader in PublicKeyStore doesn't give us an easy way to extract + // the original ASCII armor. + key.encode(aout); + info.key = new String(out.toByteArray(), UTF_8); + } + return info; + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java index 553392d05d..161af3ad7b 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java @@ -17,6 +17,7 @@ package com.google.gerrit.server.account; import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND; import static com.google.gerrit.server.account.AccountResource.CAPABILITY_KIND; import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND; +import static com.google.gerrit.server.account.AccountResource.GPG_KEY_KIND; import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND; import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND; @@ -33,6 +34,7 @@ public class Module extends RestApiModule { DynamicMap.mapOf(binder(), ACCOUNT_KIND); DynamicMap.mapOf(binder(), CAPABILITY_KIND); DynamicMap.mapOf(binder(), EMAIL_KIND); + DynamicMap.mapOf(binder(), GPG_KEY_KIND); DynamicMap.mapOf(binder(), SSH_KEY_KIND); DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND); @@ -57,11 +59,19 @@ public class Module extends RestApiModule { delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class); child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class); post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class); + get(SSH_KEY_KIND).to(GetSshKey.class); delete(SSH_KEY_KIND).to(DeleteSshKey.class); + + child(ACCOUNT_KIND, "gpgkeys").to(GpgKeys.class); + post(ACCOUNT_KIND, "gpgkeys").to(PostGpgKeys.class); + get(GPG_KEY_KIND).to(GpgKeys.Get.class); + get(ACCOUNT_KIND, "avatar").to(GetAvatar.class); get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class); + child(ACCOUNT_KIND, "capabilities").to(Capabilities.class); + get(ACCOUNT_KIND, "groups").to(GetGroups.class); get(ACCOUNT_KIND, "preferences").to(GetPreferences.class); put(ACCOUNT_KIND, "preferences").to(SetPreferences.class); diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostGpgKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostGpgKeys.java new file mode 100644 index 0000000000..ee6a746fff --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostGpgKeys.java @@ -0,0 +1,179 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.account; + +import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyToString; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.io.BaseEncoding; +import com.google.gerrit.extensions.common.GpgKeyInfo; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.reviewdb.client.AccountExternalId; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.GerritPersonIdent; +import com.google.gerrit.server.account.PostGpgKeys.Input; +import com.google.gerrit.server.git.gpg.CheckResult; +import com.google.gerrit.server.git.gpg.PublicKeyChecker; +import com.google.gerrit.server.git.gpg.PublicKeyStore; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Singleton +public class PostGpgKeys implements RestModifyView { + public static class Input { + public List add; + } + + private final Provider serverIdent; + private final Provider db; + private final Provider storeProvider; + private final PublicKeyChecker checker; + + @Inject + PostGpgKeys(@GerritPersonIdent Provider serverIdent, + Provider db, + Provider storeProvider, + PublicKeyChecker checker) { + this.serverIdent = serverIdent; + this.db = db; + this.storeProvider = storeProvider; + this.checker = checker; + } + + @Override + public Map apply(AccountResource rsrc, Input input) + throws ResourceNotFoundException, BadRequestException, + ResourceConflictException, PGPException, OrmException, IOException { + GpgKeys.checkEnabled(); + + List newKeys = readKeys(input); + List newExtIds = new ArrayList<>(newKeys.size()); + + for (PGPPublicKeyRing keyRing : newKeys) { + PGPPublicKey key = keyRing.getPublicKey(); + AccountExternalId.Key extIdKey = new AccountExternalId.Key( + AccountExternalId.SCHEME_GPGKEY, + BaseEncoding.base16().encode(key.getFingerprint())); + AccountExternalId existing = db.get().accountExternalIds().get(extIdKey); + if (existing != null) { + if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) { + throw new ResourceConflictException( + "GPG key already associated with another account"); + } + } else { + newExtIds.add( + new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey)); + } + } + + storeKeys(rsrc, newKeys); + if (!newExtIds.isEmpty()) { + db.get().accountExternalIds().insert(newExtIds); + } + return toJson(newKeys); + } + + private List readKeys(Input input) + throws BadRequestException, IOException { + if (input.add == null || input.add.isEmpty()) { + return ImmutableList.of(); + } + List keyRings = new ArrayList<>(input.add.size()); + for (String armored : input.add) { + try (InputStream in = new ByteArrayInputStream(armored.getBytes(UTF_8)); + ArmoredInputStream ain = new ArmoredInputStream(in)) { + @SuppressWarnings("unchecked") + List objs = Lists.newArrayList(new BcPGPObjectFactory(ain)); + if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) { + throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK"); + } + keyRings.add((PGPPublicKeyRing) objs.get(0)); + } + } + return keyRings; + } + + private void storeKeys(AccountResource rsrc, List keyRings) + throws BadRequestException, ResourceConflictException, PGPException, + IOException { + try (PublicKeyStore store = storeProvider.get()) { + for (PGPPublicKeyRing keyRing : keyRings) { + PGPPublicKey key = keyRing.getPublicKey(); + CheckResult result = checker.check(key); + if (!result.isOk()) { + throw new BadRequestException(String.format( + "Problems with public key %s:\n%s", + keyToString(key), Joiner.on('\n').join(result.getProblems()))); + } + store.add(keyRing); + } + CommitBuilder cb = new CommitBuilder(); + PersonIdent committer = serverIdent.get(); + cb.setAuthor(rsrc.getUser().newCommitterIdent( + committer.getWhen(), committer.getTimeZone())); + cb.setCommitter(committer); + + RefUpdate.Result saveResult = store.save(cb); + switch (saveResult) { + case NEW: + case FAST_FORWARD: + case FORCED: + break; + default: + // TODO(dborowitz): Backoff and retry on LOCK_FAILURE. + throw new ResourceConflictException( + "Failed to save public key: " + saveResult); + } + } + } + + private static Map toJson( + Collection keyRings) throws IOException { + Map infos = + Maps.newHashMapWithExpectedSize(keyRings.size()); + for (PGPPublicKeyRing keyRing : keyRings) { + GpgKeyInfo info = GpgKeys.toJson(keyRing); + infos.put(info.id, info); + info.id = null; + } + return infos; + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java index 8527e5f302..fbc07742b6 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java @@ -17,13 +17,17 @@ package com.google.gerrit.server.api.accounts; import com.google.gerrit.common.errors.EmailException; import com.google.gerrit.extensions.api.accounts.AccountApi; import com.google.gerrit.extensions.api.accounts.EmailInput; +import com.google.gerrit.extensions.api.accounts.GpgKeyApi; import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.common.GpgKeyInfo; import com.google.gerrit.extensions.restapi.IdString; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.TopLevelResource; import com.google.gerrit.server.account.AccountLoader; import com.google.gerrit.server.account.AccountResource; import com.google.gerrit.server.account.CreateEmail; +import com.google.gerrit.server.account.GpgKeys; +import com.google.gerrit.server.account.PostGpgKeys; import com.google.gerrit.server.account.StarredChanges; import com.google.gerrit.server.change.ChangeResource; import com.google.gerrit.server.change.ChangesCollection; @@ -31,6 +35,12 @@ import com.google.gwtorm.server.OrmException; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; +import org.bouncycastle.openpgp.PGPException; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + public class AccountApiImpl implements AccountApi { interface Factory { AccountApiImpl create(AccountResource account); @@ -42,6 +52,9 @@ public class AccountApiImpl implements AccountApi { private final StarredChanges.Create starredChangesCreate; private final StarredChanges.Delete starredChangesDelete; private final CreateEmail.Factory createEmailFactory; + private final PostGpgKeys postGpgKeys; + private final GpgKeys gpgKeys; + private final GpgKeyApiImpl.Factory gpgKeyApiFactory; @Inject AccountApiImpl(AccountLoader.Factory ailf, @@ -49,6 +62,9 @@ public class AccountApiImpl implements AccountApi { StarredChanges.Create starredChangesCreate, StarredChanges.Delete starredChangesDelete, CreateEmail.Factory createEmailFactory, + PostGpgKeys postGpgKeys, + GpgKeys gpgKeys, + GpgKeyApiImpl.Factory gpgKeyApiFactory, @Assisted AccountResource account) { this.account = account; this.accountLoaderFactory = ailf; @@ -56,6 +72,9 @@ public class AccountApiImpl implements AccountApi { this.starredChangesCreate = starredChangesCreate; this.starredChangesDelete = starredChangesDelete; this.createEmailFactory = createEmailFactory; + this.postGpgKeys = postGpgKeys; + this.gpgKeys = gpgKeys; + this.gpgKeyApiFactory = gpgKeyApiFactory; } @Override @@ -108,4 +127,35 @@ public class AccountApiImpl implements AccountApi { throw new RestApiException("Cannot add email", e); } } + + @Override + public Map listGpgKeys() throws RestApiException { + try { + return gpgKeys.list().apply(account); + } catch (OrmException | PGPException | IOException e) { + throw new RestApiException("Cannot list GPG keys", e); + } + } + + @Override + public Map putGpgKeys(List add) + throws RestApiException { + PostGpgKeys.Input in = new PostGpgKeys.Input(); + in.add = add; + try { + return postGpgKeys.apply(account, in); + } catch (PGPException | OrmException | IOException e) { + throw new RestApiException("Cannot add GPG key", e); + } + } + + @Override + public GpgKeyApi gpgKey(String id) throws RestApiException { + try { + IdString idStr = IdString.fromDecoded(id); + return gpgKeyApiFactory.create(gpgKeys.parse(account, idStr)); + } catch (PGPException | OrmException | IOException e) { + throw new RestApiException("Cannot get PGP key", e); + } + } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgKeyApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgKeyApiImpl.java new file mode 100644 index 0000000000..54e9bd09e1 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgKeyApiImpl.java @@ -0,0 +1,51 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.api.accounts; + +import com.google.gerrit.extensions.api.accounts.GpgKeyApi; +import com.google.gerrit.extensions.common.GpgKeyInfo; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.server.account.AccountResource; +import com.google.gerrit.server.account.GpgKeys; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; + +import java.io.IOException; + +class GpgKeyApiImpl implements GpgKeyApi { + interface Factory { + GpgKeyApiImpl create(AccountResource.GpgKey rsrc); + } + + private final GpgKeys.Get get; + private final AccountResource.GpgKey rsrc; + + @AssistedInject + GpgKeyApiImpl( + GpgKeys.Get get, + @Assisted AccountResource.GpgKey rsrc) { + this.get = get; + this.rsrc = rsrc; + } + + @Override + public GpgKeyInfo get() throws RestApiException { + try { + return get.apply(rsrc); + } catch (IOException e) { + throw new RestApiException("Cannot get GPG key", e); + } + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java index 5e3855e1c2..a9dd9d6594 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/Module.java @@ -23,5 +23,6 @@ public class Module extends FactoryModule { bind(Accounts.class).to(AccountsImpl.class); factory(AccountApiImpl.Factory.class); + factory(GpgKeyApiImpl.Factory.class); } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/GerritPublicKeyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/GerritPublicKeyChecker.java index 1e94100227..851808c3d1 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/GerritPublicKeyChecker.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/GerritPublicKeyChecker.java @@ -14,6 +14,7 @@ package com.google.gerrit.server.git.gpg; +import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY; import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString; import com.google.common.collect.FluentIterable; @@ -100,6 +101,9 @@ public class GerritPublicKeyChecker extends PublicKeyChecker { Set result = new HashSet<>(); result.addAll(user.getEmailAddresses()); for (AccountExternalId extId : user.state().getExternalIds()) { + if (extId.isScheme(SCHEME_GPGKEY)) { + continue; // Omit GPG keys. + } result.add(extId.getExternalId()); } return result; diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyStore.java index 281d3ea37a..00436f193f 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyStore.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyStore.java @@ -273,24 +273,33 @@ public class PublicKeyStore implements AutoCloseable { Collections. emptyList()); } - static String keyToString(PGPPublicKey key) { + public static String keyToString(PGPPublicKey key) { @SuppressWarnings("unchecked") Iterator it = key.getUserIDs(); - ByteBuffer buf = ByteBuffer.wrap(key.getFingerprint()); return String.format( - "%s %s(%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X)", + "%s %s(%s)", keyIdToString(key.getKeyID()), it.hasNext() ? it.next() + " " : "", + fingerprintToString(key.getFingerprint())); + } + + public static String keyIdToString(long keyId) { + // Match key ID format from gpg --list-keys. + return String.format("%08X", (int) keyId); + } + + public static String fingerprintToString(byte[] fingerprint) { + if (fingerprint.length != 20) { + throw new IllegalArgumentException(); + } + ByteBuffer buf = ByteBuffer.wrap(fingerprint); + return String.format( + "%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X", buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort()); } - static String keyIdToString(long keyId) { - // Match key ID format from gpg --list-keys. - return String.format("%08X", (int) keyId); - } - static ObjectId keyObjectId(long keyId) { ByteBuffer buf = ByteBuffer.wrap(new byte[Constants.OBJECT_ID_LENGTH]); buf.putLong(keyId); diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/TestKey.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/TestKey.java index 196dd0f46e..483964a097 100644 --- a/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/TestKey.java +++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/TestKey.java @@ -14,6 +14,8 @@ package com.google.gerrit.server.git.gpg; +import com.google.common.collect.ImmutableList; + import org.bouncycastle.bcpg.ArmoredInputStream; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPrivateKey; @@ -29,7 +31,11 @@ import org.eclipse.jgit.lib.Constants; import java.io.ByteArrayInputStream; import java.io.IOException; -class TestKey { +public class TestKey { + public static ImmutableList allValidKeys() { + return ImmutableList.of(key1(), key2(), key5()); + } + /** * A valid key with no expiration. * @@ -40,7 +46,7 @@ class TestKey { * sub 2048R/F0AF69C0 2015-07-08 * */ - static TestKey key1() throws PGPException, IOException { + public static TestKey key1() { return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "Version: GnuPG v1\n" + "\n" @@ -140,7 +146,7 @@ class TestKey { * sub 2048R/46D4F204 2015-07-08 [expires: 2065-06-25] * */ - static final TestKey key2() throws PGPException, IOException { + public static final TestKey key2() { return new TestKey( "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "Version: GnuPG v1\n" @@ -241,7 +247,7 @@ class TestKey { * uid Testuser Three <test3@example.com> * */ - static final TestKey key3() throws PGPException, IOException { + public static final TestKey key3() { return new TestKey( "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "Version: GnuPG v1\n" @@ -342,7 +348,7 @@ class TestKey { * uid Testuser Four <test4@example.com> * */ - static final TestKey key4() throws PGPException, IOException { + public static final TestKey key4() { return new TestKey( "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "Version: GnuPG v1\n" @@ -450,7 +456,7 @@ class TestKey { * sub 2048R/C781A9E3 2015-07-30 * */ - static TestKey key5() throws PGPException, IOException { + public static TestKey key5() { return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + "Version: GnuPG v1\n" + "\n" @@ -555,44 +561,47 @@ class TestKey { private final PGPPublicKeyRing pubRing; private final PGPSecretKeyRing secRing; - private TestKey(String pubArmored, String secArmored) - throws PGPException, IOException { + private TestKey(String pubArmored, String secArmored) { this.pubArmored = pubArmored; this.secArmored = secArmored; BcKeyFingerprintCalculator fc = new BcKeyFingerprintCalculator(); - this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc); - this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc); + try { + this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc); + this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc); + } catch (PGPException | IOException e) { + throw new AssertionError(e); + } } - String getPublicKeyArmored() { + public String getPublicKeyArmored() { return pubArmored; } - String getSecretKeyArmored() { + public String getSecretKeyArmored() { return secArmored; } - PGPPublicKeyRing getPublicKeyRing() { + public PGPPublicKeyRing getPublicKeyRing() { return pubRing; } - PGPPublicKey getPublicKey() { + public PGPPublicKey getPublicKey() { return pubRing.getPublicKey(); } - PGPSecretKey getSecretKey() { + public PGPSecretKey getSecretKey() { return secRing.getSecretKey(); } - long getKeyId() { + public long getKeyId() { return getPublicKey().getKeyID(); } - String getFirstUserId() { + public String getFirstUserId() { return (String) getPublicKey().getUserIDs().next(); } - PGPPrivateKey getPrivateKey() throws PGPException { + public PGPPrivateKey getPrivateKey() throws PGPException { return getSecretKey().extractPrivateKey( new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider()) // All test keys have no passphrase. @@ -604,5 +613,4 @@ class TestKey { return new ArmoredInputStream( new ByteArrayInputStream(Constants.encode(armored))); } - }