Merge "Remove custom SshSessionFactory which is the same as the default one"
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8268e6c..e64fa7e 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -684,6 +684,142 @@
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-delete-gpg-keys]]
+=== Add/Delete GPG Keys
+--
+'POST /accounts/link:#account-id[\{account-id\}]/gpgkeys'
+--
+
+Add or delete one or more GPG keys for a user.
+
+The changes must be provided in the request body as a
+link:#gpg-key-input[GpgKeyInput] entity. Each new 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"
+ ],
+ "delete": [
+ "DEADBEEF",
+ ]
+ }'
+----
+
+As a response, the modified GPG keys are returned as a map of
+link:#gpg-key-info[GpgKeyInfo] entities, keyed by ID. Deleted keys are
+represented by an empty object.
+
+.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"
+ }
+ "DEADBEEF": {}
+ }
+----
+
+[[delete-gpg-key]]
+=== Delete GPG Key
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]/gpgkeys/link:#gpg-key-id[\{gpg-key-id\}]'
+--
+
+Deletes a GPG key of a user.
+
+.Request
+----
+ DELETE /accounts/self/gpgkeys/AFC8A49B HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 204 No Content
+----
+
[[list-account-capabilities]]
=== List Account Capabilities
--
@@ -1292,6 +1428,12 @@
=== \{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 +1720,32 @@
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`|Not set for deleted keys|The 40-char (plus spaces) hex GPG key fingerprint.
+|`user_ids` |Not set for deleted keys|
+link:https://tools.ietf.org/html/rfc4880#section-5.11[OpenPGP User IDs]
+associated with the public key.
+|`key` |Not set for deleted keys|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.
+|`delete` |List of link:#gpg-key-id[`\{gpg-key-id\}`]s to delete.
+|========================
+
[[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 219575c..0888216 100644
--- a/gerrit-acceptance-tests/BUCK
+++ b/gerrit-acceptance-tests/BUCK
@@ -27,16 +27,18 @@
'//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 12e26b4..18ee2ef 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.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;
@@ -114,6 +115,9 @@
protected AcceptanceTestRequestScope atrScope;
@Inject
+ protected AccountCache accountCache;
+
+ @Inject
private IdentifiedUser.GenericFactory identifiedUserFactory;
@Inject
@@ -238,6 +242,11 @@
toClose = Collections.synchronizedList(new ArrayList<Repository>());
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/AcceptanceTestRequestScope.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 0ff4709..d978c70 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -162,6 +162,10 @@
return old;
}
+ public Context get() {
+ return current.get();
+ }
+
public Context disableDb() {
Context old = current.get();
SchemaFactory<ReviewDb> sf = new SchemaFactory<ReviewDb>() {
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 0ecbc7e..91d3f11 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,109 @@
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.common.truth.Truth.assert_;
+import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyToString;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
+import com.google.common.io.BaseEncoding;
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.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GpgKeys;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.gpg.Fingerprint;
+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.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+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.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
+import java.util.Map;
public class AccountIT extends AbstractDaemonTest {
+ @Inject
+ private Provider<PublicKeyStore> publicKeyStoreProvider;
+
+ @Inject
+ private AllUsersName allUsers;
+
+ private List<AccountExternalId> 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);
+ }
+
+ @After
+ public void clearPublicKeyStore() throws Exception {
+ try (Repository repo = repoManager.openRepository(allUsers)) {
+ Ref ref = repo.getRef(RefNames.REFS_GPG_KEYS);
+ if (ref != null) {
+ RefUpdate ru = repo.updateRef(RefNames.REFS_GPG_KEYS);
+ ru.setForceUpdate(true);
+ assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ }
+ }
+ }
+
+ private List<AccountExternalId> getExternalIds(TestAccount account)
+ throws Exception {
+ return db.accountExternalIds().byAccount(account.getId()).toList();
+ }
+
+ @After
+ public void deleteGpgKeys() throws Exception {
+ String ref = RefNames.REFS_GPG_KEYS;
+ try (Repository repo = repoManager.openRepository(allUsers)) {
+ if (repo.getRefDatabase().exactRef(ref) != null) {
+ RefUpdate ru = repo.updateRef(ref);
+ ru.setForceUpdate(true);
+ assert_().withFailureMessage("Failed to delete " + ref)
+ .that(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ }
+ }
+ }
+
@Test
public void get() throws Exception {
AccountInfo info = gApi
@@ -103,4 +192,233 @@
exception.expectMessage("invalid email address");
gApi.accounts().self().addEmail(input);
}
+
+ @Test
+ public void addGpgKey() throws Exception {
+ TestKey key = TestKey.key1();
+ String id = key.getKeyIdString();
+ addExternalIdEmail(admin, "test1@example.com");
+
+ assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
+ assertKeys(key);
+
+ 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 = key.getKeyIdString();
+ PGPPublicKey pk = key.getPublicKey();
+
+ GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
+ assertThat(info.userIds).hasSize(2);
+ assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
+
+ pk = PGPPublicKey.removeCertification(pk, "foo:myId");
+ info = addGpgKey(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();
+ addGpgKey(key.getPublicKeyArmored());
+ setApiUser(user);
+
+ exception.expect(ResourceConflictException.class);
+ exception.expectMessage("GPG key already associated with another account");
+ addGpgKey(key.getPublicKeyArmored());
+ }
+
+ @Test
+ public void listGpgKeys() throws Exception {
+ List<TestKey> keys = TestKey.allValidKeys();
+ List<String> 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, ImmutableList.<String> of());
+ assertKeys(keys);
+ }
+
+ @Test
+ public void deleteGpgKey() throws Exception {
+ TestKey key = TestKey.key1();
+ String id = key.getKeyIdString();
+ addExternalIdEmail(admin, "test1@example.com");
+ addGpgKey(key.getPublicKeyArmored());
+ assertKeys(key);
+
+ gApi.accounts().self().gpgKey(id).delete();
+ assertKeys();
+
+ exception.expect(ResourceNotFoundException.class);
+ exception.expectMessage(id);
+ gApi.accounts().self().gpgKey(id).get();
+ }
+
+ @Test
+ public void addAndRemoveGpgKeys() throws Exception {
+ for (TestKey key : TestKey.allValidKeys()) {
+ addExternalIdEmail(admin,
+ PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+ }
+ TestKey key1 = TestKey.key1();
+ TestKey key2 = TestKey.key2();
+ TestKey key5 = TestKey.key5();
+
+ Map<String, GpgKeyInfo> infos = gApi.accounts().self().putGpgKeys(
+ ImmutableList.of(
+ key1.getPublicKeyArmored(),
+ key2.getPublicKeyArmored()),
+ ImmutableList.of(key5.getKeyIdString()));
+ assertThat(infos.keySet())
+ .containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
+ assertKeys(key1, key2);
+
+ infos = gApi.accounts().self().putGpgKeys(
+ ImmutableList.of(key5.getPublicKeyArmored()),
+ ImmutableList.of(key1.getKeyIdString()));
+ assertThat(infos.keySet())
+ .containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
+ assertKeyMapContains(key5, infos);
+ assertThat(infos.get(key1.getKeyIdString()).key).isNull();
+ assertKeys(key2, key5);
+
+ exception.expect(BadRequestException.class);
+ exception.expectMessage("Cannot both add and delete key: "
+ + keyToString(key2.getPublicKey()));
+ infos = gApi.accounts().self().putGpgKeys(
+ ImmutableList.of(key2.getPublicKeyArmored()),
+ ImmutableList.of(key2.getKeyIdString()));
+ }
+
+ private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception {
+ try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+ Iterable<PGPPublicKeyRing> 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 assertKeyMapContains(TestKey expected,
+ Map<String, GpgKeyInfo> actualMap) {
+ GpgKeyInfo actual = actualMap.get(expected.getKeyIdString());
+ assertThat(actual).isNotNull();
+ assertThat(actual.id).isNull();
+ actual.id = expected.getKeyIdString();
+ assertKeyEquals(expected, actual);
+ }
+
+ private void assertKeys(TestKey... expectedKeys) throws Exception {
+ assertKeys(Arrays.asList(expectedKeys));
+ }
+
+ private void assertKeys(Iterable<TestKey> expectedKeys) throws Exception {
+ // Check via API.
+ FluentIterable<TestKey> expected = FluentIterable.from(expectedKeys);
+ Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
+ assertThat(keyMap.keySet())
+ .named("keys returned by listGpgKeys()")
+ .containsExactlyElementsIn(
+ expected.transform(new Function<TestKey, String>() {
+ @Override
+ public String apply(TestKey in) {
+ return in.getKeyIdString();
+ }
+ }));
+
+ for (TestKey key : expected) {
+ assertKeyEquals(key, gApi.accounts().self().gpgKey(
+ key.getKeyIdString()).get());
+ assertKeyEquals(key, gApi.accounts().self().gpgKey(
+ Fingerprint.toString(key.getPublicKey().getFingerprint())).get());
+ assertKeyMapContains(key, keyMap);
+ }
+
+ // Check raw external IDs.
+ Account.Id currAccountId =
+ ((IdentifiedUser) atrScope.get().getCurrentUser()).getAccountId();
+ assertThat(
+ GpgKeys.getGpgExtIds(db, currAccountId)
+ .transform(new Function<AccountExternalId, String>() {
+ @Override
+ public String apply(AccountExternalId in) {
+ return in.getSchemeRest();
+ }
+ }))
+ .named("external IDs in database")
+ .containsExactlyElementsIn(
+ expected.transform(new Function<TestKey, String>() {
+ @Override
+ public String apply(TestKey in) {
+ return BaseEncoding.base16().encode(
+ in.getPublicKey().getFingerprint());
+ }
+ }));
+
+ // Check raw stored keys.
+ for (TestKey key : expected) {
+ getOnlyKeyFromStore(key);
+ }
+ }
+
+ private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
+ String id = expected.getKeyIdString();
+ assertThat(actual.id).named(id).isEqualTo(id);
+ assertThat(actual.fingerprint).named(id).isEqualTo(
+ Fingerprint.toString(expected.getPublicKey().getFingerprint()));
+ @SuppressWarnings("unchecked")
+ List<String> 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);
+ }
+
+ private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
+ return gApi.accounts().self().putGpgKeys(
+ ImmutableList.of(armored),
+ ImmutableList.<String> of());
+ }
}
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 d9d1ceb..b6fc0bc 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 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-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 6fcdbce..438ebec 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.rest.change;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.Iterables.getOnlyElement;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assert_;
@@ -21,6 +22,7 @@
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
@@ -195,26 +197,33 @@
}
protected void submit(String changeId) throws Exception {
- submit(changeId, HttpStatus.SC_OK);
+ submit(changeId, HttpStatus.SC_OK, null);
}
- protected void submitWithConflict(String changeId) throws Exception {
- submit(changeId, HttpStatus.SC_CONFLICT);
+ protected void submitWithConflict(String changeId,
+ String expectedError) throws Exception {
+ submit(changeId, HttpStatus.SC_CONFLICT, expectedError);
}
- private void submit(String changeId, int expectedStatus) throws Exception {
+ private void submit(String changeId, int expectedStatus, String msg)
+ throws Exception {
approve(changeId);
SubmitInput subm = new SubmitInput();
RestResponse r =
adminSession.post("/changes/" + changeId + "/submit", subm);
assertThat(r.getStatusCode()).isEqualTo(expectedStatus);
if (expectedStatus == HttpStatus.SC_OK) {
+ checkArgument(msg == null, "msg must be null for successful submits");
ChangeInfo change =
newGson().fromJson(r.getReader(),
new TypeToken<ChangeInfo>() {}.getType());
assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
checkMergeResult(change);
+ } else {
+ checkArgument(!Strings.isNullOrEmpty(msg), "msg must be a valid string " +
+ "containing an error message for unsuccessful submits");
+ assertThat(r.getEntityContent()).isEqualTo(msg);
}
r.consume();
}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 98aae37..2b3d035 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -76,7 +76,11 @@
testRepo.reset(initialHead);
PushOneCommit.Result change2 =
createChange("Change 2", "a.txt", "other content");
- submitWithConflict(change2.getChangeId());
+ submitWithConflict(change2.getChangeId(),
+ "Cannot merge " + change2.getCommit().name() + "\n" +
+ "Change could not be merged due to a path conflict.\n\n" +
+ "Please rebase the change locally " +
+ "and upload the rebased commit for review.");
assertThat(getRemoteHead()).isEqualTo(oldHead);
}
}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index b99b30a..efaa1df 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -105,7 +105,12 @@
testRepo.reset(initialHead);
PushOneCommit.Result change2 =
createChange("Change 2", "a.txt", "other content");
- submitWithConflict(change2.getChangeId());
+ submitWithConflict(change2.getChangeId(),
+ "Cannot merge " + change2.getCommitId().name() + "\n" +
+ "Change could not be merged due to a path conflict.\n\n" +
+ "Please rebase the change locally and " +
+ "upload the rebased commit for review.");
+
assertThat(getRemoteHead()).isEqualTo(oldHead);
assertCurrentRevision(change2.getChangeId(), 1, change2.getCommitId());
assertNoSubmitter(change2.getChangeId(), 1);
@@ -145,7 +150,12 @@
createChange("Change 2", "b.txt", "other content");
PushOneCommit.Result change3 =
createChange("Change 3", "b.txt", "different content");
- submitWithConflict(change3.getChangeId());
+ submitWithConflict(change3.getChangeId(),
+ "Cannot merge " + change3.getCommitId().name() + "\n" +
+ "Change could not be merged due to a path conflict.\n\n" +
+ "Please rebase the change locally and " +
+ "upload the rebased commit for review.");
+
assertThat(getRemoteHead()).isEqualTo(oldHead);
assertCurrentRevision(change3.getChangeId(), 1, change3.getCommitId());
assertNoSubmitter(change3.getChangeId(), 1);
@@ -216,7 +226,11 @@
// Submit fails; change3 contains the delta "b1" -> "b2", which cannot be
// applied against tip.
- submitWithConflict(change3.getChangeId());
+ submitWithConflict(change3.getChangeId(),
+ "Cannot merge " + change3.getCommitId().name() + "\n" +
+ "Change could not be merged due to a path conflict.\n\n" +
+ "Please rebase the change locally and " +
+ "upload the rebased commit for review.");
ChangeInfo info3 = get(change3.getChangeId(), ListChangesOption.MESSAGES);
assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 2655789..2af3c25 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -19,6 +19,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.reviewdb.client.Change;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Test;
@@ -63,10 +64,14 @@
@Test
public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
RevCommit oldHead = getRemoteHead();
- createChange();
+ PushOneCommit.Result change1 = createChange();
PushOneCommit.Result change2 = createChange();
- submitWithConflict(change2.getChangeId());
+ Change.Id id1 = change1.getPatchSetId().getParentKey();
+ submitWithConflict(change2.getChangeId(),
+ "The change could not be submitted because it depends on change(s) [" +
+ id1 + "], which could not be submitted because:\n" +
+ id1 + ": needs Code-Review;");
RevCommit head = getRemoteHead();
assertThat(head.getId()).isEqualTo(oldHead.getId());
@@ -91,7 +96,10 @@
ActionInfo info = actions.get("submit");
assertThat(info.enabled).isNull();
- submitWithConflict(change2.getChangeId());
+ submitWithConflict(change2.getChangeId(),
+ "Cannot merge " + change2.getCommitId().name() + "\n" +
+ "Project policy requires all submissions to be a fast-forward.\n\n" +
+ "Please rebase the change locally and upload again for review.");
assertThat(getRemoteHead()).isEqualTo(oldHead);
assertSubmitter(change.getChangeId(), 1);
}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 032cb7d..1aa0e85 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -180,7 +180,11 @@
approve(change3.getChangeId());
if (isSubmitWholeTopicEnabled()) {
- submitWithConflict(change1b.getChangeId());
+ submitWithConflict(change1b.getChangeId(),
+ "Cannot merge " + change3.getCommit().name() + "\n" +
+ "Change could not be merged due to a path conflict.\n\n" +
+ "Please rebase the change locally " +
+ "and upload the rebased commit for review.");
} else {
submit(change1b.getChangeId());
}
@@ -292,7 +296,7 @@
"a.txt", "1", "a-topic-here");
approve(change3b.getChangeId());
- submitWithConflict(change3a.getChangeId());
+ submitWithConflict(change3a.getChangeId(), "Merge Conflict");
RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
assertThat(tipbranch.getShortMessage()).isEqualTo(
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index d1d3bdf..1528deb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -107,7 +107,7 @@
testRepo.reset(initialHead);
PushOneCommit.Result change2 =
createChange("Change 2", "a.txt", "other content");
- submitWithConflict(change2.getChangeId());
+ submitWithConflict(change2.getChangeId(), "Merge Conflict");
RevCommit head = getRemoteHead();
assertThat(head).isEqualTo(oldHead);
assertCurrentRevision(change2.getChangeId(), 1, change2.getCommitId());
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index c80d867..ff2121d 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -26,6 +26,7 @@
public static final String SETTINGS = "/settings/";
public static final String SETTINGS_PREFERENCES = "/settings/preferences";
public static final String SETTINGS_SSHKEYS = "/settings/ssh-keys";
+ public static final String SETTINGS_GPGKEYS = "/settings/gpg-keys";
public static final String SETTINGS_HTTP_PASSWORD = "/settings/http-password";
public static final String SETTINGS_WEBIDENT = "/settings/web-identities";
public static final String SETTINGS_MYGROUPS = "/settings/group-memberships";
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
index 7041dcc..a83f46c 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
@@ -32,6 +32,7 @@
protected LabelTypes labelTypes;
protected Map<String, String> capabilities;
protected Map<AccountGroup.UUID, GroupInfo> groupInfo;
+ protected List<WebLinkInfoCommon> fileHistoryLinks;
public ProjectAccess() {
}
@@ -132,4 +133,12 @@
public void setGroupInfo(Map<AccountGroup.UUID, GroupInfo> m) {
groupInfo = m;
}
+
+ public void setFileHistoryLinks(List<WebLinkInfoCommon> links) {
+ fileHistoryLinks = links;
+ }
+
+ public List<WebLinkInfoCommon> getFileHistoryLinks() {
+ return fileHistoryLinks;
+ }
}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/WebLinkInfoCommon.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/WebLinkInfoCommon.java
new file mode 100644
index 0000000..dd0a70a
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/WebLinkInfoCommon.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.common.data;
+
+public class WebLinkInfoCommon {
+ public WebLinkInfoCommon() {}
+
+ public String name;
+ public String imageUrl;
+ public String url;
+ public String target;
+}
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 a4abfe6..a356ab6 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,11 @@
void unstarChange(String id) throws RestApiException;
void addEmail(EmailInput input) throws RestApiException;
+ Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException;
+ Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove)
+ throws RestApiException;
+ GpgKeyApi gpgKey(String id) throws RestApiException;
+
/**
* A default implementation which allows source compatibility
* when adding new methods to the interface.
@@ -49,5 +58,21 @@
public void addEmail(EmailInput input) throws RestApiException {
throw new NotImplementedException();
}
+
+ @Override
+ public Map<String, GpgKeyInfo> putGpgKeys(List<String> add,
+ List<String> remove) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public GpgKeyApi gpgKey(String id) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public Map<String, GpgKeyInfo> 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 0000000..ffdcf87
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
@@ -0,0 +1,40 @@
+// 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;
+ void delete() 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();
+ }
+
+ @Override
+ public void delete() 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 0000000..443ef07
--- /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<String> userIds;
+ public String key;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
new file mode 100644
index 0000000..f9f9e58
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
@@ -0,0 +1,37 @@
+// 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.webui;
+
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+public interface FileHistoryWebLink extends WebLink {
+ /**
+ * {@link com.google.gerrit.extensions.common.WebLinkInfo}
+ * describing a link from a file to an external service displaying
+ * a log for that file.
+ *
+ * <p>In order for the web link to be visible
+ * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
+ * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
+ * must be set.<p>
+ *
+ * @param projectName Name of the project
+ * @param revision Name of the revision (e.g. branch or commit ID)
+ * @param fileName Name of the file
+ * @return WebLinkInfo that links to a log for the file in external
+ * service, null if there should be no link.
+ */
+ WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName);
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
index 7087888..dcd96da 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
@@ -91,6 +91,20 @@
return arr;
}
+ public static JsArrayString arrayOf(Iterable<String> elements) {
+ JsArrayString arr = JavaScriptObject.createArray().cast();
+ for (String elem : elements) {
+ arr.push(elem);
+ }
+ return arr;
+ }
+
+ public static JsArrayString arrayOf(String element) {
+ JsArrayString arr = JavaScriptObject.createArray().cast();
+ arr.push(element);
+ return arr;
+ }
+
private Natives() {
}
}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index e7381f8..946888d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -29,6 +29,7 @@
import static com.google.gerrit.common.PageLinks.SETTINGS_AGREEMENTS;
import static com.google.gerrit.common.PageLinks.SETTINGS_CONTACT;
import static com.google.gerrit.common.PageLinks.SETTINGS_EXTENSION;
+import static com.google.gerrit.common.PageLinks.SETTINGS_GPGKEYS;
import static com.google.gerrit.common.PageLinks.SETTINGS_HTTP_PASSWORD;
import static com.google.gerrit.common.PageLinks.SETTINGS_MYGROUPS;
import static com.google.gerrit.common.PageLinks.SETTINGS_NEW_AGREEMENT;
@@ -40,6 +41,7 @@
import com.google.gerrit.client.account.MyAgreementsScreen;
import com.google.gerrit.client.account.MyContactInformationScreen;
+import com.google.gerrit.client.account.MyGpgKeysScreen;
import com.google.gerrit.client.account.MyGroupsScreen;
import com.google.gerrit.client.account.MyIdentitiesScreen;
import com.google.gerrit.client.account.MyPasswordScreen;
@@ -536,6 +538,10 @@
return new MySshKeysScreen();
}
+ if (matchExact(SETTINGS_GPGKEYS, token)) {
+ return new MyGpgKeysScreen();
+ }
+
if (matchExact(SETTINGS_WEBIDENT, token)) {
return new MyIdentitiesScreen();
}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index c6eb2de..c2a7637 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -184,6 +184,7 @@
String sshHostKeyPanelKnownHostEntry();
String sshKeyPanelEncodedKey();
String sshKeyPanelInvalid();
+ String sshKeyTable();
String stringListPanelButtons();
String topMostCell();
String topmenu();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
index a796f94..367644f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
@@ -17,10 +17,13 @@
import com.google.gerrit.client.VoidResult;
import com.google.gerrit.client.info.AccountInfo;
import com.google.gerrit.client.rpc.CallbackGroup;
+import com.google.gerrit.client.rpc.NativeMap;
import com.google.gerrit.client.rpc.NativeString;
+import com.google.gerrit.client.rpc.Natives;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.user.client.rpc.AsyncCallback;
import java.util.Set;
@@ -147,4 +150,42 @@
protected UsernameInput() {
}
}
+
+ public static void addGpgKey(String account, String armored,
+ AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
+ new RestApi("/accounts/")
+ .id(account)
+ .view("gpgkeys")
+ .post(GpgKeysInput.add(armored), cb);
+ }
+
+ public static void removeGpgKeys(String account,
+ Iterable<String> fingerprints, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
+ new RestApi("/accounts/")
+ .id(account)
+ .view("gpgkeys")
+ .post(GpgKeysInput.remove(fingerprints), cb);
+ }
+
+ private static class GpgKeysInput extends JavaScriptObject {
+ static GpgKeysInput add(String key) {
+ return createAdd(Natives.arrayOf(key));
+ }
+
+ static GpgKeysInput remove(Iterable<String> fingerprints) {
+ return createRemove(Natives.arrayOf(fingerprints));
+ }
+
+ private static native GpgKeysInput createAdd(JsArrayString keys) /*-{
+ return {'add': keys};
+ }-*/;
+
+ private static native GpgKeysInput createRemove(
+ JsArrayString fingerprints) /*-{
+ return {'remove': fingerprints};
+ }-*/;
+
+ protected GpgKeysInput() {
+ }
+ }
}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index 4c3cc29..6234f02 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -50,14 +50,15 @@
String myMenuReset();
String tabAccountSummary();
- String tabPreferences();
- String tabWatchedProjects();
- String tabContactInformation();
- String tabSshKeys();
- String tabHttpAccess();
- String tabWebIdentities();
- String tabMyGroups();
String tabAgreements();
+ String tabContactInformation();
+ String tabGpgKeys();
+ String tabHttpAccess();
+ String tabMyGroups();
+ String tabPreferences();
+ String tabSshKeys();
+ String tabWatchedProjects();
+ String tabWebIdentities();
String buttonShowAddSshKey();
String buttonCloseAddSshKey();
@@ -94,6 +95,10 @@
String sshHostKeyFingerprint();
String sshHostKeyKnownHostEntry();
+ String gpgKeyId();
+ String gpgKeyFingerprint();
+ String gpgKeyUserIds();
+
String webIdStatus();
String webIdEmail();
String webIdIdentity();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index 36cb765..eee7a60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -36,14 +36,15 @@
changeScreenNewUi = New Screen
tabAccountSummary = Profile
-tabPreferences = Preferences
-tabWatchedProjects = Watched Projects
-tabContactInformation = Contact Information
-tabSshKeys = SSH Public Keys
-tabHttpAccess = HTTP Password
-tabWebIdentities = Identities
-tabMyGroups = Groups
tabAgreements = Agreements
+tabContactInformation = Contact Information
+tabGpgKeys = GPG Public Keys
+tabHttpAccess = HTTP Password
+tabMyGroups = Groups
+tabPreferences = Preferences
+tabSshKeys = SSH Public Keys
+tabWatchedProjects = Watched Projects
+tabWebIdentities = Identities
buttonShowAddSshKey = Add Key ...
buttonCloseAddSshKey = Close
@@ -73,6 +74,10 @@
sshHostKeyFingerprint = Fingerprint:
sshHostKeyKnownHostEntry = Entry for <code>~/.ssh/known_hosts</code>:
+gpgKeyId = ID
+gpgKeyFingerprint = Fingerprint
+gpgKeyUserIds = User IDs
+
webIdStatus = Status
webIdEmail = Email Address
webIdIdentity = Identity
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/GpgKeyInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/GpgKeyInfo.java
new file mode 100644
index 0000000..d1bb426
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/GpgKeyInfo.java
@@ -0,0 +1,28 @@
+// 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.client.account;
+
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArrayString;
+
+public class GpgKeyInfo extends JavaScriptObject {
+ public final native String id() /*-{ return this.id; }-*/;
+ public final native String fingerprint() /*-{ return this.fingerprint; }-*/;
+ public final native JsArrayString userIds() /*-{ return this.user_ids; }-*/;
+ public final native String key() /*-{ return this.key; }-*/;
+
+ protected GpgKeyInfo() {
+ }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
new file mode 100644
index 0000000..6d88e38
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
@@ -0,0 +1,283 @@
+// 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.client.account;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.NativeMap;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.FancyFlexTable;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.logical.shared.ValueChangeEvent;
+import com.google.gwt.event.logical.shared.ValueChangeHandler;
+import com.google.gwt.http.client.Response;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.rpc.StatusCodeException;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.CheckBox;
+import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.InlineLabel;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwtexpui.clippy.client.CopyableLabel;
+import com.google.gwtexpui.globalkey.client.NpTextArea;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class MyGpgKeysScreen extends SettingsScreen {
+ interface Binder extends UiBinder<HTMLPanel, MyGpgKeysScreen> {}
+ private static final Binder uiBinder = GWT.create(Binder.class);
+
+ @UiField(provided = true) GpgKeyTable keys;
+ @UiField Button deleteKey;
+ @UiField Button addKey;
+
+ @UiField VerticalPanel addKeyBlock;
+ @UiField NpTextArea keyText;
+
+ @UiField VerticalPanel errorPanel;
+ @UiField Label errorText;
+
+ @UiField Button clearButton;
+ @UiField Button addButton;
+ @UiField Button closeButton;
+
+ @Override
+ protected void onInitUI() {
+ super.onInitUI();
+ keys = new GpgKeyTable();
+ add(uiBinder.createAndBindUi(this));
+ keys.updateDeleteButton();
+ }
+
+ @Override
+ protected void onLoad() {
+ super.onLoad();
+ refreshKeys();
+ }
+
+ @UiHandler("deleteKey")
+ void onDeleteKey(@SuppressWarnings("unused") ClickEvent e) {
+ keys.deleteChecked();
+ }
+
+ @UiHandler("addKey")
+ void onAddKey(@SuppressWarnings("unused") ClickEvent e) {
+ showAddKeyBlock(true);
+ }
+
+ @UiHandler("clearButton")
+ void onClearButton(@SuppressWarnings("unused") ClickEvent e) {
+ keyText.setText("");
+ keyText.setFocus(true);
+ errorPanel.setVisible(false);
+ }
+
+ @UiHandler("closeButton")
+ void onCloseButton(@SuppressWarnings("unused") ClickEvent e) {
+ showAddKeyBlock(false);
+ }
+
+ @UiHandler("addButton")
+ void onAddButton(@SuppressWarnings("unused") ClickEvent e) {
+ doAddKey();
+ }
+
+ private void refreshKeys() {
+ AccountApi.self().view("gpgkeys").get(NativeMap.copyKeysIntoChildren("id",
+ new GerritCallback<NativeMap<GpgKeyInfo>>() {
+ @Override
+ public void onSuccess(NativeMap<GpgKeyInfo> result) {
+ List<GpgKeyInfo> list = Natives.asList(result.values());
+ // TODO(dborowitz): Sort on something more meaningful, like
+ // created date?
+ Collections.sort(list, new Comparator<GpgKeyInfo>() {
+ @Override
+ public int compare(GpgKeyInfo a, GpgKeyInfo b) {
+ return a.id().compareTo(b.id());
+ }
+ });
+ keys.clear();
+ keyText.setText("");
+ errorPanel.setVisible(false);
+ addButton.setEnabled(true);
+ if (!list.isEmpty()) {
+ keys.setVisible(true);
+ for (GpgKeyInfo k : list) {
+ keys.addOneKey(k);
+ }
+ showKeyTable(true);
+ showAddKeyBlock(false);
+ } else {
+ keys.setVisible(false);
+ showAddKeyBlock(true);
+ showKeyTable(false);
+ }
+
+ display();
+ }
+ }));
+ }
+
+ private void showAddKeyBlock(boolean show) {
+ addKey.setVisible(!show);
+ addKeyBlock.setVisible(show);
+ }
+
+ private void showKeyTable(boolean show) {
+ keys.setVisible(show);
+ deleteKey.setVisible(show);
+ addKey.setVisible(show);
+ }
+
+ private void doAddKey() {
+ if (keyText.getText().isEmpty()) {
+ return;
+ }
+ addButton.setEnabled(false);
+ keyText.setEnabled(false);
+ AccountApi.addGpgKey("self", keyText.getText(),
+ new AsyncCallback<NativeMap<GpgKeyInfo>>() {
+ @Override
+ public void onSuccess(NativeMap<GpgKeyInfo> result) {
+ keyText.setEnabled(true);
+ refreshKeys();
+ }
+
+ @Override
+ public void onFailure(Throwable caught) {
+ keyText.setEnabled(true);
+ addButton.setEnabled(true);
+ if (caught instanceof StatusCodeException) {
+ StatusCodeException sce = (StatusCodeException) caught;
+ if (sce.getStatusCode() == Response.SC_CONFLICT
+ || sce.getStatusCode() == Response.SC_BAD_REQUEST) {
+ errorText.setText(sce.getEncodedResponse());
+ } else {
+ errorText.setText(sce.getMessage());
+ }
+ } else {
+ errorText.setText(
+ "Unexpected error saving key: " + caught.getMessage());
+ }
+ errorPanel.setVisible(true);
+ }
+ });
+ }
+
+ private class GpgKeyTable extends FancyFlexTable<GpgKeyInfo> {
+ private final ValueChangeHandler<Boolean> updateDeleteHandler;
+
+ GpgKeyTable() {
+ table.setWidth("");
+ table.setText(0, 1, Util.C.gpgKeyId());
+ table.setText(0, 2, Util.C.gpgKeyFingerprint());
+ table.setText(0, 3, Util.C.gpgKeyUserIds());
+
+ FlexCellFormatter fmt = table.getFlexCellFormatter();
+ fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().iconHeader());
+ fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
+ fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
+ fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
+
+ updateDeleteHandler = new ValueChangeHandler<Boolean>() {
+ @Override
+ public void onValueChange(ValueChangeEvent<Boolean> event) {
+ updateDeleteButton();
+ }
+ };
+ }
+
+ private void addOneKey(GpgKeyInfo k) {
+ int row = table.getRowCount();
+ table.insertRow(row);
+ applyDataRowStyle(row);
+
+ CheckBox sel = new CheckBox();
+ sel.addValueChangeHandler(updateDeleteHandler);
+ table.setWidget(row, 0, sel);
+ table.setWidget(row, 1, new CopyableLabel(k.id()));
+ table.setText(row, 2, k.fingerprint());
+
+ VerticalPanel userIds = new VerticalPanel();
+ for (int i = 0; i < k.userIds().length(); i++) {
+ userIds.add(new InlineLabel(k.userIds().get(i)));
+ }
+ table.setWidget(row, 3, userIds);
+
+ FlexCellFormatter fmt = table.getFlexCellFormatter();
+ fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
+ fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
+ fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
+ fmt.addStyleName(row, 3, Gerrit.RESOURCES.css().dataCell());
+
+ setRowItem(row, k);
+ }
+
+ private void updateDeleteButton() {
+ for (int row = 1; row < table.getRowCount(); row++) {
+ if (isChecked(row)) {
+ deleteKey.setEnabled(true);
+ return;
+ }
+ }
+ deleteKey.setEnabled(false);
+ }
+
+ private void deleteChecked() {
+ deleteKey.setEnabled(false);
+ List<String> toDelete = new ArrayList<>(table.getRowCount());
+ for (int row = 1; row < table.getRowCount(); row++) {
+ if (isChecked(row)) {
+ toDelete.add(getRowItem(row).fingerprint());
+ }
+ }
+ AccountApi.removeGpgKeys("self", toDelete,
+ new GerritCallback<NativeMap<GpgKeyInfo>>() {
+ @Override
+ public void onSuccess(NativeMap<GpgKeyInfo> result) {
+ refreshKeys();
+ }
+
+ @Override
+ public void onFailure(Throwable caught) {
+ deleteKey.setEnabled(true);
+ super.onFailure(caught);
+ }
+ });
+ }
+
+ private boolean isChecked(int row) {
+ return ((CheckBox) table.getWidget(row, 0)).getValue();
+ }
+
+ private void clear() {
+ while (table.getRowCount() > 1) {
+ table.removeRow(1);
+ }
+ for (int i = table.getRowCount() - 1; i >= 1; i++) {
+ table.removeRow(i);
+ }
+ }
+ }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml
new file mode 100644
index 0000000..dc73736
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.ui.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+ xmlns:g='urn:import:com.google.gwt.user.client.ui'
+ xmlns:expui='urn:import:com.google.gwtexpui.globalkey.client'>
+ <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
+
+ <ui:style gss='false'>
+ .errorHeader {
+ font-weight: bold;
+ }
+ .errorText {
+ white-space: pre-wrap;
+ padding-bottom: 6px;
+ }
+ </ui:style>
+
+ <g:HTMLPanel>
+ <g:Widget ui:field='keys' addStyleNames='{res.css.sshKeyTable}'/>
+ <g:FlowPanel>
+ <g:Button ui:field='deleteKey'>
+ <div><ui:msg>Delete</ui:msg></div>
+ </g:Button>
+ <g:Button ui:field='addKey'>
+ <div><ui:msg>Add Key ...</ui:msg></div>
+ </g:Button>
+ </g:FlowPanel>
+ <g:VerticalPanel ui:field='addKeyBlock'
+ styleName='{res.css.addSshKeyPanel}'
+ visible='false'>
+ <g:Label>Add GPG Public Key</g:Label>
+ <g:DisclosurePanel>
+ <g:header>How to generate a GPG key</g:header>
+ <g:HTMLPanel>
+ <ol>
+ <li>
+ From the Terminal or Git Bash, run <em>gpg --gen-key</em> and
+ follow the prompts to create the key.
+ </li>
+ <li>
+ Use the default kind. Use the default (or higher) keysize. Choose
+ any value for your expiration.
+ </li>
+ <li>
+ The user ID should contain one of your registered email addresses.
+ </li>
+ <li>Setting a passphrase is strongly recommended.</li>
+ <li>Note the ID of your new key.</li>
+ <li>
+ To export your key, run the following and paste the full output
+ into the text box:
+ <br/>
+ <code>gpg --export -a <key ID></code>
+ </li>
+ </ol>
+ </g:HTMLPanel>
+ </g:DisclosurePanel>
+ <expui:NpTextArea
+ visibleLines='12'
+ characterWidth='80'
+ spellCheck='false'
+ ui:field='keyText'/>
+ <g:VerticalPanel ui:field='errorPanel' visible='false'>
+ <g:Label styleName='{style.errorHeader}'>Error adding GPG key:</g:Label>
+ <g:Label styleName='{style.errorText}' ui:field='errorText'/>
+ </g:VerticalPanel>
+ <g:FlowPanel>
+ <g:Button ui:field='clearButton'>
+ <div><ui:msg>Clear</ui:msg></div>
+ </g:Button>
+ <g:Button ui:field='addButton'>
+ <div><ui:msg>Add</ui:msg></div>
+ </g:Button>
+ <g:Button ui:field='closeButton'>
+ <div><ui:msg>Close</ui:msg></div>
+ </g:Button>
+ </g:FlowPanel>
+ </g:VerticalPanel>
+ </g:HTMLPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
index ac140ff..2f3a819 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
@@ -45,6 +45,9 @@
if (Gerrit.info().auth().isHttpPasswordSettingsEnabled()) {
linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
}
+ if (Gerrit.info().receive().enableSignedPush()) {
+ linkByGerrit(Util.C.tabGpgKeys(), PageLinks.SETTINGS_GPGKEYS);
+ }
linkByGerrit(Util.C.tabWebIdentities(), PageLinks.SETTINGS_WEBIDENT);
linkByGerrit(Util.C.tabMyGroups(), PageLinks.SETTINGS_MYGROUPS);
if (Gerrit.info().auth().useContributorAgreements()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
index 13b3a54..177faff0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
@@ -21,6 +21,7 @@
import com.google.gerrit.client.ui.ParentProjectBox;
import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.ProjectAccess;
+import com.google.gerrit.common.data.WebLinkInfoCommon;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
@@ -40,6 +41,7 @@
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Image;
import java.util.ArrayList;
import java.util.List;
@@ -65,7 +67,7 @@
DivElement history;
@UiField
- Anchor gitweb;
+ FlowPanel webLinkPanel;
@UiField
FlowPanel localContainer;
@@ -120,16 +122,7 @@
} else {
inheritsFrom.getStyle().setDisplay(Display.NONE);
}
-
- GitwebInfo c = Gerrit.info().gitweb();
- if (value.isConfigVisible() && c != null) {
- history.getStyle().setDisplay(Display.BLOCK);
- gitweb.setText(c.getLinkName());
- gitweb.setHref(c.toFileHistory(new Branch.NameKey(value.getProjectName(),
- RefNames.REFS_CONFIG), "project.config"));
- } else {
- history.getStyle().setDisplay(Display.NONE);
- }
+ setUpWebLinks();
addSection.setVisible(editing && (!value.getOwnerOf().isEmpty() || value.canUpload()));
}
@@ -162,6 +155,53 @@
addSection.setVisible(editing);
}
+ private void setUpWebLinks() {
+ if (!value.isConfigVisible()) {
+ history.getStyle().setDisplay(Display.NONE);
+ } else {
+ GitwebInfo c = Gerrit.info().gitweb();
+ List<WebLinkInfoCommon> links = value.getFileHistoryLinks();
+ if (c == null && links == null) {
+ history.getStyle().setDisplay(Display.NONE);
+ }
+ if (c != null) {
+ webLinkPanel.add(toAnchor(c.toFileHistory(new Branch.NameKey(value.getProjectName(),
+ RefNames.REFS_CONFIG), "project.config"), c.getLinkName()));
+ }
+
+ if (links != null) {
+ for (WebLinkInfoCommon link : links) {
+ webLinkPanel.add(toAnchor(link));
+ }
+ }
+ }
+ }
+
+ private Anchor toAnchor(String href, String name) {
+ Anchor a = new Anchor();
+ a.setHref(href);
+ a.setText(name);
+ return a;
+ }
+
+ private static Anchor toAnchor(WebLinkInfoCommon info) {
+ Anchor a = new Anchor();
+ a.setHref(info.url);
+ if (info.target != null && !info.target.isEmpty()) {
+ a.setTarget(info.target);
+ }
+ if (info.imageUrl != null && !info.imageUrl.isEmpty()) {
+ Image img = new Image();
+ img.setAltText(info.name);
+ img.setUrl(info.imageUrl);
+ img.setTitle(info.name);
+ a.getElement().appendChild(img.getElement());
+ } else {
+ a.setText("(" + info.name + ")");
+ }
+ return a;
+ }
+
private class Source extends EditorSource<AccessSectionEditor> {
private final FlowPanel container;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
index 0db4779..ebe6caf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
@@ -39,9 +39,12 @@
.historyTitle {
font-weight: bold;
}
- .gitwebLink {
+ .webLinkPanel a {
display: inline;
}
+ .webLinkPanel>a {
+ margin-left:2px;
+ }
.addContainer {
margin-top: 5px;
@@ -62,7 +65,9 @@
</div>
<div ui:field='history' class='{style.history}'>
<span class='{style.historyTitle}'><ui:msg>History:</ui:msg></span>
- <g:Anchor ui:field='gitweb' styleName='{style.gitwebLink}'></g:Anchor>
+ <td>
+ <g:FlowPanel ui:field="webLinkPanel" styleName='{style.webLinkPanel}'/>
+ </td>
</div>
<g:FlowPanel ui:field='localContainer'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index c26c437..0914efd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -1074,6 +1074,10 @@
width: 100%;
}
+.sshKeyTable td.dataCell, .sshKeyTable td.iconCell {
+ vertical-align: top;
+}
+
.createProjectPanel {
margin-bottom: 10px;
background-color: trimColor;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index 3615610..aee238d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -15,6 +15,7 @@
package com.google.gerrit.httpd.rpc.project;
import com.google.common.base.Predicate;
+import com.google.common.collect.FluentIterable;
import com.google.common.collect.Maps;
import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GroupDescription;
@@ -23,11 +24,13 @@
import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.common.data.ProjectAccess;
import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.WebLinkInfoCommon;
import com.google.gerrit.common.errors.NoSuchGroupException;
import com.google.gerrit.httpd.rpc.Handler;
import com.google.gerrit.reviewdb.client.AccountGroup;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.WebLinks;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupControl;
import com.google.gerrit.server.config.AllProjectsName;
@@ -64,6 +67,7 @@
private final Project.NameKey projectName;
private ProjectControl pc;
+ private WebLinks webLinks;
@Inject
ProjectAccessFactory(final GroupBackend groupBackend,
@@ -72,6 +76,7 @@
final GroupControl.Factory groupControlFactory,
final MetaDataUpdate.Server metaDataUpdateFactory,
final AllProjectsName allProjectsName,
+ final WebLinks webLinks,
@Assisted final Project.NameKey name) {
this.groupBackend = groupBackend;
@@ -80,6 +85,7 @@
this.groupControlFactory = groupControlFactory;
this.metaDataUpdateFactory = metaDataUpdateFactory;
this.allProjectsName = allProjectsName;
+ this.webLinks = webLinks;
this.projectName = name;
}
@@ -209,9 +215,17 @@
detail.setConfigVisible(pc.isOwner() || metaConfigControl.isVisible());
detail.setGroupInfo(buildGroupInfo(local));
detail.setLabelTypes(pc.getLabelTypes());
+ detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
return detail;
}
+ private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
+ FluentIterable<WebLinkInfoCommon> links =
+ webLinks.getFileHistoryLinksCommon(projectName, RefNames.REFS_CONFIG,
+ ProjectConfig.PROJECT_CONFIG);
+ return links.isEmpty() ? null : links.toList();
+ }
+
private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
Map<AccountGroup.UUID, GroupInfo> infos = new HashMap<>();
for (AccessSection section : local) {
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 8f9c726..41336791 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 @@
/** 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 c7bd8c9..ac3e291 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -88,6 +88,7 @@
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 @@
'//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/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index 1403e60..351de5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -18,11 +18,13 @@
import com.google.common.base.Predicate;
import com.google.common.base.Strings;
import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.data.WebLinkInfoCommon;
import com.google.gerrit.extensions.common.DiffWebLinkInfo;
import com.google.gerrit.extensions.common.WebLinkInfo;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.webui.BranchWebLink;
import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
import com.google.gerrit.extensions.webui.FileWebLink;
import com.google.gerrit.extensions.webui.PatchSetWebLink;
import com.google.gerrit.extensions.webui.ProjectWebLink;
@@ -53,9 +55,26 @@
return true;
}
};
+ private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
+ new Predicate<WebLinkInfoCommon>() {
+
+ @Override
+ public boolean apply(WebLinkInfoCommon link) {
+ if (link == null) {
+ return false;
+ } else if (Strings.isNullOrEmpty(link.name)
+ || Strings.isNullOrEmpty(link.url)) {
+ log.warn(String.format("%s is missing name and/or url", link
+ .getClass().getName()));
+ return false;
+ }
+ return true;
+ }
+ };
private final DynamicSet<PatchSetWebLink> patchSetLinks;
private final DynamicSet<FileWebLink> fileLinks;
+ private final DynamicSet<FileHistoryWebLink> fileHistoryLinks;
private final DynamicSet<DiffWebLink> diffLinks;
private final DynamicSet<ProjectWebLink> projectLinks;
private final DynamicSet<BranchWebLink> branchLinks;
@@ -63,11 +82,14 @@
@Inject
public WebLinks(DynamicSet<PatchSetWebLink> patchSetLinks,
DynamicSet<FileWebLink> fileLinks,
+ DynamicSet<FileHistoryWebLink> fileLogLinks,
DynamicSet<DiffWebLink> diffLinks,
DynamicSet<ProjectWebLink> projectLinks,
- DynamicSet<BranchWebLink> branchLinks) {
+ DynamicSet<BranchWebLink> branchLinks
+ ) {
this.patchSetLinks = patchSetLinks;
this.fileLinks = fileLinks;
+ this.fileHistoryLinks = fileLogLinks;
this.diffLinks = diffLinks;
this.projectLinks = projectLinks;
this.branchLinks = branchLinks;
@@ -111,6 +133,46 @@
/**
*
* @param project Project name.
+ * @param revision SHA1 of revision.
+ * @param file File name.
+ * @return Links for file history
+ */
+ public FluentIterable<WebLinkInfo> getFileHistoryLinks(final String project,
+ final String revision, final String file) {
+ return filterLinks(fileHistoryLinks, new Function<WebLink, WebLinkInfo>() {
+
+ @Override
+ public WebLinkInfo apply(WebLink webLink) {
+ return ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
+ revision, file);
+ }
+ });
+ }
+
+ public FluentIterable<WebLinkInfoCommon> getFileHistoryLinksCommon(
+ final String project, final String revision, final String file) {
+ return FluentIterable
+ .from(fileHistoryLinks)
+ .transform(new Function<WebLink, WebLinkInfoCommon>() {
+ @Override
+ public WebLinkInfoCommon apply(WebLink webLink) {
+ WebLinkInfo info =
+ ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
+ revision, file);
+ WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
+ commonInfo.name = info.name;
+ commonInfo.imageUrl = info.imageUrl;
+ commonInfo.url = info.url;
+ commonInfo.target = info.target;
+ return commonInfo;
+ }
+ })
+ .filter(INVALID_WEBLINK_COMMON);
+ }
+
+ /**
+ *
+ * @param project Project name.
* @param patchSetIdA Patch set ID of side A, <code>null</code> if no base
* patch set was selected.
* @param revisionA SHA1 of revision of side A.
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 75e5ae5..7b5bead 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.change.ChangeResource;
import com.google.inject.TypeLiteral;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+
public class AccountResource implements RestResource {
public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
new TypeLiteral<RestView<AccountResource>>() {};
@@ -35,6 +37,9 @@
public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND =
new TypeLiteral<RestView<SshKey>>() {};
+ public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND =
+ new TypeLiteral<RestView<GpgKey>>() {};
+
public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
new TypeLiteral<RestView<StarredChange>>() {};
@@ -96,6 +101,19 @@
}
}
+ 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/DeleteGpgKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteGpgKey.java
new file mode 100644
index 0000000..b7e63cf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteGpgKey.java
@@ -0,0 +1,91 @@
+// 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.keyIdToString;
+
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+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.AccountResource.GpgKey;
+import com.google.gerrit.server.account.DeleteGpgKey.Input;
+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 org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+
+import java.io.IOException;
+import java.util.Collections;
+
+public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
+ public static class Input {
+ }
+
+ private final Provider<PersonIdent> serverIdent;
+ private final Provider<ReviewDb> db;
+ private final Provider<PublicKeyStore> storeProvider;
+
+ @Inject
+ DeleteGpgKey(@GerritPersonIdent Provider<PersonIdent> serverIdent,
+ Provider<ReviewDb> db,
+ Provider<PublicKeyStore> storeProvider) {
+ this.serverIdent = serverIdent;
+ this.db = db;
+ this.storeProvider = storeProvider;
+ }
+
+ @Override
+ public Response<?> apply(GpgKey rsrc, Input input)
+ throws ResourceConflictException, PGPException, OrmException,
+ IOException {
+ PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
+ AccountExternalId.Key extIdKey = new AccountExternalId.Key(
+ AccountExternalId.SCHEME_GPGKEY,
+ BaseEncoding.base16().encode(key.getFingerprint()));
+ db.get().accountExternalIds().deleteKeys(Collections.singleton(extIdKey));
+
+ try (PublicKeyStore store = storeProvider.get()) {
+ store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+
+ CommitBuilder cb = new CommitBuilder();
+ PersonIdent committer = serverIdent.get();
+ cb.setAuthor(rsrc.getUser().newCommitterIdent(
+ committer.getWhen(), committer.getTimeZone()));
+ cb.setCommitter(committer);
+ cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+ RefUpdate.Result saveResult = store.save(cb);
+ switch (saveResult) {
+ case NO_CHANGE:
+ case FAST_FORWARD:
+ break;
+ default:
+ throw new ResourceConflictException(
+ "Failed to delete public key: " + saveResult);
+ }
+ }
+ return Response.none();
+ }
+}
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 0000000..bbcdd06
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GpgKeys.java
@@ -0,0 +1,229 @@
+// 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.Fingerprint;
+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.eclipse.jgit.util.NB;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+@Singleton
+public class GpgKeys implements
+ ChildCollection<AccountResource, AccountResource.GpgKey> {
+ private static final Logger log = LoggerFactory.getLogger(GpgKeys.class);
+
+ public static String MIME_TYPE = "application/pgp-keys";
+
+ private final DynamicMap<RestView<AccountResource.GpgKey>> views;
+ private final Provider<ReviewDb> db;
+ private final Provider<PublicKeyStore> storeProvider;
+
+ @Inject
+ GpgKeys(DynamicMap<RestView<AccountResource.GpgKey>> views,
+ Provider<ReviewDb> db,
+ Provider<PublicKeyStore> 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 = parseFingerprint(id.get(), getGpgExtIds(parent));
+ 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);
+ }
+
+ static byte[] parseFingerprint(String str,
+ Iterable<AccountExternalId> existingExtIds)
+ throws ResourceNotFoundException {
+ str = CharMatcher.WHITESPACE.removeFrom(str).toUpperCase();
+ if ((str.length() != 8 && str.length() != 40)
+ || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
+ throw new ResourceNotFoundException(str);
+ }
+ byte[] fp = null;
+ for (AccountExternalId extId : existingExtIds) {
+ String fpStr = extId.getSchemeRest();
+ if (!fpStr.endsWith(str)) {
+ continue;
+ } else if (fp != null) {
+ throw new ResourceNotFoundException("Multiple keys found for " + str);
+ }
+ fp = BaseEncoding.base16().decode(fpStr);
+ if (str.length() == 40) {
+ break;
+ }
+ }
+ if (fp == null) {
+ throw new ResourceNotFoundException(str);
+ }
+ return fp;
+ }
+
+ @Override
+ public DynamicMap<RestView<GpgKey>> views() {
+ return views;
+ }
+
+ public class ListGpgKeys implements RestReadView<AccountResource> {
+ @Override
+ public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
+ throws OrmException, PGPException, IOException {
+ Map<String, GpgKeyInfo> 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 {}",
+ Fingerprint.toString(fp));
+ }
+ }
+ }
+ return keys;
+ }
+ }
+
+ @Singleton
+ public static class Get implements RestReadView<AccountResource.GpgKey> {
+ @Override
+ public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
+ return toJson(rsrc.getKeyRing());
+ }
+ }
+
+ @VisibleForTesting
+ public static FluentIterable<AccountExternalId> getGpgExtIds(ReviewDb db,
+ Account.Id accountId) throws OrmException {
+ return FluentIterable
+ .from(db.accountExternalIds().byAccount(accountId))
+ .filter(new Predicate<AccountExternalId>() {
+ @Override
+ public boolean apply(AccountExternalId in) {
+ return in.isScheme(SCHEME_GPGKEY);
+ }
+ });
+ }
+
+ private Iterable<AccountExternalId> getGpgExtIds(AccountResource rsrc)
+ throws OrmException {
+ return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
+ }
+
+ private static long keyId(byte[] fp) {
+ return NB.decodeInt64(fp, 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 = Fingerprint.toString(key.getFingerprint());
+ @SuppressWarnings("unchecked")
+ Iterator<String> 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 553392d..137a8fe 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 @@
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 @@
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,20 @@
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);
+ delete(GPG_KEY_KIND).to(DeleteGpgKey.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 0000000..776f983
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostGpgKeys.java
@@ -0,0 +1,237 @@
+// 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.keyIdToString;
+import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyToString;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+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.Fingerprint;
+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;
+import java.util.Set;
+
+@Singleton
+public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
+ public static class Input {
+ public List<String> add;
+ public List<String> delete;
+ }
+
+ private final Provider<PersonIdent> serverIdent;
+ private final Provider<ReviewDb> db;
+ private final Provider<PublicKeyStore> storeProvider;
+ private final PublicKeyChecker checker;
+
+ @Inject
+ PostGpgKeys(@GerritPersonIdent Provider<PersonIdent> serverIdent,
+ Provider<ReviewDb> db,
+ Provider<PublicKeyStore> storeProvider,
+ PublicKeyChecker checker) {
+ this.serverIdent = serverIdent;
+ this.db = db;
+ this.storeProvider = storeProvider;
+ this.checker = checker;
+ }
+
+ @Override
+ public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
+ throws ResourceNotFoundException, BadRequestException,
+ ResourceConflictException, PGPException, OrmException, IOException {
+ GpgKeys.checkEnabled();
+
+ List<AccountExternalId> existingExtIds =
+ GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
+
+ try (PublicKeyStore store = storeProvider.get()) {
+ Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
+ List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
+ List<AccountExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
+
+ for (PGPPublicKeyRing keyRing : newKeys) {
+ PGPPublicKey key = keyRing.getPublicKey();
+ AccountExternalId.Key extIdKey = toExtIdKey(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, toRemove);
+ if (!newExtIds.isEmpty()) {
+ db.get().accountExternalIds().insert(newExtIds);
+ }
+ db.get().accountExternalIds().deleteKeys(Iterables.transform(toRemove,
+ new Function<Fingerprint, AccountExternalId.Key>() {
+ @Override
+ public AccountExternalId.Key apply(Fingerprint fp) {
+ return toExtIdKey(fp.get());
+ }
+ }));
+ return toJson(newKeys, toRemove);
+ }
+ }
+
+ private Set<Fingerprint> readKeysToRemove(Input input,
+ List<AccountExternalId> existingExtIds) {
+ if (input.delete == null || input.delete.isEmpty()) {
+ return ImmutableSet.of();
+ }
+ Set<Fingerprint> fingerprints =
+ Sets.newHashSetWithExpectedSize(input.delete.size());
+ for (String id : input.delete) {
+ try {
+ fingerprints.add(new Fingerprint(
+ GpgKeys.parseFingerprint(id, existingExtIds)));
+ } catch (ResourceNotFoundException e) {
+ // Skip removal.
+ }
+ }
+ return fingerprints;
+ }
+
+ private List<PGPPublicKeyRing> readKeysToAdd(Input input,
+ Set<Fingerprint> toRemove)
+ throws BadRequestException, IOException {
+ if (input.add == null || input.add.isEmpty()) {
+ return ImmutableList.of();
+ }
+ List<PGPPublicKeyRing> 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<Object> objs = Lists.newArrayList(new BcPGPObjectFactory(ain));
+ if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) {
+ throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
+ }
+ PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
+ if (toRemove.contains(
+ new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
+ throw new BadRequestException("Cannot both add and delete key: "
+ + keyToString(keyRing.getPublicKey()));
+ }
+ keyRings.add(keyRing);
+ }
+ }
+ return keyRings;
+ }
+
+ private void storeKeys(AccountResource rsrc, List<PGPPublicKeyRing> keyRings,
+ Set<Fingerprint> toRemove) 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);
+ }
+ for (Fingerprint fp : toRemove) {
+ store.remove(fp.get());
+ }
+ 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:
+ case NO_CHANGE:
+ break;
+ default:
+ // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
+ throw new ResourceConflictException(
+ "Failed to save public keys: " + saveResult);
+ }
+ }
+ }
+
+ private final AccountExternalId.Key toExtIdKey(byte[] fp) {
+ return new AccountExternalId.Key(
+ AccountExternalId.SCHEME_GPGKEY,
+ BaseEncoding.base16().encode(fp));
+ }
+
+ private static Map<String, GpgKeyInfo> toJson(
+ Collection<PGPPublicKeyRing> keys,
+ Set<Fingerprint> deleted) throws IOException {
+ Map<String, GpgKeyInfo> infos =
+ Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
+ for (PGPPublicKeyRing keyRing : keys) {
+ GpgKeyInfo info = GpgKeys.toJson(keyRing);
+ infos.put(info.id, info);
+ info.id = null;
+ }
+ for (Fingerprint fp : deleted) {
+ infos.put(keyIdToString(fp.getId()), new GpgKeyInfo());
+ }
+ 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 8527e5f..85e519b 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 @@
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.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 @@
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 @@
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 @@
this.starredChangesCreate = starredChangesCreate;
this.starredChangesDelete = starredChangesDelete;
this.createEmailFactory = createEmailFactory;
+ this.postGpgKeys = postGpgKeys;
+ this.gpgKeys = gpgKeys;
+ this.gpgKeyApiFactory = gpgKeyApiFactory;
}
@Override
@@ -108,4 +127,36 @@
throw new RestApiException("Cannot add email", e);
}
}
+
+ @Override
+ public Map<String, GpgKeyInfo> 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<String, GpgKeyInfo> putGpgKeys(List<String> add,
+ List<String> delete) throws RestApiException {
+ PostGpgKeys.Input in = new PostGpgKeys.Input();
+ in.add = add;
+ in.delete = delete;
+ 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 0000000..e42c2f6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgKeyApiImpl.java
@@ -0,0 +1,67 @@
+// 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.DeleteGpgKey;
+import com.google.gerrit.server.account.GpgKeys;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.bouncycastle.openpgp.PGPException;
+
+import java.io.IOException;
+
+class GpgKeyApiImpl implements GpgKeyApi {
+ interface Factory {
+ GpgKeyApiImpl create(AccountResource.GpgKey rsrc);
+ }
+
+ private final GpgKeys.Get get;
+ private final DeleteGpgKey delete;
+ private final AccountResource.GpgKey rsrc;
+
+ @AssistedInject
+ GpgKeyApiImpl(
+ GpgKeys.Get get,
+ DeleteGpgKey delete,
+ @Assisted AccountResource.GpgKey rsrc) {
+ this.get = get;
+ this.delete = delete;
+ 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);
+ }
+ }
+
+ @Override
+ public void delete() throws RestApiException {
+ try {
+ delete.apply(rsrc, new DeleteGpgKey.Input());
+ } catch (PGPException | OrmException | IOException e) {
+ throw new RestApiException("Cannot delete 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 5e3855e..a9dd9d6 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 @@
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/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index abbc640..aeed0a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -14,16 +14,18 @@
package com.google.gerrit.server.change;
-import com.google.common.base.Strings;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.gerrit.extensions.common.ActionInfo;
import com.google.gerrit.extensions.restapi.ETagView;
import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.git.ChangeSet;
+import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.project.ProjectControl;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.OrmRuntimeException;
import com.google.inject.Inject;
@@ -32,21 +34,25 @@
import org.eclipse.jgit.lib.Config;
+import java.io.IOException;
import java.util.Map;
@Singleton
public class GetRevisionActions implements ETagView<RevisionResource> {
private final ActionJson delegate;
- private final Provider<InternalChangeQuery> queryProvider;
private final Config config;
+ private final Provider<ReviewDb> dbProvider;
+ private final MergeSuperSet mergeSuperSet;
@Inject
GetRevisionActions(
ActionJson delegate,
- Provider<InternalChangeQuery> queryProvider,
+ Provider<ReviewDb> dbProvider,
+ MergeSuperSet mergeSuperSet,
@GerritServerConfig Config config) {
this.delegate = delegate;
- this.queryProvider = queryProvider;
+ this.dbProvider = dbProvider;
+ this.mergeSuperSet = mergeSuperSet;
this.config = config;
}
@@ -57,18 +63,19 @@
@Override
public String getETag(RevisionResource rsrc) {
- String topic = rsrc.getChange().getTopic();
- if (!Submit.wholeTopicEnabled(config)
- || Strings.isNullOrEmpty(topic)) {
- return rsrc.getETag();
- }
Hasher h = Hashing.md5().newHasher();
CurrentUser user = rsrc.getControl().getCurrentUser();
try {
- for (ChangeData c : queryProvider.get().byTopicOpen(topic)) {
- new ChangeResource(c.changeControl()).prepareETag(h, user);
+ rsrc.getChangeResource().prepareETag(h, user);
+ h.putBoolean(Submit.wholeTopicEnabled(config));
+ ReviewDb db = dbProvider.get();
+ ChangeSet cs = mergeSuperSet.completeChangeSet(db,
+ ChangeSet.create(rsrc.getChange()));
+ ProjectControl ctl = rsrc.getControl().getProjectControl();
+ for (Change c : cs.changes()) {
+ new ChangeResource(ctl.controlFor(c)).prepareETag(h, user);
}
- } catch (OrmException e){
+ } catch (IOException | OrmException e) {
throw new OrmRuntimeException(e);
}
return h.hash().toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 478febe..a452561 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -37,6 +37,7 @@
import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
import com.google.gerrit.extensions.webui.BranchWebLink;
import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
import com.google.gerrit.extensions.webui.FileWebLink;
import com.google.gerrit.extensions.webui.PatchSetWebLink;
import com.google.gerrit.extensions.webui.ProjectWebLink;
@@ -285,6 +286,7 @@
DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
DynamicSet.setOf(binder(), PatchSetWebLink.class);
DynamicSet.setOf(binder(), FileWebLink.class);
+ DynamicSet.setOf(binder(), FileHistoryWebLink.class);
DynamicSet.setOf(binder(), DiffWebLink.class);
DynamicSet.setOf(binder(), ProjectWebLink.class);
DynamicSet.setOf(binder(), BranchWebLink.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index a18a3a3..531db79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -37,6 +37,7 @@
ImmutableSetMultimap.builder();
ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> cbb =
ImmutableSetMultimap.builder();
+ ImmutableSet.Builder<Change> cb = ImmutableSet.builder();
for (Change c : changes) {
Branch.NameKey branch = c.getDest();
@@ -48,10 +49,11 @@
pbb.put(project, branch);
pcb.put(project, c.getId());
cbb.put(branch, c.getId());
+ cb.add(c);
}
return new AutoValue_ChangeSet(pb.build(), bb.build(), ib.build(),
- psb.build(), pbb.build(), pcb.build(), cbb.build());
+ psb.build(), pbb.build(), pcb.build(), cbb.build(), cb.build());
}
public static ChangeSet create(Change change) {
@@ -68,6 +70,7 @@
changesByProject();
public abstract ImmutableSetMultimap<Branch.NameKey, Change.Id>
changesByBranch();
+ public abstract ImmutableSet<Change> changes();
@Override
public int hashCode() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/Fingerprint.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/Fingerprint.java
new file mode 100644
index 0000000..bc70cb3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/Fingerprint.java
@@ -0,0 +1,84 @@
+// 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.git.gpg;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import org.eclipse.jgit.util.NB;
+
+import java.util.Arrays;
+
+public class Fingerprint {
+ private final byte[] fp;
+
+ public static String toString(byte[] fp) {
+ checkLength(fp);
+ return String.format(
+ "%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X",
+ NB.decodeUInt16(fp, 0), NB.decodeUInt16(fp, 2), NB.decodeUInt16(fp, 4),
+ NB.decodeUInt16(fp, 6), NB.decodeUInt16(fp, 8), NB.decodeUInt16(fp, 10),
+ NB.decodeUInt16(fp, 12), NB.decodeUInt16(fp, 14),
+ NB.decodeUInt16(fp, 16), NB.decodeUInt16(fp, 18));
+ }
+
+ private static byte[] checkLength(byte[] fp) {
+ checkArgument(fp.length == 20,
+ "fingerprint must be 20 bytes, got %s", fp.length);
+ return fp;
+ }
+
+ /**
+ * Wrap a fingerprint byte array.
+ * <p>
+ * The newly created Fingerprint object takes ownership of the byte array,
+ * which must not be subsequently modified. (Most callers, such as hex
+ * decoders and {@code
+ * org.bouncycastle.openpgp.PGPPublicKey#getFingerprint()}, already produce
+ * fresh byte arrays).
+ *
+ * @param fp 20-byte fingerprint byte array to wrap.
+ */
+ public Fingerprint(byte[] fp) {
+ this.fp = checkLength(fp);
+ }
+
+ public byte[] get() {
+ return fp;
+ }
+
+ public boolean equalsBytes(byte[] bytes) {
+ return Arrays.equals(fp, bytes);
+ }
+
+ @Override
+ public int hashCode() {
+ // Same hash code as ObjectId: second int word.
+ return NB.decodeInt32(fp, 4);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (o instanceof Fingerprint) && equalsBytes(((Fingerprint) o).fp);
+ }
+
+ @Override
+ public String toString() {
+ return toString(fp);
+ }
+
+ public long getId() {
+ return NB.decodeInt64(fp, 12);
+ }
+}
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
new file mode 100644
index 0000000..851808c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/GerritPublicKeyChecker.java
@@ -0,0 +1,150 @@
+// 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.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;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
+import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Checker for GPG public keys including Gerrit-specific checks.
+ * <p>
+ * For Gerrit, keys must contain a self-signed user ID certification matching a
+ * trusted external ID in the database, or an email address thereof.
+ */
+@Singleton
+public class GerritPublicKeyChecker extends PublicKeyChecker {
+ private static final Logger log =
+ LoggerFactory.getLogger(GerritPublicKeyChecker.class);
+
+ private final String webUrl;
+ private final Provider<IdentifiedUser> userProvider;
+
+ @Inject
+ GerritPublicKeyChecker(
+ @CanonicalWebUrl String webUrl,
+ Provider<IdentifiedUser> userProvider) {
+ this.webUrl = webUrl;
+ this.userProvider = userProvider;
+ }
+
+ @Override
+ public void checkCustom(PGPPublicKey key, long expectedKeyId,
+ List<String> problems) {
+ try {
+ Set<String> allowedUserIds = getAllowedUserIds();
+ if (allowedUserIds.isEmpty()) {
+ problems.add("No identities found for user; check "
+ + webUrl + "#" + PageLinks.SETTINGS_WEBIDENT);
+ return;
+ }
+
+ @SuppressWarnings("unchecked")
+ Iterator<String> userIds = key.getUserIDs();
+ while (userIds.hasNext()) {
+ String userId = userIds.next();
+ if (isAllowed(userId, allowedUserIds)) {
+ @SuppressWarnings("unchecked")
+ Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
+ while (sigs.hasNext()) {
+ if (isValidCertification(key, sigs.next(), userId)) {
+ return;
+ }
+ }
+ }
+ }
+
+ problems.add(missingUserIds(allowedUserIds));
+ } catch (PGPException e) {
+ String msg = "Error checking user IDs for key";
+ log.warn(msg + " " + keyIdToString(key.getKeyID()), e);
+ problems.add(msg);
+ }
+ }
+
+ private Set<String> getAllowedUserIds() {
+ IdentifiedUser user = userProvider.get();
+ Set<String> 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;
+ }
+
+ private static boolean isAllowed(String userId, Set<String> allowedUserIds) {
+ return allowedUserIds.contains(userId)
+ || allowedUserIds.contains(
+ PushCertificateIdent.parse(userId).getEmailAddress());
+ }
+
+ private static boolean isValidCertification(PGPPublicKey key,
+ PGPSignature sig, String userId) throws PGPException {
+ if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
+ && sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
+ return false;
+ }
+ if (sig.getKeyID() != key.getKeyID()) {
+ return false;
+ }
+ // TODO(dborowitz): Handle certification revocations:
+ // - Is there a revocation by either this key or another key trusted by the
+ // server?
+ // - Does such a revocation postdate all other valid certifications?
+
+ sig.init(new BcPGPContentVerifierBuilderProvider(), key);
+ return sig.verifyCertification(userId, key);
+ }
+
+ private static String missingUserIds(Set<String> allowedUserIds) {
+ StringBuilder sb = new StringBuilder("Key must contain a valid"
+ + " certification for one of the following identities:\n");
+ Iterator<String> sorted = FluentIterable.from(allowedUserIds)
+ .toSortedList(Ordering.natural())
+ .iterator();
+ while (sorted.hasNext()) {
+ sb.append(" ").append(sorted.next());
+ if (sorted.hasNext()) {
+ sb.append('\n');
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyChecker.java
index 5806e8e..62cb563 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/PublicKeyChecker.java
@@ -27,6 +27,15 @@
* Check a public key.
*
* @param key the public key.
+ */
+ public final CheckResult check(PGPPublicKey key) {
+ return check(key, key.getKeyID());
+ }
+
+ /**
+ * Check a public key.
+ *
+ * @param key the public key.
* @param expectedKeyId the key ID that the caller expects.
*/
public final CheckResult check(PGPPublicKey key, long expectedKeyId) {
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 7327c87..7151914 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
@@ -20,27 +20,38 @@
import com.google.gerrit.reviewdb.client.RefNames;
import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.NB;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.ByteBuffer;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
/**
* Store of GPG public keys in git notes.
@@ -59,13 +70,21 @@
* only trust keys after checking with a {@link PublicKeyChecker}.
*/
public class PublicKeyStore implements AutoCloseable {
+ private static final ObjectId EMPTY_TREE =
+ ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
+
private final Repository repo;
private ObjectReader reader;
+ private RevCommit tip;
private NoteMap notes;
+ private Map<Fingerprint, PGPPublicKeyRing> toAdd;
+ private Set<Fingerprint> toRemove;
/** @param repo repository to read keys from. */
public PublicKeyStore(Repository repo) {
this.repo = repo;
+ toAdd = new HashMap<>();
+ toRemove = new HashSet<>();
}
@Override
@@ -86,7 +105,8 @@
return;
}
try (RevWalk rw = new RevWalk(reader)) {
- notes = NoteMap.read(reader, rw.parseCommit(ref.getObjectId()));
+ tip = rw.parseCommit(ref.getObjectId());
+ notes = NoteMap.read(reader, tip);
}
}
@@ -136,7 +156,171 @@
}
}
- // TODO(dborowitz): put method.
+ /**
+ * Add a public key to the store.
+ * <p>
+ * Multiple calls may be made to buffer keys in memory, and they are not saved
+ * until {@link #save(CommitBuilder)} is called.
+ *
+ * @param keyRing a key ring containing exactly one public master key.
+ */
+ public void add(PGPPublicKeyRing keyRing) {
+ int numMaster = 0;
+ for (PGPPublicKey key : keyRing) {
+ if (key.isMasterKey()) {
+ numMaster++;
+ }
+ }
+ // We could have an additional sanity check to ensure all subkeys belong to
+ // this master key, but that requires doing actual signature verification
+ // here. The alternative is insane but harmless.
+ if (numMaster != 1) {
+ throw new IllegalArgumentException(
+ "Exactly 1 master key is required, found " + numMaster);
+ }
+ Fingerprint fp = new Fingerprint(keyRing.getPublicKey().getFingerprint());
+ toAdd.put(fp, keyRing);
+ toRemove.remove(fp);
+ }
+
+ /**
+ * Remove a public key from the store.
+ * <p>
+ * Multiple calls may be made to buffer deletes in memory, and they are not
+ * saved until {@link #save(CommitBuilder)} is called.
+ *
+ * @param fingerprint the fingerprint of the key to remove.
+ */
+ public void remove(byte[] fingerprint) {
+ Fingerprint fp = new Fingerprint(fingerprint);
+ toAdd.remove(fp);
+ toRemove.add(fp);
+ }
+
+ /**
+ * Save pending keys to the store.
+ * <p>
+ * One commit is created and the ref updated. The pending list is cleared if
+ * and only if the ref update succeeds, which allows for easy retries in case
+ * of lock failure.
+ *
+ * @param cb commit builder with at least author and identity populated; tree
+ * and parent are ignored.
+ * @return result of the ref update.
+ */
+ public RefUpdate.Result save(CommitBuilder cb)
+ throws PGPException, IOException {
+ if (toAdd.isEmpty() && toRemove.isEmpty()) {
+ return RefUpdate.Result.NO_CHANGE;
+ }
+ if (reader == null) {
+ load();
+ }
+ if (notes == null) {
+ notes = NoteMap.newEmptyMap();
+ }
+ ObjectId newTip;
+ try (ObjectInserter ins = repo.newObjectInserter()) {
+ for (PGPPublicKeyRing keyRing : toAdd.values()) {
+ saveToNotes(ins, keyRing);
+ }
+ for (Fingerprint fp : toRemove) {
+ deleteFromNotes(ins, fp);
+ }
+ cb.setTreeId(notes.writeTree(ins));
+ if (cb.getTreeId().equals(
+ tip != null ? tip.getTree() : EMPTY_TREE)) {
+ return RefUpdate.Result.NO_CHANGE;
+ }
+
+ if (tip != null) {
+ cb.setParentId(tip);
+ }
+ if (cb.getMessage() == null) {
+ int n = toAdd.size() + toRemove.size();
+ cb.setMessage(
+ String.format("Update %d public key%s", n, n != 1 ? "s" : ""));
+ }
+ newTip = ins.insert(cb);
+ ins.flush();
+ }
+
+ RefUpdate ru = repo.updateRef(RefNames.REFS_GPG_KEYS);
+ ru.setExpectedOldObjectId(tip);
+ ru.setNewObjectId(newTip);
+ ru.setRefLogIdent(cb.getCommitter());
+ ru.setRefLogMessage("Store public keys", true);
+ RefUpdate.Result result = ru.update();
+ close();
+ switch (result) {
+ case FAST_FORWARD:
+ case NEW:
+ case NO_CHANGE:
+ toAdd.clear();
+ toRemove.clear();
+ break;
+ default:
+ break;
+ }
+ return result;
+ }
+
+ private void saveToNotes(ObjectInserter ins, PGPPublicKeyRing keyRing)
+ throws PGPException, IOException {
+ long keyId = keyRing.getPublicKey().getKeyID();
+ PGPPublicKeyRingCollection existing = get(keyId);
+ List<PGPPublicKeyRing> toWrite = new ArrayList<>(existing.size() + 1);
+ boolean replaced = false;
+ for (PGPPublicKeyRing kr : existing) {
+ if (sameKey(keyRing, kr)) {
+ toWrite.add(keyRing);
+ replaced = true;
+ } else {
+ toWrite.add(kr);
+ }
+ }
+ if (!replaced) {
+ toWrite.add(keyRing);
+ }
+ notes.set(keyObjectId(keyId),
+ ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+ }
+
+ private void deleteFromNotes(ObjectInserter ins, Fingerprint fp)
+ throws PGPException, IOException {
+ long keyId = fp.getId();
+ PGPPublicKeyRingCollection existing = get(keyId);
+ List<PGPPublicKeyRing> toWrite = new ArrayList<>(existing.size());
+ for (PGPPublicKeyRing kr : existing) {
+ if (!fp.equalsBytes(kr.getPublicKey().getFingerprint())) {
+ toWrite.add(kr);
+ }
+ }
+ if (toWrite.size() == existing.size()) {
+ return;
+ } else if (toWrite.size() > 0) {
+ notes.set(keyObjectId(keyId),
+ ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+ } else {
+ notes.remove(keyObjectId(keyId));
+ }
+ }
+
+ private static boolean sameKey(PGPPublicKeyRing kr1, PGPPublicKeyRing kr2) {
+ return Arrays.equals(kr1.getPublicKey().getFingerprint(),
+ kr2.getPublicKey().getFingerprint());
+ }
+
+ private static byte[] keysToArmored(List<PGPPublicKeyRing> keys)
+ throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(4096 * keys.size());
+ for (PGPPublicKeyRing kr : keys) {
+ try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
+ kr.encode(aout);
+ }
+ }
+ return out.toByteArray();
+ }
private static PGPPublicKeyRingCollection empty()
throws PGPException, IOException {
@@ -144,27 +328,24 @@
Collections.<PGPPublicKeyRing> emptyList());
}
- static String keyToString(PGPPublicKey key) {
+ public static String keyToString(PGPPublicKey key) {
@SuppressWarnings("unchecked")
Iterator<String> 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() + " " : "",
- buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(),
- buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(),
- buf.getShort(), buf.getShort());
+ Fingerprint.toString(key.getFingerprint()));
}
- static String keyIdToString(long keyId) {
+ public 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);
- return ObjectId.fromRaw(buf.array());
+ byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+ NB.encodeInt64(buf, 0, keyId);
+ return ObjectId.fromRaw(buf);
}
}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushModule.java
index 6e7cc5f..88bda9a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushModule.java
@@ -18,22 +18,28 @@
import com.google.common.collect.Lists;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.ReceivePackInitializer;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.util.BouncyCastleUtil;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.PreReceiveHookChain;
import org.eclipse.jgit.transport.ReceivePack;
import org.eclipse.jgit.transport.SignedPushConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Random;
@@ -48,13 +54,15 @@
@Override
protected void configure() {
- if (BouncyCastleUtil.havePGP()) {
- DynamicSet.bind(binder(), ReceivePackInitializer.class)
- .to(Initializer.class);
- } else {
+ if (!BouncyCastleUtil.havePGP()) {
log.info("BouncyCastle PGP not installed; signed push verification is"
+ " disabled");
+ return;
}
+ bind(PublicKeyChecker.class).to(GerritPublicKeyChecker.class);
+ bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+ DynamicSet.bind(binder(), ReceivePackInitializer.class)
+ .to(Initializer.class);
}
@Singleton
@@ -102,6 +110,39 @@
}
}
+ @Singleton
+ private static class StoreProvider implements Provider<PublicKeyStore> {
+ private final GitRepositoryManager repoManager;
+ private final AllUsersName allUsers;
+
+ @Inject
+ StoreProvider(GitRepositoryManager repoManager,
+ AllUsersName allUsers) {
+ this.repoManager = repoManager;
+ this.allUsers = allUsers;
+ }
+
+ @Override
+ public PublicKeyStore get() {
+ final Repository repo;
+ try {
+ repo = repoManager.openRepository(allUsers);
+ } catch (IOException e) {
+ throw new ProvisionException("Cannot open " + allUsers, e);
+ }
+ return new PublicKeyStore(repo) {
+ @Override
+ public void close() {
+ try {
+ super.close();
+ } finally {
+ repo.close();
+ }
+ }
+ };
+ }
+ }
+
private static String randomString(int len) {
Random random;
try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushPreReceiveHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushPreReceiveHook.java
index c6889b5..f5e9f09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushPreReceiveHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/gpg/SignedPushPreReceiveHook.java
@@ -45,13 +45,16 @@
private final GitRepositoryManager repoManager;
private final AllUsersName allUsers;
+ private final PublicKeyChecker keyChecker;
@Inject
public SignedPushPreReceiveHook(
GitRepositoryManager repoManager,
- AllUsersName allUsers) {
+ AllUsersName allUsers,
+ PublicKeyChecker keyChecker) {
this.repoManager = repoManager;
this.allUsers = allUsers;
+ this.keyChecker = keyChecker;
}
@Override
@@ -62,18 +65,17 @@
if (cert == null) {
return;
}
- PushCertificateChecker checker = new PushCertificateChecker(
- new PublicKeyChecker()) {
- @Override
- protected Repository getRepository() throws IOException {
- return repoManager.openRepository(allUsers);
- }
+ PushCertificateChecker checker = new PushCertificateChecker(keyChecker) {
+ @Override
+ protected Repository getRepository() throws IOException {
+ return repoManager.openRepository(allUsers);
+ }
- @Override
- protected boolean shouldClose(Repository repo) {
- return true;
- }
- };
+ @Override
+ protected boolean shouldClose(Repository repo) {
+ return true;
+ }
+ };
CheckResult result = checker.check(cert);
if (!result.isOk()) {
for (String problem : result.getProblems()) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/GerritPublicKeyCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/GerritPublicKeyCheckerTest.java
new file mode 100644
index 0000000..4b00d5c
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/GerritPublicKeyCheckerTest.java
@@ -0,0 +1,201 @@
+// 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.git.gpg;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.lifecycle.LifecycleManager;
+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.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+
+/** Unit tests for {@link GerritPublicKeyChecker}. */
+public class GerritPublicKeyCheckerTest {
+ @Inject
+ private AccountCache accountCache;
+
+ @Inject
+ private AccountManager accountManager;
+
+ @Inject
+ private GerritPublicKeyChecker checker;
+
+ @Inject
+ private IdentifiedUser.GenericFactory userFactory;
+
+ @Inject
+ private InMemoryDatabase schemaFactory;
+
+ @Inject
+ private SchemaCreator schemaCreator;
+
+ @Inject
+ private ThreadLocalRequestContext requestContext;
+
+ private LifecycleManager lifecycle;
+ private ReviewDb db;
+ private Account.Id userId;
+ private IdentifiedUser user;
+
+ @Before
+ public void setUpInjector() throws Exception {
+ Injector injector = Guice.createInjector(new InMemoryModule());
+ lifecycle = new LifecycleManager();
+ lifecycle.add(injector);
+ injector.injectMembers(this);
+ lifecycle.start();
+
+ db = schemaFactory.open();
+ schemaCreator.create(db);
+ userId =
+ accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+ Account userAccount = db.accounts().get(userId);
+ // Note: does not match any key in TestKey.
+ userAccount.setPreferredEmail("user@example.com");
+ db.accounts().update(ImmutableList.of(userAccount));
+ user = reloadUser();
+
+ requestContext.setContext(new RequestContext() {
+ @Override
+ public CurrentUser getCurrentUser() {
+ return user;
+ }
+
+ @Override
+ public Provider<ReviewDb> getReviewDbProvider() {
+ return Providers.of(db);
+ }
+ });
+ }
+
+ private IdentifiedUser reloadUser() {
+ accountCache.evict(userId);
+ user = userFactory.create(Providers.of(db), userId);
+ return user;
+ }
+
+ @After
+ public void tearDownInjector() {
+ if (lifecycle != null) {
+ lifecycle.stop();
+ }
+ if (db != null) {
+ db.close();
+ }
+ InMemoryDatabase.drop(schemaFactory);
+ }
+
+ @Test
+ public void defaultGpgCertificationMatchesEmail() throws Exception {
+ TestKey key = TestKey.key5();
+ assertProblems(
+ TestKey.key5(),
+ "Key must contain a valid certification for one of the following "
+ + "identities:\n"
+ + " gerrit:user\n"
+ + " username:user");
+
+ addExternalId("test", "test", "test5@example.com");
+ assertNoProblems(key);
+ }
+
+ @Test
+ public void defaultGpgCertificationDoesNotMatchEmail() throws Exception {
+ addExternalId("test", "test", "nobody@example.com");
+ assertProblems(
+ TestKey.key5(),
+ "Key must contain a valid certification for one of the following "
+ + "identities:\n"
+ + " gerrit:user\n"
+ + " nobody@example.com\n"
+ + " test:test\n"
+ + " username:user");
+ }
+
+ @Test
+ public void manualCertificationMatchesExternalId() throws Exception {
+ addExternalId("foo", "myId", null);
+ assertNoProblems(TestKey.key5());
+ }
+
+ @Test
+ public void manualCertificationDoesNotExternalId() throws Exception {
+ addExternalId("foo", "otherId", null);
+ assertProblems(
+ TestKey.key5(),
+ "Key must contain a valid certification for one of the following "
+ + "identities:\n"
+ + " foo:otherId\n"
+ + " gerrit:user\n"
+ + " username:user");
+ }
+
+ @Test
+ public void noExternalIds() throws Exception {
+ db.accountExternalIds().delete(
+ db.accountExternalIds().byAccount(user.getAccountId()));
+ reloadUser();
+ assertProblems(
+ TestKey.key5(),
+ "No identities found for user; check"
+ + " http://test/#/settings/web-identities");
+ }
+
+ private void assertNoProblems(TestKey key) throws Exception {
+ assertThat(checker.check(key.getPublicKey()).getProblems()).isEmpty();
+ }
+
+ private void assertProblems(TestKey key, String... expected)
+ throws Exception {
+ checkArgument(expected.length > 0);
+ assertThat(checker.check(key.getPublicKey()).getProblems())
+ .containsExactly((Object[]) expected)
+ .inOrder();
+ }
+
+ private void addExternalId(String scheme, String id, String email)
+ throws Exception {
+ AccountExternalId extId = new AccountExternalId(user.getAccountId(),
+ new AccountExternalId.Key(scheme, id));
+ if (email != null) {
+ extId.setEmailAddress(email);
+ }
+ db.accountExternalIds().insert(Collections.singleton(extId));
+ reloadUser();
+ }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/PublicKeyStoreTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/PublicKeyStoreTest.java
index f42e8b3..c84757e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/PublicKeyStoreTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/gpg/PublicKeyStoreTest.java
@@ -17,7 +17,10 @@
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyObjectId;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyToString;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
import com.google.gerrit.reviewdb.client.RefNames;
@@ -27,9 +30,19 @@
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
import org.junit.Before;
import org.junit.Test;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
import java.util.Set;
import java.util.TreeSet;
@@ -100,6 +113,107 @@
assertKeys(key1.getKeyId(), key1, key2);
}
+ @Test
+ public void save() throws Exception {
+ TestKey key1 = TestKey.key1();
+ TestKey key2 = TestKey.key2();
+ store.add(key1.getPublicKeyRing());
+ store.add(key2.getPublicKeyRing());
+
+ assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+ assertKeys(key1.getKeyId(), key1);
+ assertKeys(key2.getKeyId(), key2);
+ }
+
+ @Test
+ public void saveAppendsToExistingList() throws Exception {
+ TestKey key1 = TestKey.key1();
+ TestKey key2 = TestKey.key2();
+ tr.branch(RefNames.REFS_GPG_KEYS)
+ .commit()
+ // Mismatched for this key ID, but we can still read it out.
+ .add(keyObjectId(key1.getKeyId()).name(), key2.getPublicKeyArmored())
+ .create();
+
+ store.add(key1.getPublicKeyRing());
+ assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+
+ assertKeys(key1.getKeyId(), key1, key2);
+
+ try (ObjectReader reader = tr.getRepository().newObjectReader();
+ RevWalk rw = new RevWalk(reader)) {
+ NoteMap notes = NoteMap.read(
+ reader, tr.getRevWalk().parseCommit(
+ tr.getRepository().getRef(RefNames.REFS_GPG_KEYS).getObjectId()));
+ String contents = new String(
+ reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(),
+ UTF_8);
+ String header = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
+ int i1 = contents.indexOf(header);
+ assertTrue(i1 >= 0);
+ int i2 = contents.indexOf(header, i1 + header.length());
+ assertTrue(i2 >= 0);
+ }
+ }
+
+ @Test
+ public void updateExisting() throws Exception {
+ TestKey key5 = TestKey.key5();
+ PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
+ PGPPublicKey key = keyRing.getPublicKey();
+ store.add(keyRing);
+ assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+ assertUserIds(store.get(key5.getKeyId()).iterator().next(),
+ "Testuser Five <test5@example.com>",
+ "foo:myId");
+
+ keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
+ key = PGPPublicKey.removeCertification(key, "foo:myId");
+ keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
+ store.add(keyRing);
+ assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+
+ Iterator<PGPPublicKeyRing> keyRings = store.get(key.getKeyID()).iterator();
+ keyRing = keyRings.next();
+ assertFalse(keyRings.hasNext());
+ assertUserIds(keyRing, "Testuser Five <test5@example.com>");
+ }
+
+ @Test
+ public void remove() throws Exception {
+ TestKey key1 = TestKey.key1();
+ store.add(key1.getPublicKeyRing());
+ assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+ assertKeys(key1.getKeyId(), key1);
+
+ store.remove(key1.getPublicKey().getFingerprint());
+ assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
+ assertKeys(key1.getKeyId());
+ }
+
+ @Test
+ public void removeNonexisting() throws Exception {
+ TestKey key1 = TestKey.key1();
+ store.add(key1.getPublicKeyRing());
+ assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
+
+ TestKey key2 = TestKey.key2();
+ store.remove(key2.getPublicKey().getFingerprint());
+ assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
+ assertKeys(key1.getKeyId(), key1);
+ }
+
+ @Test
+ public void addThenRemove() throws Exception {
+ TestKey key1 = TestKey.key1();
+ store.add(key1.getPublicKeyRing());
+ store.remove(key1.getPublicKey().getFingerprint());
+ assertEquals(RefUpdate.Result.NO_CHANGE, store.save(newCommitBuilder()));
+ assertKeys(key1.getKeyId());
+ }
+
private void assertKeys(long keyId, TestKey... expected)
throws Exception {
Set<String> expectedStrings = new TreeSet<>();
@@ -113,4 +227,25 @@
}
assertEquals(expectedStrings, actualStrings);
}
+
+ private void assertUserIds(PGPPublicKeyRing keyRing, String... expected)
+ throws Exception {
+ List<String> actual = new ArrayList<>();
+ @SuppressWarnings("unchecked")
+ Iterator<String> userIds = store.get(keyRing.getPublicKey().getKeyID())
+ .iterator().next().getPublicKey().getUserIDs();
+ while (userIds.hasNext()) {
+ actual.add(userIds.next());
+ }
+
+ assertEquals(actual, Arrays.asList(expected));
+ }
+
+ private CommitBuilder newCommitBuilder() {
+ CommitBuilder cb = new CommitBuilder();
+ PersonIdent ident = new PersonIdent("A U Thor", "author@example.com");
+ cb.setAuthor(ident);
+ cb.setCommitter(ident);
+ return cb;
+ }
}
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 69362ab..321f01b 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,10 @@
package com.google.gerrit.server.git.gpg;
+import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString;
+
+import com.google.common.collect.ImmutableList;
+
import org.bouncycastle.bcpg.ArmoredInputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPrivateKey;
@@ -29,7 +33,11 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
-class TestKey {
+public class TestKey {
+ public static ImmutableList<TestKey> allValidKeys() {
+ return ImmutableList.of(key1(), key2(), key5());
+ }
+
/**
* A valid key with no expiration.
*
@@ -40,7 +48,7 @@
* sub 2048R/F0AF69C0 2015-07-08
* </pre>
*/
- 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 +148,7 @@
* sub 2048R/46D4F204 2015-07-08 [expires: 2065-06-25]
* </pre>
*/
- 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 +249,7 @@
* uid Testuser Three <test3@example.com>
* </pre>
*/
- 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 +350,7 @@
* uid Testuser Four <test4@example.com>
* </pre>
*/
- 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"
@@ -439,49 +447,168 @@
+ "-----END PGP PRIVATE KEY BLOCK-----\n");
}
+ /**
+ * A key with an additional user ID.
+ *
+ * <pre>
+ * pub 2048R/98C51DBF 2015-07-30
+ * Key fingerprint = 42B3 294D 1924 D7EB AF4A A99F 5024 BB44 98C5 1DBF
+ * uid foo:myId
+ * uid Testuser Five <test5@example.com>
+ * sub 2048R/C781A9E3 2015-07-30
+ * </pre>
+ */
+ public static TestKey key5() {
+ return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+ + "Version: GnuPG v1\n"
+ + "\n"
+ + "mQENBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+ + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+ + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+ + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+ + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+ + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAG0IVRlc3R1c2VyIEZpdmUg\n"
+ + "PHRlc3Q1QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgC\n"
+ + "CQoLBBYCAwECHgECF4AACgkQUCS7RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v\n"
+ + "3H/PyhvYF1nuKNftmhqIiUHec9RaUHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVO\n"
+ + "RyQ/Tv7/xtpqGZqivV0yn2ZXbCceA627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu\n"
+ + "/zdUofEbFAvcXs+Z1uXnUDdeGn47Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6W\n"
+ + "paCIGno69CyNHNnWjJCSD33oLVaXyvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fk\n"
+ + "t4jtiGu9aze4n59GbtSjmWQgzbLCQWhK9K7UCcSLYNKXVyMha2WapBO156V027QI\n"
+ + "Zm9vOm15SWSJATgEEwECACIFAlW6jwYCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4B\n"
+ + "AheAAAoJEFAku0SYxR2/zZUH/1BwPsResHLDSmo6UdQyQGxvV0NcwBqGAPSLHr+S\n"
+ + "PHEaHEIYvOywNfWXquYrECa/5iIrXuTQmCH0q8WRcz1UapDCeD8Ui82r+3O8m6gk\n"
+ + "hIR5VAeza+x/fGWhG342PvtpDU7JycDA3KMCTWtcAM89tFhffzuEQ3f5p5cMTtZk\n"
+ + "/23iegXbHd61vojYO17QYEj+qp9l0VNiyFymPL3qr5bVj/xn/mXFj+asj0L2ypIj\n"
+ + "zC36FkhzW5EX2xgV9Cl9zu7kLMTm+yM+jxbMLskYkG8z/D+xBQsoX8tEIPlxHLhB\n"
+ + "miEmVuZrp91ArRMWa3B7PYz7hQzs+M/bxKXcmWxacggTOvy5AQ0EVbqN3gEIAOlq\n"
+ + "mwdiXW0BQP/iQvIweP1taNypAvdjI2fpnXkUfBT5X/+E/RjYOHQEAzy8nEkS+Y0l\n"
+ + "MLwKt3S0IVRvdeXxlpL6Tl+P8DkcD5H+uvACrg9rtgbbNSoQtc9/3bknG9hea6xi\n"
+ + "6SBH1k9Y2RInIrwWslfKmuNkyZVhxPKypasBsvyhOWLlpCngGiCa74KJ1th1WKa2\n"
+ + "aaDqcbieBTc1mtsXR6kBhJZqK+JYBoHriUQMs7nyXxn2qyv6Lehs/tHlrBZ7j16S\n"
+ + "faQzYoBi1edVrpFr/CuGk6RNKxG9vi/uAA9q2cLCMjjyfMH4g0G2l0HuDPQLA9wi\n"
+ + "BfusEC+OceaeFKtS9ykAEQEAAYkBHwQYAQIACQUCVbqN3gIbDAAKCRBQJLtEmMUd\n"
+ + "vw/DB/9Qx9m1eSdddqz/fk16wJf7Ncr2teVvdQOjRf/qo43KDKxEzeepjgypG1br\n"
+ + "St7U4/MlPygJLBDB4pXp0kaKt+S/aqLpEGSGzQ1FysM8oY6K0e1Kbf6nMaQS8ATG\n"
+ + "aD377FrUJ42NV4JS+NGlwaM9PhpRVm5n8iCzRs9HtlTyfCBkNGDjGOSdWcah2m6T\n"
+ + "fEQdD+XVDN1ZC8zAnc8FW28YOTeTjX079okP6ZCjLJ16VZ7eiHFkrNbS9Dl4SPNK\n"
+ + "eElvsZLBaf8t4RQXFFKwRq4BW+zS8zm9E2H6bZ9yGrmgIREzyRPpwU98g8yrabu0\n"
+ + "54w16Vp/SVViJs7nTMSug0WREyd2\n"
+ + "=ldwB\n"
+ + "-----END PGP PUBLIC KEY BLOCK-----\n",
+ "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
+ + "Version: GnuPG v1\n"
+ + "\n"
+ + "lQOYBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+ + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+ + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+ + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+ + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+ + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAEAB/9MIlrQiWb+Gf3fWFh+\n"
+ + "mkg0Bva9p4IfNX1n5S7hGFGnjGzqXaRX6W1e16gh1qM5ZO1IVh9j5kLmnrt4SNhb\n"
+ + "/Irqnq3s14trpoJUBC81bm9JMUESHrLSjdo4OIWJncOP4xd0bG7h+SKYXGLE1+Me\n"
+ + "pqLu65RNebqRcFYM1xAxfCdaxatcz+LrW5ZX+6T/Gh/VCHRkkzzVIZO1dDBbyU2C\n"
+ + "JrNcfHSvNrjzfqYHtwfsk/lwcuY9pqkYcuwZ2IM+iWKit+WyCR2BzOpG/Sva1t8b\n"
+ + "7B7ituQCFMCv5IiaAoaSKX/t/0ucWCoT1ttih8LdwgEE0kgij/ZUfRxCiL9HmtLy\n"
+ + "ad9BBADBGYWv6NiTQiBG7+MZ+twCjlSL7vq8iENhQYZShGHF9z+ju7m8U1dteLny\n"
+ + "pC3NcNfCgWyy+8lRn1e6Oe6m7xL83LL3HJT5nIy9mpsCw/TIrrkzkoE+VpkEIL/o\n"
+ + "Yeoxauah4SU7laVD29aAQZ3TqwSwx0sJwPjsj73WjjqtzJfFkQQA410ghqMbQZN1\n"
+ + "yJzXgVAj162ZwTi961N5iYmqTiBtqGz1UfaNBJWdJMkCmhMTsiOtm1h4zUQRuEH+\n"
+ + "yq1xhKOGf15dB/cLSMj2KpVVlvgLoVmYDugSER8Q23juilY7iaf0bqo9q1sTHpn9\n"
+ + "O7Oin/9J3sz+ic45vDh4aa74sOzfhA8EAJwAFEWLrGSxtnYJR5vQNstHIH1wtQ5G\n"
+ + "ZUZ57y9CbDkKrfCQvd0JOBjfUDz+N8qiamNIqfhQBtlhIDYgtswiG+iGP/2G0l6S\n"
+ + "j9DHNe2CYPUKgy+zQiRnyNGE2XUfcE+HuNDfu3AryPqaD8vLLw8TnsAgis3bRGg+\n"
+ + "hhrAC1NyKfDXTg20IVRlc3R1c2VyIEZpdmUgPHRlc3Q1QGV4YW1wbGUuY29tPokB\n"
+ + "OAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQUCS7\n"
+ + "RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v3H/PyhvYF1nuKNftmhqIiUHec9Ra\n"
+ + "UHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVORyQ/Tv7/xtpqGZqivV0yn2ZXbCce\n"
+ + "A627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu/zdUofEbFAvcXs+Z1uXnUDdeGn47\n"
+ + "Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6WpaCIGno69CyNHNnWjJCSD33oLVaX\n"
+ + "yvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fkt4jtiGu9aze4n59GbtSjmWQgzbLC\n"
+ + "QWhK9K7UCcSLYNKXVyMha2WapBO156V0250DmARVuo3eAQgA6WqbB2JdbQFA/+JC\n"
+ + "8jB4/W1o3KkC92MjZ+mdeRR8FPlf/4T9GNg4dAQDPLycSRL5jSUwvAq3dLQhVG91\n"
+ + "5fGWkvpOX4/wORwPkf668AKuD2u2Bts1KhC1z3/duScb2F5rrGLpIEfWT1jZEici\n"
+ + "vBayV8qa42TJlWHE8rKlqwGy/KE5YuWkKeAaIJrvgonW2HVYprZpoOpxuJ4FNzWa\n"
+ + "2xdHqQGElmor4lgGgeuJRAyzufJfGfarK/ot6Gz+0eWsFnuPXpJ9pDNigGLV51Wu\n"
+ + "kWv8K4aTpE0rEb2+L+4AD2rZwsIyOPJ8wfiDQbaXQe4M9AsD3CIF+6wQL45x5p4U\n"
+ + "q1L3KQARAQABAAf8C+2DsJPpPEnFHY5dZ2zssd6mbihA2414YLYCcw6F7Lh1nGQa\n"
+ + "XuulruAJnk/xGJbco8bTv7g4ecE+tsbfWnnG/QnHeYCsgO6bKRXATcWFSYpyidUn\n"
+ + "2VdzQwBAv1ZtSNhCXlPLn/erzvA2X4QadUwfnvbehWJAHt8ZJmHUr3FtyRUHEdCK\n"
+ + "2EXsBWnzPCcqHZOMvcbSINSqBFGzVXkOZsMFvPTNIUYRHz8NbJT/OPiOmyBshXpS\n"
+ + "t8w3QqZhBcTT3NZo3kgxN1RygaTa10ytB2cxTCVuD8hmUBaV9gakdfMYkVJds7/T\n"
+ + "ZY3It68F0vitBnqpppZQ+NFgr/vwVg0p3gbmAQQA79zsWPvyIqYvyJhmiKvLIpev\n"
+ + "569ho8tC9xx+IZ5WnjN8ZADlb9brAdA9cqGfBgZkpZUhngCRVOYUIco+m2NYkEJm\n"
+ + "BsSTTM77dqU55DRloJ3FtBwCPXHkwg9P/FHMMYYGyLpQTSB92hXk8yomo+ozX7kx\n"
+ + "DtUHZIrir/rr0lQe+GkEAPkep9V5jBmfHMArnfji7Nfb1/ZjrSAaK+rtqczgm+6j\n"
+ + "ubY/0DpM/6gm+/8X27WFw2m45ncH3qNvOe4Qm40EmgmHkXsdQyU0Fv7uXc9nBYoo\n"
+ + "G6s7DWLY4VAqWwPsvbqgpSp/qdGn9nlcJjjY1HtfU7HM3xysT7TJ2YVhYHlJdjDB\n"
+ + "A/0alBcYtHvaCJaRLWX4UiashbfETWAf/4oHlERjkXj64qOdsGnD6CD99t9x91Ue\n"
+ + "pClPsLDFvY8/HxWX7STA9pQZAa2ZdJd8b58Rgy9TBShw2mbz2S6Cbw77pP/WEjtJ\n"
+ + "pJuS2gDp70H01fYRaw7YH32CfUr1VeEv7hTjk/SNVteIZkkOiQEfBBgBAgAJBQJV\n"
+ + "uo3eAhsMAAoJEFAku0SYxR2/D8MH/1DH2bV5J112rP9+TXrAl/s1yva15W91A6NF\n"
+ + "/+qjjcoMrETN56mODKkbVutK3tTj8yU/KAksEMHilenSRoq35L9qoukQZIbNDUXK\n"
+ + "wzyhjorR7Upt/qcxpBLwBMZoPfvsWtQnjY1XglL40aXBoz0+GlFWbmfyILNGz0e2\n"
+ + "VPJ8IGQ0YOMY5J1ZxqHabpN8RB0P5dUM3VkLzMCdzwVbbxg5N5ONfTv2iQ/pkKMs\n"
+ + "nXpVnt6IcWSs1tL0OXhI80p4SW+xksFp/y3hFBcUUrBGrgFb7NLzOb0TYfptn3Ia\n"
+ + "uaAhETPJE+nBT3yDzKtpu7TnjDXpWn9JVWImzudMxK6DRZETJ3Y=\n"
+ + "=uND5\n"
+ + "-----END PGP PRIVATE KEY BLOCK-----\n");
+ }
+
// TODO(dborowitz): Figure out how to get gpg to revoke a key for someone
// else.
private final String pubArmored;
private final String secArmored;
- private final PGPPublicKey pub;
- private final PGPSecretKey sec;
+ 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.pub = new PGPPublicKeyRing(newStream(pubArmored), fc).getPublicKey();
- this.sec = new PGPSecretKeyRing(newStream(secArmored), fc).getSecretKey();
+ 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;
}
- PGPPublicKey getPublicKey() {
- return pub;
+ public PGPPublicKeyRing getPublicKeyRing() {
+ return pubRing;
}
- PGPSecretKey getSecretKey() {
- return sec;
+ public PGPPublicKey getPublicKey() {
+ return pubRing.getPublicKey();
}
- long getKeyId() {
- return pub.getKeyID();
+ public PGPSecretKey getSecretKey() {
+ return secRing.getSecretKey();
}
- String getFirstUserId() {
- return (String) pub.getUserIDs().next();
+ public long getKeyId() {
+ return getPublicKey().getKeyID();
}
- PGPPrivateKey getPrivateKey() throws PGPException {
- return sec.extractPrivateKey(
+ public String getKeyIdString() {
+ return keyIdToString(getPublicKey().getKeyID());
+ }
+
+ public String getFirstUserId() {
+ return (String) getPublicKey().getUserIDs().next();
+ }
+
+ public PGPPrivateKey getPrivateKey() throws PGPException {
+ return getSecretKey().extractPrivateKey(
new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
// All test keys have no passphrase.
.build(new char[0]));
@@ -492,5 +619,4 @@
return new ArmoredInputStream(
new ByteArrayInputStream(Constants.encode(armored)));
}
-
}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 807f78d..e594255 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -84,8 +84,9 @@
public static void setDefaults(Config cfg) {
cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
- cfg.setString("gerrit", null, "basePath", "git");
cfg.setString("gerrit", null, "allProjects", "Test-Projects");
+ cfg.setString("gerrit", null, "basePath", "git");
+ cfg.setString("gerrit", null, "canonicalWebUrl", "http://test/");
cfg.setString("user", null, "name", "Gerrit Code Review");
cfg.setString("user", null, "email", "gerrit@localhost");
cfg.unset("cache", null, "directory");
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index d2f6bc3..7306135 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit d2f6bc3511185729d3ecc3b3df25b1e9cebe2b2d
+Subproject commit 730613516a733fa33f684cbe03fe22ecf811216e
diff --git a/plugins/replication b/plugins/replication
index 6a83800..acdedee 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 6a83800e8fa959b69faa98111ad53437ee993378
+Subproject commit acdedee5e90bba60cec8be3cdfd9ab3642f4f287