Encrypt oauth token at rest
The oauth token provided by Github is used together with the
`github_oauth` schema, to generate a new external identity for the
Gerrit user.
The external-id is then persisted on disk as a note under the
`refs/meta/external-ids` ref of the `All-Users.git` repo, as documented
in [1].
All credentials should be stored encrypted and protected at-rest, as the
token could be used to impersonate the user associated with it.
This vulnerability has been exploited in the past on other OAuth-based
integrations [2].
Avoid using plaintext oauth tokens for storing github external-ids by
encrypting them first.
[1] https://gerrit-documentation.storage.googleapis.com/Documentation/3.6.1/config-accounts.html#external-ids
[2] https://github.blog/2022-04-15-security-alert-stolen-oauth-user-tokens/
Bug: Issue 16192
Change-Id: I69c2add9170159ab8b9a31098cf35f184e678401
diff --git a/github-oauth/pom.xml b/github-oauth/pom.xml
index dc8b456..21631aa 100644
--- a/github-oauth/pom.xml
+++ b/github-oauth/pom.xml
@@ -120,5 +120,10 @@
<version>4.4</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/CipherException.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/CipherException.java
new file mode 100644
index 0000000..6ab74d0
--- /dev/null
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/CipherException.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import java.io.IOException;
+
+/**
+ * Signals that a cipher exception has occurred. This class can be used to represent exception for
+ * both encryption and decryption failures
+ */
+public class CipherException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Constructs a {@code CipherException} with the specified detail message and cause
+ *
+ * @param message The detail message of the failure
+ * @param cause The cause of the failure
+ */
+ public CipherException(String message, Exception cause) {
+ super(message, cause);
+ }
+}
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
index 91a17d8..a826886 100644
--- a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
@@ -24,22 +24,32 @@
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.github.oauth.OAuthProtocol.Scope;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import lombok.Getter;
import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
@Singleton
public class GitHubOAuthConfig {
+ private static Logger logger = LoggerFactory.getLogger(GitHubOAuthConfig.class);
+
private final Config config;
private final CanonicalWebUrl canonicalWebUrl;
public static final String CONF_SECTION = "github";
+ public static final String CONF_KEY_SECTION = "github-key";
public static final String GITHUB_OAUTH_AUTHORIZE = "/login/oauth/authorize";
public static final String GITHUB_OAUTH_ACCESS_TOKEN = "/login/oauth/access_token";
public static final String GERRIT_OAUTH_FINAL = "/oauth";
@@ -68,6 +78,8 @@
public final long httpConnectionTimeout;
public final long httpReadTimeout;
+ private final Map<String, KeyConfig> keyConfigMap;
+ private final KeyConfig currentKeyConfig;
@Inject
protected GitHubOAuthConfig(@GerritServerConfig Config config, CanonicalWebUrl canonicalWebUrl) {
@@ -121,6 +133,30 @@
ConfigUtil.getTimeUnit(
config, CONF_SECTION, null, "httpReadTimeout", 30, TimeUnit.SECONDS),
TimeUnit.SECONDS);
+
+ Map<String, KeyConfig> configuredKeyConfig =
+ config.getSubsections(CONF_KEY_SECTION).stream()
+ .map(KeyConfig::new)
+ .collect(Collectors.toMap(KeyConfig::getKeyId, Function.identity()));
+
+ if (configuredKeyConfig.isEmpty()) {
+ logger.warn(
+ "No configured '{}' sections found. Default configuration should NOT be used in production code.",
+ CONF_KEY_SECTION);
+ currentKeyConfig = new KeyConfig();
+ keyConfigMap = Map.of(currentKeyConfig.getKeyId(), currentKeyConfig);
+ } else {
+ keyConfigMap = configuredKeyConfig;
+ List<KeyConfig> currentKeyConfigs =
+ keyConfigMap.values().stream().filter(KeyConfig::isCurrent).collect(Collectors.toList());
+ if (currentKeyConfigs.size() != 1) {
+ throw new IllegalStateException(
+ String.format(
+ "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
+ CONF_KEY_SECTION, currentKeyConfigs.size()));
+ }
+ currentKeyConfig = currentKeyConfigs.get(0);
+ }
}
public String getOAuthFinalRedirectUrl(HttpServletRequest req) {
@@ -173,4 +209,106 @@
}
return scopes.get("scopes").toArray(new Scope[0]);
}
+
+ public KeyConfig getCurrentKeyConfig() {
+ return currentKeyConfig;
+ }
+
+ public KeyConfig getKeyConfig(String subsection) {
+ return keyConfigMap.get(subsection);
+ }
+
+ public class KeyConfig {
+
+ public static final String PASSWORD_DEVICE_DEFAULT = "/dev/zero";
+ public static final int PASSWORD_LENGTH_DEFAULT = 16;
+ public static final String CIPHER_ALGORITHM_DEFAULT = "AES/ECB/PKCS5Padding";
+ public static final String SECRET_KEY_ALGORITHM_DEFAULT = "AES";
+ public static final boolean IS_CURRENT_DEFAULT = false;
+ public static final String KEY_ID_DEFAULT = "current";
+
+ public static final String PASSWORD_DEVICE_CONFIG_LABEL = "passwordDevice";
+ public static final String PASSWORD_LENGTH_CONFIG_LABEL = "passwordLength";
+ public static final String SECRET_KEY_CONFIG_LABEL = "secretKeyAlgorithm";
+ public static final String CIPHER_ALGO_CONFIG_LABEL = "cipherAlgorithm";
+ public static final String CURRENT_CONFIG_LABEL = "current";
+
+ public static final String KEY_DELIMITER = ":";
+
+ private final String passwordDevice;
+ private final Integer passwordLength;
+ private final String cipherAlgorithm;
+ private final String secretKeyAlgorithm;
+ private final String keyId;
+ private final Boolean isCurrent;
+
+ KeyConfig(String keyId) {
+
+ if (keyId.contains(KEY_DELIMITER)) {
+ throw new IllegalStateException(
+ String.format(
+ "Configuration error. %s.%s should not contain '%s'",
+ CONF_KEY_SECTION, keyId, KEY_DELIMITER));
+ }
+
+ this.passwordDevice =
+ trimTrailingSlash(
+ MoreObjects.firstNonNull(
+ config.getString(CONF_KEY_SECTION, keyId, PASSWORD_DEVICE_CONFIG_LABEL),
+ PASSWORD_DEVICE_DEFAULT));
+ this.passwordLength =
+ config.getInt(
+ CONF_KEY_SECTION, keyId, PASSWORD_LENGTH_CONFIG_LABEL, PASSWORD_LENGTH_DEFAULT);
+ isCurrent =
+ config.getBoolean(CONF_KEY_SECTION, keyId, CURRENT_CONFIG_LABEL, IS_CURRENT_DEFAULT);
+
+ this.cipherAlgorithm =
+ MoreObjects.firstNonNull(
+ config.getString(CONF_KEY_SECTION, keyId, CIPHER_ALGO_CONFIG_LABEL),
+ CIPHER_ALGORITHM_DEFAULT);
+
+ this.secretKeyAlgorithm =
+ MoreObjects.firstNonNull(
+ config.getString(CONF_KEY_SECTION, keyId, SECRET_KEY_CONFIG_LABEL),
+ SECRET_KEY_ALGORITHM_DEFAULT);
+ this.keyId = keyId;
+ }
+
+ private KeyConfig() {
+ passwordDevice = PASSWORD_DEVICE_DEFAULT;
+ passwordLength = PASSWORD_LENGTH_DEFAULT;
+ isCurrent = true;
+ cipherAlgorithm = CIPHER_ALGORITHM_DEFAULT;
+ keyId = KEY_ID_DEFAULT;
+ secretKeyAlgorithm = SECRET_KEY_ALGORITHM_DEFAULT;
+ }
+
+ public byte[] readPassword() throws IOException {
+ Path devicePath = Paths.get(passwordDevice);
+ try (FileInputStream in = new FileInputStream(devicePath.toFile())) {
+ byte[] passphrase = new byte[passwordLength];
+ if (in.read(passphrase) < passwordLength) {
+ throw new IOException("End of password device has already been reached");
+ }
+
+ return passphrase;
+ }
+ }
+
+ public String getCipherAlgorithm() {
+ return cipherAlgorithm;
+ }
+
+ public String getSecretKeyAlgorithm() {
+ return secretKeyAlgorithm;
+ }
+
+ public Boolean isCurrent() {
+ return isCurrent;
+ }
+
+ public String getKeyId() {
+ return keyId;
+ }
+ }
}
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/IdentifiedUserGitHubLoginProvider.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/IdentifiedUserGitHubLoginProvider.java
index b953531..5449347 100644
--- a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/IdentifiedUserGitHubLoginProvider.java
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/IdentifiedUserGitHubLoginProvider.java
@@ -39,17 +39,20 @@
private final GitHubOAuthConfig config;
private final AccountCache accountCache;
private final GitHubHttpConnector httpConnector;
+ private final OAuthTokenCipher oAuthTokenCipher;
@Inject
public IdentifiedUserGitHubLoginProvider(
Provider<IdentifiedUser> identifiedUserProvider,
GitHubOAuthConfig config,
GitHubHttpConnector httpConnector,
- AccountCache accountCache) {
+ AccountCache accountCache,
+ OAuthTokenCipher oAuthTokenCipher) {
this.userProvider = identifiedUserProvider;
this.config = config;
this.accountCache = accountCache;
this.httpConnector = httpConnector;
+ this.oAuthTokenCipher = oAuthTokenCipher;
}
@Override
@@ -75,16 +78,17 @@
}
}
- private AccessToken newAccessTokenFromUser(String username) {
+ private AccessToken newAccessTokenFromUser(String username) throws IOException {
AccountState account = accountCache.getByUsername(username).get();
Collection<ExternalId> externalIds = account.externalIds();
for (ExternalId accountExternalId : externalIds) {
String key = accountExternalId.key().get();
if (key.startsWith(EXTERNAL_ID_PREFIX)) {
- return new AccessToken(key.substring(EXTERNAL_ID_PREFIX.length()));
+ String encryptedOauthToken = key.substring(EXTERNAL_ID_PREFIX.length());
+ String decryptedOauthToken = oAuthTokenCipher.decrypt(encryptedOauthToken);
+ return new AccessToken(decryptedOauthToken);
}
}
-
return null;
}
}
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipher.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipher.java
new file mode 100644
index 0000000..a8133dc
--- /dev/null
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipher.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_DELIMITER;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.List;
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Provides the ability to encrypt and decrypt an OAuth token */
+@Singleton
+public class OAuthTokenCipher {
+ private final String currentCipherAlgorithm;
+ private final SecretKeySpec currentSecretKey;
+ private final String currentKeyId;
+ private final GitHubOAuthConfig config;
+
+ /**
+ * Constructs a {@code OAuthTokenCipher} using cipher algorithm specified in configuration
+ *
+ * @param config the github oauth configuration object
+ * @throws IOException when the cipher could not be constructed
+ */
+ @Inject
+ public OAuthTokenCipher(GitHubOAuthConfig config) throws IOException {
+ GitHubOAuthConfig.KeyConfig currentKeyConfig = config.getCurrentKeyConfig();
+ currentKeyId = currentKeyConfig.getKeyId();
+ currentCipherAlgorithm = currentKeyConfig.getCipherAlgorithm();
+ currentSecretKey =
+ new SecretKeySpec(
+ currentKeyConfig.readPassword(), currentKeyConfig.getSecretKeyAlgorithm());
+ this.config = config;
+ }
+
+ /**
+ * Encrypts the provided string and returns its base64 representation, prefixed with the name of
+ * the configuration subsection used to encrypt it, separated by ':'.
+ *
+ * <p>For example:
+ *
+ * <p>current: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
+ *
+ * @param plainText the string to encrypt
+ * @return the base64-encoded encrypted string
+ * @throws CipherException when the string could not be encrypted
+ */
+ public String encrypt(final String plainText) throws CipherException {
+ try {
+ return prependCurrentKeyId(
+ Base64.getEncoder()
+ .encodeToString(
+ initCurrentCipherForEncryption()
+ .doFinal(plainText.getBytes(StandardCharsets.UTF_8))));
+ } catch (IllegalBlockSizeException | BadPaddingException e) {
+ throw new CipherException("Could not encrypt oauth token", e);
+ }
+ }
+
+ /**
+ * Decrypts the provided base64-encoded encrypted string, prefixed with the name of the
+ * configuration subsection used to encrypt it, separated by ':'.
+ *
+ * <p>For example:
+ *
+ * <p>current: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
+ *
+ * <p>In order to provide back-compatibility with plaintext oauth tokens that were stored before
+ * encryption was introduced, it will return the input string as-is, when the string is not
+ * prefixed by a key-id
+ *
+ * @param base64EncryptedString the string to decrypt
+ * @return the plainText string
+ * @throws CipherException when the string could not be decrypted
+ */
+ public String decrypt(final String base64EncryptedString) throws CipherException {
+ try {
+
+ if (isPrefixedWithKeyId(base64EncryptedString)) {
+ List<String> keyIdAndMaterial = splitKeyIdFromMaterial(base64EncryptedString);
+ String keyId = keyIdAndMaterial.get(0);
+ String material = keyIdAndMaterial.get(1);
+ Cipher decryptCipher = getCipherFor(keyId);
+ return new String(
+ decryptCipher.doFinal(Base64.getDecoder().decode(material)), StandardCharsets.UTF_8);
+ }
+ return base64EncryptedString;
+
+ } catch (IllegalStateException
+ | IllegalArgumentException
+ | IllegalBlockSizeException
+ | BadPaddingException
+ | IOException e) {
+ throw new CipherException("Could not decrypt oauth token", e);
+ }
+ }
+
+ private static Cipher initCipher(String cipherAlgorithm, SecretKeySpec secretKey, int mode)
+ throws CipherException {
+ try {
+ Cipher cipher = Cipher.getInstance(cipherAlgorithm);
+ cipher.init(mode, secretKey);
+ return cipher;
+ } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new CipherException("Could not init cipher", e);
+ }
+ }
+
+ private Cipher initCurrentCipherForEncryption() throws CipherException {
+ return initCipher(currentCipherAlgorithm, currentSecretKey, Cipher.ENCRYPT_MODE);
+ }
+
+ private String prependCurrentKeyId(String base64EncodedString) {
+ return String.format("%s%s%s", currentKeyId, KEY_DELIMITER, base64EncodedString);
+ }
+
+ private static boolean isPrefixedWithKeyId(String maybeEncryptedString) {
+ return maybeEncryptedString.contains(KEY_DELIMITER);
+ }
+
+ @VisibleForTesting
+ static List<String> splitKeyIdFromMaterial(String base64EncryptedString) {
+ List<String> tokens = Splitter.on(KEY_DELIMITER).splitToList(base64EncryptedString);
+ int nOfTokens = tokens.size();
+ if (nOfTokens != 2) {
+ throw new IllegalStateException(
+ String.format(
+ "The encrypted key is expected to contain 2 tokens (keyId:key), whereas it contains %d tokens",
+ nOfTokens));
+ }
+ return tokens;
+ }
+
+ private Cipher getCipherFor(String keyId) throws IOException {
+ GitHubOAuthConfig.KeyConfig keyConfig = config.getKeyConfig(keyId);
+ if (keyConfig == null) {
+ throw new IllegalStateException(
+ String.format("Could not find key-id '%s' in configuration", keyId));
+ }
+ return initCipher(
+ keyConfig.getCipherAlgorithm(),
+ new SecretKeySpec(keyConfig.readPassword(), keyConfig.getSecretKeyAlgorithm()),
+ Cipher.DECRYPT_MODE);
+ }
+}
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthWebFilter.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthWebFilter.java
index 87f56e5..3c72141 100644
--- a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthWebFilter.java
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/OAuthWebFilter.java
@@ -49,6 +49,7 @@
private final SitePaths sites;
private final ScopedProvider<GitHubLogin> loginProvider;
private final OAuthProtocol oauth;
+ private final OAuthTokenCipher oAuthTokenCipher;
@Inject
public OAuthWebFilter(
@@ -57,11 +58,13 @@
OAuthProtocol oauth,
// We need to explicitly tell Guice the correct implementation
// as this filter is instantiated with a standard Gerrit WebModule
- GitHubLogin.Provider loginProvider) {
+ GitHubLogin.Provider loginProvider,
+ OAuthTokenCipher oAuthTokenCipher) {
this.config = config;
this.sites = sites;
this.oauth = oauth;
this.loginProvider = loginProvider;
+ this.oAuthTokenCipher = oAuthTokenCipher;
}
@Override
@@ -88,13 +91,14 @@
}
if (ghLogin != null && ghLogin.isLoggedIn()) {
+ String hashedToken = oAuthTokenCipher.encrypt(ghLogin.getToken().accessToken);
httpRequest =
new AuthenticatedHttpRequest(
httpRequest,
config.httpHeader,
ghLogin.getMyself().getLogin(),
config.oauthHttpHeader,
- GITHUB_EXT_ID + ghLogin.getToken().accessToken);
+ GITHUB_EXT_ID + hashedToken);
}
chain.doFilter(httpRequest, httpResponse);
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
new file mode 100644
index 0000000..94d1a43
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_KEY_SECTION;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_SECTION;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGORITHM_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGO_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CURRENT_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_DELIMITER;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_ID_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_ALGORITHM_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_CONFIG_LABEL;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.httpd.CanonicalWebUrl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GitHubOAuthConfigTest {
+
+ CanonicalWebUrl canonicalWebUrl;
+ Config config;
+
+ @Before
+ public void setUp() {
+ config = new Config();
+ config.setString(CONF_SECTION, null, "clientSecret", "theSecret");
+ config.setString(CONF_SECTION, null, "clientId", "theClientId");
+ config.setString("auth", null, "httpHeader", "GITHUB_USER");
+ config.setString("auth", null, "type", AuthType.HTTP.toString());
+
+ canonicalWebUrl =
+ Guice.createInjector(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(String.class)
+ .annotatedWith(com.google.gerrit.server.config.CanonicalWebUrl.class)
+ .toProvider(Providers.of(null));
+ }
+ })
+ .getInstance(CanonicalWebUrl.class);
+ }
+
+ @Test
+ public void shouldDefaultKeyConfigWhenNoSpecificConfigurationIsSet() {
+ GitHubOAuthConfig objectUnderTest = objectUnderTest();
+ assertEquals(objectUnderTest.getCurrentKeyConfig().isCurrent(), true);
+ assertEquals(
+ objectUnderTest.getCurrentKeyConfig().getCipherAlgorithm(), CIPHER_ALGORITHM_DEFAULT);
+ assertEquals(
+ objectUnderTest.getCurrentKeyConfig().getSecretKeyAlgorithm(),
+ SECRET_KEY_ALGORITHM_DEFAULT);
+ assertEquals(objectUnderTest.getCurrentKeyConfig().getKeyId(), KEY_ID_DEFAULT);
+ }
+
+ @Test
+ public void shouldDBeAbleToLookupDefaultKeyWhenNoSpecificConfigurationIsSet() {
+ assertEquals(objectUnderTest().getKeyConfig(KEY_ID_DEFAULT).isCurrent(), true);
+ }
+
+ @Test
+ public void shouldReadASpecificKeyConfig() {
+ String keySubsection = "someKeyConfig";
+ String cipherAlgorithm = "AES/CFB8/NoPadding";
+ String secretKeyAlgorithm = "DES";
+ config.setBoolean(CONF_KEY_SECTION, keySubsection, CURRENT_CONFIG_LABEL, true);
+ config.setString(CONF_KEY_SECTION, keySubsection, CIPHER_ALGO_CONFIG_LABEL, cipherAlgorithm);
+ config.setString(CONF_KEY_SECTION, keySubsection, SECRET_KEY_CONFIG_LABEL, secretKeyAlgorithm);
+
+ GitHubOAuthConfig objectUnderTest = objectUnderTest();
+
+ assertEquals(objectUnderTest.getCurrentKeyConfig().isCurrent(), true);
+ assertEquals(objectUnderTest.getCurrentKeyConfig().getCipherAlgorithm(), cipherAlgorithm);
+ assertEquals(objectUnderTest.getCurrentKeyConfig().getSecretKeyAlgorithm(), secretKeyAlgorithm);
+ assertEquals(objectUnderTest.getCurrentKeyConfig().getKeyId(), keySubsection);
+ }
+
+ @Test
+ public void shouldReturnTheExpectedKeyConfigAsCurrent() {
+ config.setBoolean(CONF_KEY_SECTION, "currentKeyConfig", CURRENT_CONFIG_LABEL, true);
+ config.setBoolean(CONF_KEY_SECTION, "someOtherKeyConfig", CURRENT_CONFIG_LABEL, false);
+
+ assertEquals(objectUnderTest().getCurrentKeyConfig().getKeyId(), "currentKeyConfig");
+ }
+
+ @Test
+ public void shouldReadMultipleKeyConfigs() {
+ String currentKeyConfig = "currentKeyConfig";
+ String someOtherKeyConfig = "someOtherKeyConfig";
+ config.setBoolean(CONF_KEY_SECTION, currentKeyConfig, CURRENT_CONFIG_LABEL, true);
+ config.setBoolean(CONF_KEY_SECTION, someOtherKeyConfig, CURRENT_CONFIG_LABEL, false);
+
+ GitHubOAuthConfig objectUnderTest = objectUnderTest();
+
+ assertEquals(objectUnderTest.getKeyConfig(currentKeyConfig).getKeyId(), currentKeyConfig);
+ assertEquals(objectUnderTest.getKeyConfig(someOtherKeyConfig).getKeyId(), someOtherKeyConfig);
+ }
+
+ @Test
+ public void shouldThrowWhenNoKeyConfigIsSetAsCurrent() {
+ config.setBoolean(CONF_KEY_SECTION, "someKeyConfig", CURRENT_CONFIG_LABEL, false);
+
+ assertThrows(IllegalStateException.class, this::objectUnderTest);
+ }
+
+ @Test
+ public void shouldThrowWhenKeyConfigContainsDelimiterCharacter() {
+ String invalidSubsection = "foo" + KEY_DELIMITER + "bar";
+ config.setBoolean(CONF_KEY_SECTION, invalidSubsection, CURRENT_CONFIG_LABEL, false);
+
+ IllegalStateException illegalStateException =
+ assertThrows(IllegalStateException.class, this::objectUnderTest);
+
+ assertEquals(
+ illegalStateException.getMessage(),
+ String.format(
+ "Configuration error. %s.%s should not contain '%s'",
+ CONF_KEY_SECTION, invalidSubsection, KEY_DELIMITER));
+ }
+
+ @Test
+ public void shouldThrowWhenMoreThanOneKeyConfigIsSetAsCurrent() {
+ config.setBoolean(CONF_KEY_SECTION, "someKeyConfig", CURRENT_CONFIG_LABEL, true);
+ config.setBoolean(CONF_KEY_SECTION, "someOtherKeyConfig", CURRENT_CONFIG_LABEL, true);
+
+ assertThrows(IllegalStateException.class, this::objectUnderTest);
+ }
+
+ private GitHubOAuthConfig objectUnderTest() {
+ return new GitHubOAuthConfig(config, canonicalWebUrl);
+ }
+}
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
new file mode 100644
index 0000000..9621fc5
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_KEY_SECTION;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_SECTION;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGO_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CURRENT_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.OAuthTokenCipher.splitKeyIdFromMaterial;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.httpd.CanonicalWebUrl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class OAuthTokenCipherTest {
+
+ CanonicalWebUrl canonicalWebUrl;
+ Config config;
+
+ private static final String VERSION1_KEY_ID = "version1";
+ private static final String VERSION2_KEY_ID = "version2";
+
+ @Before
+ public void setUp() {
+ config = new Config();
+
+ config.setString(CONF_SECTION, null, "clientSecret", "theSecret");
+ config.setString(CONF_SECTION, null, "clientId", "theClientId");
+ config.setString("auth", null, "httpHeader", "GITHUB_USER");
+ config.setString("auth", null, "type", AuthType.HTTP.toString());
+
+ config.setBoolean(CONF_KEY_SECTION, VERSION1_KEY_ID, CURRENT_CONFIG_LABEL, true);
+ config.setBoolean(CONF_KEY_SECTION, VERSION2_KEY_ID, CURRENT_CONFIG_LABEL, false);
+
+ canonicalWebUrl =
+ Guice.createInjector(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(String.class)
+ .annotatedWith(com.google.gerrit.server.config.CanonicalWebUrl.class)
+ .toProvider(Providers.of(null));
+ }
+ })
+ .getInstance(CanonicalWebUrl.class);
+ }
+
+ @Test
+ public void shouldEncryptAndDecryptAToken() throws IOException {
+ String someOauthToken = "someToken";
+ OAuthTokenCipher objectUnderTest = objectUnderTest();
+
+ String encrypt = objectUnderTest.encrypt(someOauthToken);
+ assertNotEquals(encrypt, someOauthToken);
+ assertEquals(objectUnderTest.decrypt(encrypt), someOauthToken);
+ }
+
+ @Test
+ public void shouldEncryptWithKeyId() throws IOException {
+ assertEquals(takeKeyId(objectUnderTest().encrypt("someToken")), VERSION1_KEY_ID);
+ }
+
+ @Test
+ public void shouldReturnAPrefixedBase64EncodedEncryptedString() throws IOException {
+ String someOauthToken = "someToken";
+ List<String> keyAndMaterial = splitKeyIdFromMaterial(objectUnderTest().encrypt(someOauthToken));
+ String keyId = keyAndMaterial.get(0);
+ String material = keyAndMaterial.get(1);
+
+ assertEquals(keyId, VERSION1_KEY_ID);
+ assertNotEquals(
+ Base64.getDecoder().decode(material), someOauthToken.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void shouldStillBeAbleToDecryptATokenEncryptedWithANonCurrentKey() throws IOException {
+ String someToken = "someToken";
+ String encryptedWithV1 = objectUnderTest().encrypt(someToken);
+
+ config.setBoolean(CONF_KEY_SECTION, VERSION1_KEY_ID, CURRENT_CONFIG_LABEL, false);
+ config.setBoolean(CONF_KEY_SECTION, VERSION2_KEY_ID, CURRENT_CONFIG_LABEL, true);
+
+ assertEquals(objectUnderTest().decrypt(encryptedWithV1), someToken);
+ }
+
+ @Test
+ public void shouldPassThroughWhenDecryptingPlainTextStrings() throws IOException {
+ String somePlainTextToken = "someToken";
+ assertEquals(objectUnderTest().decrypt(somePlainTextToken), somePlainTextToken);
+ }
+
+ @Test
+ public void shouldThrowWhenDecryptingATokenEncryptedANoLongerAvailableKey() {
+ CipherException cipherException =
+ assertThrows(
+ CipherException.class, () -> objectUnderTest().decrypt("non-existing-key:foobar"));
+
+ assertEquals(
+ cipherException.getCause().getMessage(),
+ "Could not find key-id 'non-existing-key' in configuration");
+ }
+
+ @Test
+ public void shouldThrowWhenCipherAlgorithmIsNotValid() {
+ config.setString(
+ CONF_KEY_SECTION, VERSION1_KEY_ID, CIPHER_ALGO_CONFIG_LABEL, "Invalid cipher algorithm");
+
+ assertThrows(CipherException.class, () -> objectUnderTest().encrypt("some token"));
+ }
+
+ @Test
+ public void shouldThrowWhenKeyAlgorithmIsNotValid() {
+ config.setString(
+ CONF_KEY_SECTION, VERSION1_KEY_ID, SECRET_KEY_CONFIG_LABEL, "Invalid Key algorithm");
+
+ assertThrows(CipherException.class, () -> objectUnderTest().encrypt("some token"));
+ }
+
+ @Test
+ public void shouldThrowWhenPasswordCouldNotBeRead() {
+ config.setString(
+ CONF_KEY_SECTION, VERSION1_KEY_ID, PASSWORD_DEVICE_CONFIG_LABEL, "/some/unexisting/file");
+
+ assertThrows(IOException.class, this::objectUnderTest);
+ }
+
+ @Test
+ public void shouldThrowWhenDecryptingANonBase64String() {
+ assertThrows(
+ IOException.class, () -> objectUnderTest().decrypt("current:some non-base64 string"));
+ }
+
+ private static String takeKeyId(String base64EncryptedString) {
+ return splitKeyIdFromMaterial(base64EncryptedString).get(0);
+ }
+
+ private OAuthTokenCipher objectUnderTest() throws IOException {
+ return new OAuthTokenCipher(new GitHubOAuthConfig(config, canonicalWebUrl));
+ }
+}
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/filters/GitHubOAuthFilter.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/filters/GitHubOAuthFilter.java
index 4fc1533..b025737 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/filters/GitHubOAuthFilter.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/filters/GitHubOAuthFilter.java
@@ -26,6 +26,7 @@
import com.googlesource.gerrit.plugins.github.oauth.IdentifiedUserGitHubLoginProvider;
import com.googlesource.gerrit.plugins.github.oauth.OAuthFilter;
import com.googlesource.gerrit.plugins.github.oauth.OAuthProtocol.AccessToken;
+import com.googlesource.gerrit.plugins.github.oauth.OAuthTokenCipher;
import com.googlesource.gerrit.plugins.github.oauth.OAuthWebFilter;
import com.googlesource.gerrit.plugins.github.oauth.ScopedProvider;
import java.io.IOException;
@@ -47,15 +48,18 @@
private final ScopedProvider<GitHubLogin> loginProvider;
private final Provider<CurrentUser> userProvider;
private final AccountCache accountCache;
+ private final OAuthTokenCipher oAuthTokenCipher;
@Inject
public GitHubOAuthFilter(
ScopedProvider<GitHubLogin> loginProvider,
Provider<CurrentUser> userProvider,
- AccountCache accountCache) {
+ AccountCache accountCache,
+ OAuthTokenCipher oAuthTokenCipher) {
this.loginProvider = loginProvider;
this.userProvider = userProvider;
this.accountCache = accountCache;
+ this.oAuthTokenCipher = oAuthTokenCipher;
}
@Override
@@ -78,7 +82,8 @@
.get()
.substring(
ExternalId.SCHEME_EXTERNAL.length() + OAuthWebFilter.GITHUB_EXT_ID.length() + 1);
- hubLogin.login(new AccessToken(oauthToken));
+ String decryptedToken = oAuthTokenCipher.decrypt(oauthToken);
+ hubLogin.login(new AccessToken(decryptedToken));
}
chain.doFilter(request, response);
diff --git a/github-plugin/src/main/resources/Documentation/config.md b/github-plugin/src/main/resources/Documentation/config.md
index e8fc5fa..ea32890 100644
--- a/github-plugin/src/main/resources/Documentation/config.md
+++ b/github-plugin/src/main/resources/Documentation/config.md
@@ -83,4 +83,60 @@
* s, sec, second, seconds
* m, min, minute, minutes
* h, hr, hour, hours
- Default value: 30 seconds
\ No newline at end of file
+ Default value: 30 seconds
+
+Key Configuration
+-------------
+
+Since this plugin obtains credentials from Github and persists them in Gerrit,
+it also takes care of encrypting them at rest. The Gerrit admin can configure
+how this is done by setting the relevant configuration parameters.
+
+github-key.<key-id>.passwordDevice
+: The device or file where to retrieve the encryption passphrase.\
+Default: /dev/zero
+
+*NOTE*: such configuration is considered insecure and should *not be used in
+production*, always set a non-zero password device for deriving the key.
+
+github-key.<key-id>.passwordLength
+: The length in bytes of the password read from the passwordDevice.\
+Default: 16
+
+github-key.<key-id>.cipherAlgorithm
+: The algorithm to be used for encryption/decryption. Available algorithms are
+described in
+the [Cipher section](https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#cipher-algorithm-names)
+of the Java Cryptography Architecture Standard Algorithm Name Documentation.\
+Default: AES/ECB/PKCS5Padding
+
+github-key.<key-id>.secretKeyAlgorithm
+: the algorithm to be used to encrypt the provided password. Available
+algorithms are described in
+the [Cipher section](https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#cipher-algorithm-names)
+of the Java Cryptography Architecture Standard Algorithm Name Documentation.\
+Default: AES
+
+github-key.<key-id>.current
+: Whether this configuration is the current one, and it should be used to
+encrypt new Github credentials. Note that _exactly_ one github-key configuration
+must be set to `current`, otherwise an error exception will be thrown.\
+Default: false
+
+As you can observe, in order to support key rotations, multiple `github-key`
+can be specified in configuration. credentials encrypted with a `<key-id>` key
+can still be decrypted as long as the `github-key.<key-id>` stanza is available
+in the configuration. New credentials will always be encrypted with
+the `current` `<key-id>`.
+
+Note that unencrypted oauth tokens will be handled gracefully and just passed
+through to github by the decryption algorithm. This is done so that oauth tokens
+that were persisted _before_ the encryption feature was implemented will still
+be considered valid until their natural expiration time.
+
+If no `github-key.<key-id>` exists in configuration, then a default current key
+configuration
+(named `current`) will be inferred, using the defaults documented above.
+
+*NOTE* such configuration is considered insecure and should *not be used in
+production*.
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 6202ae3..34b1018 100644
--- a/pom.xml
+++ b/pom.xml
@@ -302,4 +302,14 @@
<scope>provided</scope>
</dependency>
</dependencies>
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.13.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
</project>