diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index da66929e9c..a36d71666d 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -57,6 +57,12 @@ public class RefNames {
 
   public static final String EDIT_PREFIX = "edit-";
 
+  /**
+   * Special ref for GPG public keys used by {@link
+   * com.google.gerrit.server.git.SignedPushPreReceiveHook}.
+   */
+  public static final String REFS_GPG_KEYS = REFS + "gpg-keys";
+
   public static String fullName(String ref) {
     return ref.startsWith(REFS) ? ref : REFS_HEADS + ref;
   }
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index c43c722a8e..378462b7ab 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -213,6 +213,9 @@ java_test(
     '//lib:grappa',
     '//lib:gwtorm',
     '//lib:truth',
+    '//lib/bouncycastle:bcprov',
+    '//lib/bouncycastle:bcpg',
+    '//lib/bouncycastle:bcpkix',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/jgit:jgit',
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 7d2b306998..a6a7c9f52e 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
@@ -79,6 +79,7 @@ import com.google.gerrit.server.git.MergeQueue;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.SignedPushModule;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
@@ -178,6 +179,7 @@ public class GerritGlobalModule extends FactoryModule {
     install(new NoteDbModule());
     install(new PrologModule());
     install(new SshAddressesModule());
+    install(new SignedPushModule());
     install(ThreadLocalRequestContext.module());
 
     bind(AccountResolver.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java
new file mode 100644
index 0000000000..050756ba60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java
@@ -0,0 +1,60 @@
+// 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;
+
+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.util.BouncyCastleUtil;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.transport.PreReceiveHookChain;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SignedPushModule extends AbstractModule {
+  private static final Logger log =
+      LoggerFactory.getLogger(SignedPushModule.class);
+
+  @Override
+  protected void configure() {
+    if (BouncyCastleUtil.havePGP()) {
+      DynamicSet.bind(binder(), ReceivePackInitializer.class)
+          .to(Initializer.class);
+    } else {
+      log.info("BouncyCastle PGP not installed; signed push verification is"
+          + " disabled");
+    }
+  }
+
+  @Singleton
+  private static class Initializer implements ReceivePackInitializer {
+    private final SignedPushPreReceiveHook hook;
+
+    @Inject
+    Initializer(SignedPushPreReceiveHook hook) {
+      this.hook = hook;
+    }
+
+    @Override
+    public void init(Project.NameKey project, ReceivePack rp) {
+      rp.setPreReceiveHook(PreReceiveHookChain.newChain(Lists.newArrayList(
+          hook, rp.getPreReceiveHook())));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java
new file mode 100644
index 0000000000..cb25d06053
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java
@@ -0,0 +1,244 @@
+// 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;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPObjectFactory;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureList;
+import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PreReceiveHook;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.PushCertificate.NonceStatus;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.Iterator;
+
+/**
+ * Pre-receive hook to validate signed pushes.
+ * 
+ * If configured, prior to processing any push using {@link ReceiveCommits},
+ * requires that any push certificate present must be valid.
+ */
+@Singleton
+public class SignedPushPreReceiveHook implements PreReceiveHook {
+  private static final Logger log =
+      LoggerFactory.getLogger(SignedPushPreReceiveHook.class);
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+
+  @Inject
+  public SignedPushPreReceiveHook(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+  }
+
+  @Override
+  public void onPreReceive(ReceivePack rp,
+      Collection commands) {
+    try (Writer msgOut = new OutputStreamWriter(rp.getMessageOutputStream())) {
+      PushCertificate cert = rp.getPushCertificate();
+      if (cert == null) {
+        return;
+      }
+      if (cert.getNonceStatus() != NonceStatus.OK) {
+        rejectInvalid(commands);
+        return;
+      }
+      verifySignature(cert, commands, msgOut);
+    } catch (IOException e) {
+      log.error("Error verifying push certificate", e);
+      reject(commands, "push cert error");
+    }
+  }
+
+  private void verifySignature(PushCertificate cert,
+      Collection commands, Writer msgOut) throws IOException {
+    PGPSignature sig = readSignature(cert);
+    if (sig == null) {
+      msgOut.write("Invalid signature format\n");
+      rejectInvalid(commands);
+      return;
+    }
+    PGPPublicKey key = readPublicKey(sig.getKeyID());
+    if (key == null) {
+      msgOut.write("No valid public key found for ID "
+          + keyIdToString(sig.getKeyID()) + "\n");
+      rejectInvalid(commands);
+      return;
+    }
+    try {
+      sig.init(new BcPGPContentVerifierBuilderProvider(), key);
+      sig.update(Constants.encode(cert.toText()));
+      if (!sig.verify()) {
+        msgOut.write("Push certificate signature does not match\n");
+        rejectInvalid(commands);
+      }
+      return;
+    } catch (PGPException e) {
+      msgOut.write(
+          "Push certificate verification error: " + e.getMessage() + "\n");
+      rejectInvalid(commands);
+      return;
+    }
+  }
+
+  private PGPSignature readSignature(PushCertificate cert) throws IOException {
+    ArmoredInputStream in = new ArmoredInputStream(
+        new ByteArrayInputStream(Constants.encode(cert.getSignature())));
+    PGPObjectFactory factory = new BcPGPObjectFactory(in);
+    PGPSignature sig = null;
+
+    Object obj;
+    while ((obj = factory.nextObject()) != null) {
+      if (!(obj instanceof PGPSignatureList)) {
+        log.error("Unexpected packet in push cert: {}",
+            obj.getClass().getSimpleName());
+        return null;
+      }
+      if (sig != null) {
+        log.error("Multiple signature packets found in push cert");
+        return null;
+      }
+      PGPSignatureList sigs = (PGPSignatureList) obj;
+      if (sigs.size() != 1) {
+        log.error("Expected 1 signature in push cert, found {}", sigs.size());
+        return null;
+      }
+      sig = sigs.get(0);
+    }
+    return sig;
+  }
+
+  private PGPPublicKey readPublicKey(long keyId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.getRefDatabase().exactRef(RefNames.REFS_GPG_KEYS);
+      if (ref == null) {
+        return null;
+      }
+      NoteMap notes = NoteMap.read(
+          rw.getObjectReader(), rw.parseCommit(ref.getObjectId()));
+      Note note = notes.getNote(keyObjectId(keyId));
+      if (note == null) {
+        return null;
+      }
+
+      try (InputStream objIn =
+              rw.getObjectReader().open(note.getData(), OBJ_BLOB).openStream();
+          ArmoredInputStream in = new ArmoredInputStream(objIn)) {
+        PGPObjectFactory factory = new BcPGPObjectFactory(in);
+        PGPPublicKey matched = null;
+        Object obj;
+        while ((obj = factory.nextObject()) != null) {
+          if (!(obj instanceof PGPPublicKeyRing)) {
+            // TODO(dborowitz): Support assertions signed by a trusted key.
+            log.info("Ignoring {} packet in {}",
+                obj.getClass().getSimpleName(), note.getName());
+            continue;
+          }
+          PGPPublicKeyRing keyRing = (PGPPublicKeyRing) obj;
+          PGPPublicKey key = keyRing.getPublicKey(keyId);
+          if (key == null) {
+            log.warn("Public key ring in {} does not contain key ID {}",
+                note.getName(), keyObjectId(keyId));
+            continue;
+          }
+          if (matched != null) {
+            // TODO(dborowitz): Try all keys.
+            log.warn("Ignoring key with duplicate ID: {}", toString(key));
+            continue;
+          }
+          matched = key;
+        }
+        // TODO(dborowitz): Additional key verification, at least user ID
+        // signature.
+        return matched;
+      }
+    }
+  }
+
+  static ObjectId keyObjectId(long keyId) {
+    // Right-pad key IDs in network byte order to ObjectId length. This allows
+    // us to reuse the fanout code in NoteMap for free. (If we ever fix the
+    // fanout code to work with variable-length byte strings, we will need to
+    // fall back to this key format during a transition period.)
+    ByteBuffer buf = ByteBuffer.wrap(new byte[Constants.OBJECT_ID_LENGTH]);
+    buf.putLong(keyId);
+    return ObjectId.fromRaw(buf.array());
+  }
+
+  static String toString(PGPPublicKey key) {
+    @SuppressWarnings("unchecked")
+    Iterator it = key.getUserIDs();
+    ByteBuffer buf = ByteBuffer.wrap(key.getFingerprint());
+    return String.format(
+        "%s %s(%04X %04X %04X %04X %04X  %04X %04X %04X %04X %04X)",
+        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());
+  }
+
+  private static void reject(Collection commands,
+      String reason) {
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED) {
+        cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, reason);
+      }
+    }
+  }
+
+  static String keyIdToString(long keyId) {
+    // Match key ID format from gpg --list-keys.
+    return String.format("%08X", (int) keyId);
+  }
+
+  private static void rejectInvalid(Collection commands) {
+    reject(commands, "invalid push cert");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java
new file mode 100644
index 0000000000..f0cfe180ad
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java
@@ -0,0 +1,90 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.git.SignedPushPreReceiveHook.keyIdToString;
+
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
+import org.eclipse.jgit.lib.Constants;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+
+public class SignedPushPreReceiveHookTest {
+  // ./pubring.gpg
+  // -------------
+  // pub   1024R/30A5A053 2015-06-16 [expires: 2015-06-17]
+  //       Key fingerprint = 96D6 DE78 E6D8 DA49 9387  1F31 FA09 A0C4 30A5 A053
+  // uid                  A U. Thor 
+  // sub   1024R/D6831DC8 2015-06-16 [expires: 2015-06-17]
+  private static final String PUBKEY =
+      "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+      + "Version: GnuPG v1\n"
+      + "\n"
+      + "mI0EVYCBUQEEALCKzuY6M68RRRm6PS1F322lpHSHTdW9PIURm5B//tbfS32EN6lM\n"
+      + "ISwJxhanpZanv2o4mbV3V8oLT3jMVDPJ3dqmOZJdJs37l+dxCVJ3ycFe1LHtT2oT\n"
+      + "eRyC5PxD7UY5PdDe97mjp7yrp/bx1hE6XqGV0nDGrkJXc8A35u3WzIF5ABEBAAG0\n"
+      + "IEEgVS4gVGhvciA8YV91X3Rob3JAZXhhbXBsZS5jb20+iL4EEwECACgFAlWAgVEC\n"
+      + "GwMFCQABUYAGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEPoJoMQwpaBTjhoD\n"
+      + "/0MRCX1zBjEKIfzFYeSEg/OcSLbAkUD7un5YTfpgds3oUNIKlIgovWO24TQxrCCu\n"
+      + "5pSzN/WfRSzPFhj9HahY/5yh+EGd6HmIU2v/k5I3LwTPEOcZUi1SzOScSv6JOO9Q\n"
+      + "3srVilCu3h6TNW1UGBNjfOr1NdmkWfsUZcjsEc/XrfBGuI0EVYCBUQEEAL0UP9jJ\n"
+      + "eLj3klCCa2tmwdgyFiSf9T+Yoed4I3v3ag2F0/CWrCJr3e1ogSs4Bdts0WptI+Nu\n"
+      + "QIq40AYszewq55dTcB4lbNAYE4svVYQ5AGz78iKzljaBFhyT6ePdZ5wfb+8Jqu1l\n"
+      + "7wRwzRI5Jn3OXCmdGm/dmoUNG136EA9A4ZLLABEBAAGIpQQYAQIADwUCVYCBUQIb\n"
+      + "DAUJAAFRgAAKCRD6CaDEMKWgU5JTA/9XjwPFZ5NseNROMhYZMmje1/ixISb2jaVc\n"
+      + "9m9RLCl8Y3RCY9NNdU5FinTIX9LsRTrJlW6FSG5sin8mwx9jq0eGE1TBEKND5klT\n"
+      + "TmsG0jx1dZG9kWDy6lPnIWw2/4W+N0fK/Cw6WEL1Xg7RLi4NQ9Bi2WoxJii9bWMv\n"
+      + "yy35U6UfPQ==\n"
+      + "=0GL9\n"
+      + "-----END PGP PUBLIC KEY BLOCK-----\n";
+
+  private PGPPublicKey key;
+
+  @Before
+  public void setUp() throws Exception {
+    ArmoredInputStream in = new ArmoredInputStream(
+        new ByteArrayInputStream(Constants.encode(PUBKEY)));
+    PGPPublicKeyRing keyRing =
+        new PGPPublicKeyRing(in, new BcKeyFingerprintCalculator());
+    key = keyRing.getPublicKey();
+  }
+
+  @Test
+  public void testKeyIdToString() throws Exception {
+    assertThat(keyIdToString(key.getKeyID()))
+        .isEqualTo("30A5A053");
+  }
+
+  @Test
+  public void testKeyToString() throws Exception {
+    assertThat(SignedPushPreReceiveHook.toString(key))
+        .isEqualTo("30A5A053 A U. Thor "
+          + " (96D6 DE78 E6D8 DA49 9387  1F31 FA09 A0C4 30A5 A053)");
+  }
+
+  @Test
+  public void testKeyObjectId() throws Exception {
+    String objId = SignedPushPreReceiveHook.keyObjectId(key.getKeyID()).name();
+    assertThat(objId).isEqualTo("fa09a0c430a5a053000000000000000000000000");
+    assertThat(objId.substring(8, 16))
+        .isEqualTo(keyIdToString(key.getKeyID()).toLowerCase());
+  }
+}