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 &lt;key ID&gt;</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 &lt;test3@example.com&gt;
    * </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 &lt;test4@example.com&gt;
    * </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