Add a collection and API for a user's GPG keys

Users are allowed to upload GPG keys as long as they meet the
restrictions in GerritPublicKeyChecker, i.e. it is a valid key
matching at least one user ID to an external ID in the database. Allow
adding keys with a POST to /accounts/self/gpgkeys, as well as listing
GPG keys and looking up by ID or fingerprint.

To facilitate listing keys, store an additional external ID in the
database with the key fingerprint. Since this is the entire external
ID key, this implies only a single user may use a particular GPG key;
this is similar to the restriction that only a single user may use a
particular email address or HTTP username.

Change-Id: I92102279452af904a985b0933a294573a16a48ca
This commit is contained in:
Dave Borowitz 2015-07-27 17:31:49 -07:00
parent 6d883d8830
commit ed170f35f6
20 changed files with 1005 additions and 37 deletions

View File

@ -684,6 +684,119 @@ Deletes an SSH key of a user.
HTTP/1.1 204 No Content
----
[[list-gpg-keys]]
=== List GPG Keys
--
'GET /accounts/link:#account-id[\{account-id\}]/gpgkeys'
--
Returns the GPG keys of an account.
.Request
----
GET /accounts/self/gpgkeys HTTP/1.0
----
As a response, the GPG keys of the account are returned as a map of
link:#gpg-key-info[GpgKeyInfo] entities, keyed by ID.
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
{
"AFC8A49B": {
"fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B",
"user_ids": [
"John Doe \u003cjohn.doe@example.com\u003e"
],
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0"
}
}
----
[[get-gpg-key]]
=== Get GPG Key
--
'GET /accounts/link:#account-id[\{account-id\}]/gpgkeys/link:#gpg-key-id[\{gpg-key-id\}]'
--
Retrieves a GPG key of a user.
.Request
----
GET /accounts/self/gpgkeys/AFC8A49B HTTP/1.0
----
As a response, a link:#gpg-key-info[GpgKeyInfo] entity is returned that
describes the GPG key.
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
{
"id": "AFC8A49B",
"fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B",
"user_ids": [
"John Doe \u003cjohn.doe@example.com\u003e"
],
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0"
}
----
[[add-gpg-keys]]
=== Add GPG Keys
--
'POST /accounts/link:#account-id[\{account-id\}]/gpgkeys'
--
Add one or more GPG keys for a user.
The new keys must be provided in the request body as a
link:#gpg-key-input[GpgKeyInput] entity. Each GPG key is provided in
ASCII armored format, and must contain a self-signed certification
matching a registered email or other identity of the user.
.Request
----
POST /accounts/link:#account-id[\{account-id\}]/gpgkeys
Content-Type: application/json
{
"add": [
"-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v1\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0yZO5AQ0E\nVdSk1wEIALUycrH2HK9zQYdR/KJo1yJJuaextLWsYYn881yDQo/p06U5vXOZ28lG\nAq/Xs96woVZPbgME6FyQzhf20Z2sbr+5bNo3OcEKaKX3Eo/sWwSJ7bXbGLDxMf4S\netfY1WDC+4rTqE30JuC++nQviPRdCcZf0AEgM6TxVhYEMVYwV787YO1IH62EBICM\nSkIONOfnusNZ4Skgjq9OzakOOpROZ4tki5cH/5oSDgdcaGPy1CFDpL9fG6er2zzk\nsw3qCbraqZrrlgpinWcAduiao67U/dV18O6OjYzrt33fTKZ0+bXhk1h1gloC21MQ\nya0CXlnfR/FOQhvuK0RlbR3cMfhZQscAEQEAAYkBHwQYAQIACQUCVdSk1wIbDAAK\nCRCTUJ5Lr8ikm8+QB/4uE+AlvFQFh9W8koPdfk7CJF7wdgZZ2NDtktvLL71WuMK8\nPOmf9f5JtcLCX4iJxGzcWogAR5ed20NgUoHUg7jn9Xm3fvP+kiqL6WqPhjazd89h\nk06v9hPE65kp4wb0fQqDrtWfP1lFGuh77rQgISt3Y4QutDl49vXS183JAfGPxFxx\n8FgGcfNwL2LVObvqCA0WLqeIrQVbniBPFGocE3yA/0W9BB/xtolpKfgMMsqGRMeu\n9oIsNxB2oE61OsqjUtGsnKQi8k5CZbhJaql4S89vwS+efK0R+mo+0N55b0XxRlCS\nfaURgAcjarQzJnG0hUps2GNO/+nM7UyyJAGfHlh5\n=EdXO\n-----END PGP PUBLIC KEY BLOCK-----\n"
]
}'
----
As a response, the added GPG keys are returned as a map of
link:#gpg-key-info[GpgKeyInfo] entities, keyed by ID.
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
{
"AFC8A49B": {
"fingerprint": "0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B",
"user_ids": [
"John Doe \u003cjohn.doe@example.com\u003e"
],
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.52\n\nmQENBFXUpNcBCACv4paCiyKxZ0EcKy8VaWVNkJlNebRBiyw9WxU85wPOq5Gz/3GT\nRQwKqeY0SxVdQT8VNBw2sBe2m6eqcfZ2iKmesSlbXMe15DA7k8Bg4zEpQ0tXNG1L\nhceZDVQ1Xk06T2sgkunaiPsXi82nwN3UWYtDXxX4is5e6xBNL48Jgz4lbqo6+8D5\nvsVYiYMx4AwRkJyt/oA3IZAtSlY8Yd445nY14VPcnsGRwGWTLyZv9gxKHRUppVhQ\nE3o6ePXKEVgmONnQ4CjqmkGwWZvjMF2EPtAxvQLAuFa8Hqtkq5cgfgVkv/Vrcln4\nnQZVoMm3a3f5ODii2tQzNh6+7LL1bpqAmVEtABEBAAG0H0pvaG4gRG9lIDxqb2hu\nLmRvZUBleGFtcGxlLmNvbT6JATgEEwECACIFAlXUpNcCGwMGCwkIBwMCBhUIAgkK\nCwQWAgMBAh4BAheAAAoJEJNQnkuvyKSbfjoH/2OcSQOu1kJ20ndjhgY2yNChm7gd\ntU7TEBbB0TsLeazkrrLtKvrpW5+CRe07ZAG9HOtp3DikwAyrhSxhlYgVsQDhgB8q\nG0tYiZtQ88YyYrncCQ4hwknrcWXVW9bK3V4ZauxzPv3ADSloyR9tMURw5iHCIeL5\nfIw/pLvA3RjPMx4Sfow/bqRCUELua39prGw5Tv8a2ZRFbj2sgP5j8lUFegyJPQ4z\ntJhe6zZvKOzvIyxHO8llLmdrImsXRL9eqroWGs0VYqe6baQpY6xpSjbYK0J5HYcg\nTO+/u80JI+ROTMHE6unGp5Pgh/xIz6Wd34E0lWL1eOyNfGiPLyRWn1d0"
}
}
----
[[list-account-capabilities]]
=== List Account Capabilities
--
@ -1292,6 +1405,12 @@ The user name.
=== \{ssh-key-id\}
The sequence number of the SSH key.
[[gpg-key-id]]
=== \{gpg-key-id\}
A GPG key identifier, either the 8-character hex key reported by
`gpg --list-keys`, or the 40-character hex fingerprint (whitespace is
ignored) reported by `gpg --list-keys --with-fingerprint`.
[[json-entities]]
== JSON Entities
@ -1578,6 +1697,31 @@ Only Gerrit administrators are allowed to add email addresses without
confirmation.
|==============================
[[gpg-key-info]]
=== GpgKeyInfo
The `GpgKeyInfo` entity contains information about a GPG public key.
[options="header",cols="1,^1,5"]
|========================
|Field Name ||Description
|`id` |Not set in map context|The 8-char hex GPG key ID.
|`fingerprint`||The 40-char (plus spaces) hex GPG key fingerprint.
|`user_ids` ||
link:https://tools.ietf.org/html/rfc4880#section-5.11[OpenPGP User IDs]
associated with the public key.
|`key` ||ASCII armored public key material.
|========================
[[gpg-key-input]]
=== GpgKeyInput
The `GpgKeyInput` entity contains information for adding GPG keys.
[options="header",cols="1,6"]
|========================
|Field Name|Description
|`add` |List of ASCII armored public key strings to add.
|========================
[[http-password-input]]
=== HttpPasswordInput
The `HttpPasswordInput` entity contains information for setting/generating

View File

@ -27,16 +27,18 @@ java_library(
'//lib:truth',
'//lib/auto:auto-value',
'//lib/httpcomponents:fluent-hc',
'//lib/httpcomponents:httpclient',
'//lib/httpcomponents:httpcore',
'//lib/log:impl_log4j',
'//lib/log:log4j',
'//lib/bouncycastle:bcpg',
'//lib/bouncycastle:bcprov',
'//lib/guice:guice',
'//lib/guice:guice-assistedinject',
'//lib/guice:guice-servlet',
'//lib/httpcomponents:fluent-hc',
'//lib/httpcomponents:httpclient',
'//lib/httpcomponents:httpcore',
'//lib/jgit:jgit',
'//lib/jgit:junit',
'//lib/log:impl_log4j',
'//lib/log:log4j',
'//lib/mina:sshd',
],
visibility = [

View File

@ -43,6 +43,7 @@ import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.OutputFormat;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.CanonicalWebUrl;
@ -113,6 +114,9 @@ public abstract class AbstractDaemonTest {
@Inject
protected AcceptanceTestRequestScope atrScope;
@Inject
protected AccountCache accountCache;
@Inject
private IdentifiedUser.GenericFactory identifiedUserFactory;
@ -238,6 +242,11 @@ public abstract class AbstractDaemonTest {
toClose = Collections.synchronizedList(new ArrayList<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);

View File

@ -14,20 +14,68 @@
package com.google.gerrit.acceptance.api.accounts;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.fingerprintToString;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.extensions.api.accounts.EmailInput;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.reviewdb.client.AccountExternalId;
import com.google.gerrit.server.git.gpg.PublicKeyStore;
import com.google.gerrit.server.git.gpg.TestKey;
import com.google.inject.Inject;
import com.google.inject.Provider;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.eclipse.jgit.transport.PushCertificateIdent;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
public class AccountIT extends AbstractDaemonTest {
@Inject
private Provider<PublicKeyStore> publicKeyStoreProvider;
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);
}
private List<AccountExternalId> getExternalIds(TestAccount account)
throws Exception {
return db.accountExternalIds().byAccount(account.getId()).toList();
}
@Test
public void get() throws Exception {
AccountInfo info = gApi
@ -103,4 +151,140 @@ public class AccountIT extends AbstractDaemonTest {
exception.expectMessage("invalid email address");
gApi.accounts().self().addEmail(input);
}
@Test
public void addGpgKey() throws Exception {
TestKey key = TestKey.key1();
String id = keyIdToString(key.getKeyId());
addExternalIdEmail(admin, "test1@example.com");
GpgKeyInfo info = gApi.accounts().self()
.putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()))
.get(id);
info.id = id;
assertKeyEquals(key, info);
assertKeyEquals(key, gApi.accounts().self().gpgKey(id).get());
PGPPublicKey stored = getOnlyKeyFromStore(key);
assertThat(stored.getFingerprint())
.isEqualTo(key.getPublicKey().getFingerprint());
setApiUser(user);
exception.expect(ResourceNotFoundException.class);
exception.expectMessage(id);
gApi.accounts().self().gpgKey(id).get();
}
@Test
public void reAddExistingGpgKey() throws Exception {
addExternalIdEmail(admin, "test5@example.com");
TestKey key = TestKey.key5();
String id = keyIdToString(key.getKeyId());
PGPPublicKey pk = key.getPublicKey();
GpgKeyInfo info = gApi.accounts().self()
.putGpgKeys(ImmutableList.of(armor(pk)))
.get(id);
assertThat(info.userIds).hasSize(2);
assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
pk = PGPPublicKey.removeCertification(pk, "foo:myId");
info = gApi.accounts().self()
.putGpgKeys(ImmutableList.of(armor(pk)))
.get(id);
assertThat(info.userIds).hasSize(1);
assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
}
@Test
public void addOtherUsersGpgKey_Conflict() throws Exception {
// Both users have a matching external ID for this key.
addExternalIdEmail(admin, "test5@example.com");
AccountExternalId extId = new AccountExternalId(
user.getId(), new AccountExternalId.Key("foo:myId"));
db.accountExternalIds().insert(Collections.singleton(extId));
TestKey key = TestKey.key5();
String id = keyIdToString(key.getKeyId());
gApi.accounts().self()
.putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()))
.get(id);
setApiUser(user);
exception.expect(ResourceConflictException.class);
exception.expectMessage("GPG key already associated with another account");
gApi.accounts().self()
.putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()))
.get(id);
}
@Test
public void listGpgKeys() throws Exception {
List<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);
Map<String, GpgKeyInfo> actual = gApi.accounts().self().listGpgKeys();
assertThat(actual).hasSize(keys.size());
for (TestKey k : keys) {
String id = keyIdToString(k.getKeyId());
GpgKeyInfo info = actual.get(id);
assertThat(info).named(id).isNotNull();
assertThat(info.id).named(id).isNull();
info.id = id;
assertKeyEquals(k, info);
}
}
private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception {
try (PublicKeyStore store = publicKeyStoreProvider.get()) {
Iterable<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 assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
String id = keyIdToString(expected.getKeyId());
assertThat(actual.id).named(id).isEqualTo(id);
assertThat(actual.fingerprint).named(id).isEqualTo(
fingerprintToString(expected.getPublicKey().getFingerprint()));
@SuppressWarnings("unchecked")
List<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);
}
}

View File

@ -20,16 +20,11 @@ import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAcco
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GetDetail.AccountDetailInfo;
import com.google.inject.Inject;
import org.junit.Test;
public class GetAccountDetailIT extends AbstractDaemonTest {
@Inject
private AccountCache accountCache;
@Test
public void getDetail() throws Exception {
RestResponse r = adminSession.get("/accounts/" + admin.username + "/detail/");

View File

@ -15,9 +15,13 @@
package com.google.gerrit.extensions.api.accounts;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.NotImplementedException;
import com.google.gerrit.extensions.restapi.RestApiException;
import java.util.List;
import java.util.Map;
public interface AccountApi {
AccountInfo get() throws RestApiException;
@ -25,6 +29,10 @@ public interface AccountApi {
void unstarChange(String id) throws RestApiException;
void addEmail(EmailInput input) throws RestApiException;
Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException;
Map<String, GpgKeyInfo> putGpgKeys(List<String> add) throws RestApiException;
GpgKeyApi gpgKey(String id) throws RestApiException;
/**
* A default implementation which allows source compatibility
* when adding new methods to the interface.
@ -49,5 +57,21 @@ public interface AccountApi {
public void addEmail(EmailInput input) throws RestApiException {
throw new NotImplementedException();
}
@Override
public Map<String, GpgKeyInfo> putGpgKeys(List<String> add)
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();
}
}
}

View File

@ -0,0 +1,34 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.extensions.api.accounts;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.NotImplementedException;
import com.google.gerrit.extensions.restapi.RestApiException;
public interface GpgKeyApi {
GpgKeyInfo get() throws RestApiException;
/**
* A default implementation which allows source compatibility
* when adding new methods to the interface.
*/
public class NotImplemented implements GpgKeyApi {
@Override
public GpgKeyInfo get() throws RestApiException {
throw new NotImplementedException();
}
}
}

View File

@ -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;
}

View File

@ -36,6 +36,9 @@ public final class AccountExternalId {
/** Scheme for the username used to authenticate an account, e.g. over SSH. */
public static final String SCHEME_USERNAME = "username:";
/** Scheme used for GPG public keys. */
public static final String SCHEME_GPGKEY = "gpgkey:";
/** Scheme for external auth used during authentication, e.g. OAuth Token */
public static final String SCHEME_EXTERNAL = "external:";

View File

@ -88,6 +88,7 @@ java_sources(
TESTUTIL = glob([
'src/test/java/com/google/gerrit/testutil/**/*.java',
'src/test/java/com/google/gerrit/server/project/Util.java',
'src/test/java/com/google/gerrit/server/git/gpg/TestKey.java',
])
java_library(
name = 'testutil',
@ -103,6 +104,8 @@ java_library(
'//lib:h2',
'//lib:truth',
'//lib/auto:auto-value',
'//lib/bouncycastle:bcpg',
'//lib/bouncycastle:bcprov',
'//lib/guice:guice',
'//lib/guice:guice-servlet',
'//lib/jgit:jgit',

View File

@ -22,6 +22,8 @@ import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.change.ChangeResource;
import com.google.inject.TypeLiteral;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
public class AccountResource implements RestResource {
public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
new TypeLiteral<RestView<AccountResource>>() {};
@ -35,6 +37,9 @@ public class AccountResource implements RestResource {
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 class AccountResource implements RestResource {
}
}
public static class GpgKey extends AccountResource {
private final PGPPublicKeyRing keyRing;
public GpgKey(IdentifiedUser user, PGPPublicKeyRing keyRing) {
super(user);
this.keyRing = keyRing;
}
public PGPPublicKeyRing getKeyRing() {
return keyRing;
}
}
public static class StarredChange extends AccountResource {
private final ChangeResource change;

View File

@ -0,0 +1,216 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.account;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.base.Predicate;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.io.BaseEncoding;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ChildCollection;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.AccountExternalId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.account.AccountResource.GpgKey;
import com.google.gerrit.server.git.gpg.PublicKeyStore;
import com.google.gerrit.server.util.BouncyCastleUtil;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
@Singleton
public class GpgKeys implements
ChildCollection<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 = null;
for (AccountExternalId extId : getGpgExtIds(parent)) {
String fpStr = extId.getSchemeRest();
if (!fpStr.endsWith(str)) {
continue;
} else if (fp != null) {
throw new ResourceNotFoundException("Multiple keys found for " + id);
}
fp = BaseEncoding.base16().decode(fpStr);
if (str.length() == 40) {
break;
}
}
if (fp == null) {
throw new ResourceNotFoundException(id);
}
try (PublicKeyStore store = storeProvider.get()) {
long keyId = keyId(fp);
for (PGPPublicKeyRing keyRing : store.get(keyId)) {
PGPPublicKey key = keyRing.getPublicKey();
if (Arrays.equals(key.getFingerprint(), fp)) {
return new AccountResource.GpgKey(parent.getUser(), keyRing);
}
}
}
throw new ResourceNotFoundException(id);
}
@Override
public DynamicMap<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 {}", 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 Iterable<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 ByteBuffer.wrap(fp).getLong(fp.length - 8);
}
static void checkEnabled() throws ResourceNotFoundException {
if (!BouncyCastleUtil.havePGP()) {
throw new ResourceNotFoundException("GPG not enabled");
}
}
static GpgKeyInfo toJson(PGPPublicKeyRing keyRing) throws IOException {
PGPPublicKey key = keyRing.getPublicKey();
GpgKeyInfo info = new GpgKeyInfo();
info.id = PublicKeyStore.keyIdToString(key.getKeyID());
info.fingerprint = PublicKeyStore.fingerprintToString(key.getFingerprint());
@SuppressWarnings("unchecked")
Iterator<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;
}
}

View File

@ -17,6 +17,7 @@ package com.google.gerrit.server.account;
import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
import static com.google.gerrit.server.account.AccountResource.CAPABILITY_KIND;
import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND;
import static com.google.gerrit.server.account.AccountResource.GPG_KEY_KIND;
import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
@ -33,6 +34,7 @@ public class Module extends RestApiModule {
DynamicMap.mapOf(binder(), ACCOUNT_KIND);
DynamicMap.mapOf(binder(), CAPABILITY_KIND);
DynamicMap.mapOf(binder(), EMAIL_KIND);
DynamicMap.mapOf(binder(), GPG_KEY_KIND);
DynamicMap.mapOf(binder(), SSH_KEY_KIND);
DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
@ -57,11 +59,19 @@ public class Module extends RestApiModule {
delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
get(SSH_KEY_KIND).to(GetSshKey.class);
delete(SSH_KEY_KIND).to(DeleteSshKey.class);
child(ACCOUNT_KIND, "gpgkeys").to(GpgKeys.class);
post(ACCOUNT_KIND, "gpgkeys").to(PostGpgKeys.class);
get(GPG_KEY_KIND).to(GpgKeys.Get.class);
get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
child(ACCOUNT_KIND, "capabilities").to(Capabilities.class);
get(ACCOUNT_KIND, "groups").to(GetGroups.class);
get(ACCOUNT_KIND, "preferences").to(GetPreferences.class);
put(ACCOUNT_KIND, "preferences").to(SetPreferences.class);

View File

@ -0,0 +1,179 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.account;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyToString;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.BaseEncoding;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.reviewdb.client.AccountExternalId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.PostGpgKeys.Input;
import com.google.gerrit.server.git.gpg.CheckResult;
import com.google.gerrit.server.git.gpg.PublicKeyChecker;
import com.google.gerrit.server.git.gpg.PublicKeyStore;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.bouncycastle.bcpg.ArmoredInputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.RefUpdate;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Singleton
public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
public static class Input {
public List<String> add;
}
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<PGPPublicKeyRing> newKeys = readKeys(input);
List<AccountExternalId> newExtIds = new ArrayList<>(newKeys.size());
for (PGPPublicKeyRing keyRing : newKeys) {
PGPPublicKey key = keyRing.getPublicKey();
AccountExternalId.Key extIdKey = new AccountExternalId.Key(
AccountExternalId.SCHEME_GPGKEY,
BaseEncoding.base16().encode(key.getFingerprint()));
AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
if (existing != null) {
if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
throw new ResourceConflictException(
"GPG key already associated with another account");
}
} else {
newExtIds.add(
new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
}
}
storeKeys(rsrc, newKeys);
if (!newExtIds.isEmpty()) {
db.get().accountExternalIds().insert(newExtIds);
}
return toJson(newKeys);
}
private List<PGPPublicKeyRing> readKeys(Input input)
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");
}
keyRings.add((PGPPublicKeyRing) objs.get(0));
}
}
return keyRings;
}
private void storeKeys(AccountResource rsrc, List<PGPPublicKeyRing> keyRings)
throws BadRequestException, ResourceConflictException, PGPException,
IOException {
try (PublicKeyStore store = storeProvider.get()) {
for (PGPPublicKeyRing keyRing : keyRings) {
PGPPublicKey key = keyRing.getPublicKey();
CheckResult result = checker.check(key);
if (!result.isOk()) {
throw new BadRequestException(String.format(
"Problems with public key %s:\n%s",
keyToString(key), Joiner.on('\n').join(result.getProblems())));
}
store.add(keyRing);
}
CommitBuilder cb = new CommitBuilder();
PersonIdent committer = serverIdent.get();
cb.setAuthor(rsrc.getUser().newCommitterIdent(
committer.getWhen(), committer.getTimeZone()));
cb.setCommitter(committer);
RefUpdate.Result saveResult = store.save(cb);
switch (saveResult) {
case NEW:
case FAST_FORWARD:
case FORCED:
break;
default:
// TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
throw new ResourceConflictException(
"Failed to save public key: " + saveResult);
}
}
}
private static Map<String, GpgKeyInfo> toJson(
Collection<PGPPublicKeyRing> keyRings) throws IOException {
Map<String, GpgKeyInfo> infos =
Maps.newHashMapWithExpectedSize(keyRings.size());
for (PGPPublicKeyRing keyRing : keyRings) {
GpgKeyInfo info = GpgKeys.toJson(keyRing);
infos.put(info.id, info);
info.id = null;
}
return infos;
}
}

View File

@ -17,13 +17,17 @@ package com.google.gerrit.server.api.accounts;
import com.google.gerrit.common.errors.EmailException;
import com.google.gerrit.extensions.api.accounts.AccountApi;
import com.google.gerrit.extensions.api.accounts.EmailInput;
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.CreateEmail;
import com.google.gerrit.server.account.GpgKeys;
import com.google.gerrit.server.account.PostGpgKeys;
import com.google.gerrit.server.account.StarredChanges;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.ChangesCollection;
@ -31,6 +35,12 @@ import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import org.bouncycastle.openpgp.PGPException;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class AccountApiImpl implements AccountApi {
interface Factory {
AccountApiImpl create(AccountResource account);
@ -42,6 +52,9 @@ public class AccountApiImpl implements AccountApi {
private final StarredChanges.Create starredChangesCreate;
private final StarredChanges.Delete starredChangesDelete;
private final CreateEmail.Factory createEmailFactory;
private final PostGpgKeys postGpgKeys;
private final GpgKeys gpgKeys;
private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
@Inject
AccountApiImpl(AccountLoader.Factory ailf,
@ -49,6 +62,9 @@ public class AccountApiImpl implements AccountApi {
StarredChanges.Create starredChangesCreate,
StarredChanges.Delete starredChangesDelete,
CreateEmail.Factory createEmailFactory,
PostGpgKeys postGpgKeys,
GpgKeys gpgKeys,
GpgKeyApiImpl.Factory gpgKeyApiFactory,
@Assisted AccountResource account) {
this.account = account;
this.accountLoaderFactory = ailf;
@ -56,6 +72,9 @@ public class AccountApiImpl implements AccountApi {
this.starredChangesCreate = starredChangesCreate;
this.starredChangesDelete = starredChangesDelete;
this.createEmailFactory = createEmailFactory;
this.postGpgKeys = postGpgKeys;
this.gpgKeys = gpgKeys;
this.gpgKeyApiFactory = gpgKeyApiFactory;
}
@Override
@ -108,4 +127,35 @@ public class AccountApiImpl implements AccountApi {
throw new RestApiException("Cannot add email", e);
}
}
@Override
public Map<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)
throws RestApiException {
PostGpgKeys.Input in = new PostGpgKeys.Input();
in.add = add;
try {
return postGpgKeys.apply(account, in);
} catch (PGPException | OrmException | IOException e) {
throw new RestApiException("Cannot add GPG key", e);
}
}
@Override
public GpgKeyApi gpgKey(String id) throws RestApiException {
try {
IdString idStr = IdString.fromDecoded(id);
return gpgKeyApiFactory.create(gpgKeys.parse(account, idStr));
} catch (PGPException | OrmException | IOException e) {
throw new RestApiException("Cannot get PGP key", e);
}
}
}

View File

@ -0,0 +1,51 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.api.accounts;
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.GpgKeys;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
class GpgKeyApiImpl implements GpgKeyApi {
interface Factory {
GpgKeyApiImpl create(AccountResource.GpgKey rsrc);
}
private final GpgKeys.Get get;
private final AccountResource.GpgKey rsrc;
@AssistedInject
GpgKeyApiImpl(
GpgKeys.Get get,
@Assisted AccountResource.GpgKey rsrc) {
this.get = get;
this.rsrc = rsrc;
}
@Override
public GpgKeyInfo get() throws RestApiException {
try {
return get.apply(rsrc);
} catch (IOException e) {
throw new RestApiException("Cannot get GPG key", e);
}
}
}

View File

@ -23,5 +23,6 @@ public class Module extends FactoryModule {
bind(Accounts.class).to(AccountsImpl.class);
factory(AccountApiImpl.Factory.class);
factory(GpgKeyApiImpl.Factory.class);
}
}

View File

@ -14,6 +14,7 @@
package com.google.gerrit.server.git.gpg;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
import static com.google.gerrit.server.git.gpg.PublicKeyStore.keyIdToString;
import com.google.common.collect.FluentIterable;
@ -100,6 +101,9 @@ public class GerritPublicKeyChecker extends PublicKeyChecker {
Set<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;

View File

@ -273,24 +273,33 @@ public class PublicKeyStore implements AutoCloseable {
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() + " " : "",
fingerprintToString(key.getFingerprint()));
}
public static String keyIdToString(long keyId) {
// Match key ID format from gpg --list-keys.
return String.format("%08X", (int) keyId);
}
public static String fingerprintToString(byte[] fingerprint) {
if (fingerprint.length != 20) {
throw new IllegalArgumentException();
}
ByteBuffer buf = ByteBuffer.wrap(fingerprint);
return String.format(
"%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X",
buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(),
buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(),
buf.getShort(), buf.getShort());
}
static String keyIdToString(long keyId) {
// Match key ID format from gpg --list-keys.
return String.format("%08X", (int) keyId);
}
static ObjectId keyObjectId(long keyId) {
ByteBuffer buf = ByteBuffer.wrap(new byte[Constants.OBJECT_ID_LENGTH]);
buf.putLong(keyId);

View File

@ -14,6 +14,8 @@
package com.google.gerrit.server.git.gpg;
import com.google.common.collect.ImmutableList;
import org.bouncycastle.bcpg.ArmoredInputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPrivateKey;
@ -29,7 +31,11 @@ import org.eclipse.jgit.lib.Constants;
import java.io.ByteArrayInputStream;
import java.io.IOException;
class TestKey {
public class TestKey {
public static ImmutableList<TestKey> allValidKeys() {
return ImmutableList.of(key1(), key2(), key5());
}
/**
* A valid key with no expiration.
*
@ -40,7 +46,7 @@ class TestKey {
* sub 2048R/F0AF69C0 2015-07-08
* </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 +146,7 @@ class TestKey {
* 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 +247,7 @@ class TestKey {
* 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 +348,7 @@ class TestKey {
* 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"
@ -450,7 +456,7 @@ class TestKey {
* sub 2048R/C781A9E3 2015-07-30
* </pre>
*/
static TestKey key5() throws PGPException, IOException {
public static TestKey key5() {
return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+ "Version: GnuPG v1\n"
+ "\n"
@ -555,44 +561,47 @@ class TestKey {
private final PGPPublicKeyRing pubRing;
private final PGPSecretKeyRing secRing;
private TestKey(String pubArmored, String secArmored)
throws PGPException, IOException {
private TestKey(String pubArmored, String secArmored) {
this.pubArmored = pubArmored;
this.secArmored = secArmored;
BcKeyFingerprintCalculator fc = new BcKeyFingerprintCalculator();
this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc);
this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc);
try {
this.pubRing = new PGPPublicKeyRing(newStream(pubArmored), fc);
this.secRing = new PGPSecretKeyRing(newStream(secArmored), fc);
} catch (PGPException | IOException e) {
throw new AssertionError(e);
}
}
String getPublicKeyArmored() {
public String getPublicKeyArmored() {
return pubArmored;
}
String getSecretKeyArmored() {
public String getSecretKeyArmored() {
return secArmored;
}
PGPPublicKeyRing getPublicKeyRing() {
public PGPPublicKeyRing getPublicKeyRing() {
return pubRing;
}
PGPPublicKey getPublicKey() {
public PGPPublicKey getPublicKey() {
return pubRing.getPublicKey();
}
PGPSecretKey getSecretKey() {
public PGPSecretKey getSecretKey() {
return secRing.getSecretKey();
}
long getKeyId() {
public long getKeyId() {
return getPublicKey().getKeyID();
}
String getFirstUserId() {
public String getFirstUserId() {
return (String) getPublicKey().getUserIDs().next();
}
PGPPrivateKey getPrivateKey() throws PGPException {
public PGPPrivateKey getPrivateKey() throws PGPException {
return getSecretKey().extractPrivateKey(
new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
// All test keys have no passphrase.
@ -604,5 +613,4 @@ class TestKey {
return new ArmoredInputStream(
new ByteArrayInputStream(Constants.encode(armored)));
}
}