Merge branch 'stable-3.6'

* stable-3.6:
  Generate default password during the plugin's init step
  Encrypt oauth token at rest
  Remove test dependencies
  Remove unused GITHUB_GET_USER variable
  Remove unused constructor argument
  Bump Apache Velocity to 2.3

Change-Id: I68e9dc2607c036eb83f95af62ba205539bdb89fe
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 eb81e47..7ef81d1 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
@@ -13,23 +13,29 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.github.oauth;
 
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
+
 import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.httpd.CanonicalWebUrl;
-import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 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;
@@ -41,9 +47,9 @@
   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 GITHUB_GET_USER = "/user";
   public static final String GERRIT_OAUTH_FINAL = "/oauth";
   public static final String GITHUB_URL_DEFAULT = "https://github.com";
   public static final String GITHUB_API_URL_DEFAULT = "https://api.github.com";
@@ -70,10 +76,11 @@
 
   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, AuthConfig authConfig) {
+  protected GitHubOAuthConfig(@GerritServerConfig Config config, CanonicalWebUrl canonicalWebUrl) {
     this.config = config;
     this.canonicalWebUrl = canonicalWebUrl;
 
@@ -124,6 +131,21 @@
             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()));
+    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) {
@@ -176,4 +198,110 @@
     }
     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 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(getPasswordDeviceOrThrow(config, keyId));
+      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;
+    }
+
+    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;
+    }
+  }
+
+  /**
+   * Method returns the password device value for a given {@code keyId}.
+   *
+   * @throws {@link IllegalStateException} when password device is not configured for {@code keyId}
+   */
+  private static String getPasswordDeviceOrThrow(Config config, String keyId) {
+    String passwordDevice =
+        config.getString(CONF_KEY_SECTION, keyId, KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL);
+    if (Strings.isNullOrEmpty(passwordDevice)) {
+      throw new IllegalStateException(
+          String.format(
+              "Configuration error. Missing %s.%s for key-id '%s'",
+              CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, keyId));
+    }
+
+    return passwordDevice;
+  }
 }
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/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java
new file mode 100644
index 0000000..2f36425
--- /dev/null
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java
@@ -0,0 +1,99 @@
+// 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.PASSWORD_LENGTH_DEFAULT;
+import static java.util.Objects.requireNonNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PasswordGenerator {
+  private static final Logger logger = LoggerFactory.getLogger(PasswordGenerator.class);
+  public static final String DEFAULT_PASSWORD_FILE = "default.key";
+
+  /**
+   * Generates default password and stores under given {@code Path}. Note that if password already
+   * exists it is not regenerated.
+   *
+   * @param passwordFilePath path that should contain the default password; cannot be {@code null}
+   * @throws {@link IllegalStateException} when file denoted by given {@code Path} is a directory,
+   *     cannot be read, has invalid length or doesn't exist and cannot be created
+   * @return {@code true} if password was generated, {@code false} if it already exists
+   */
+  public boolean generate(Path passwordFilePath) {
+    requireNonNull(passwordFilePath);
+
+    File passwordFile = passwordFilePath.toFile();
+
+    if (passwordFile.isDirectory()) {
+      throw logErrorAndCreateRuntimeException(
+          "'%s' is directory whilst a regular file was expected.", passwordFilePath);
+    }
+
+    if (passwordFile.isFile()) {
+      if (!passwordFile.canRead()) {
+        throw logErrorAndCreateRuntimeException(
+            "'%s' password file exists, but cannot be read.", passwordFilePath);
+      }
+
+      long length = passwordFile.length();
+      if (length != PASSWORD_LENGTH_DEFAULT) {
+        throw logErrorAndCreateRuntimeException(
+            "'%s' password file exists but has an invalid length of %d bytes. The expected length is %d bytes.",
+            passwordFilePath, length, PASSWORD_LENGTH_DEFAULT);
+      }
+      return false;
+    }
+
+    byte[] token = generateToken();
+    try {
+      Files.write(passwordFilePath, token);
+      logger.info("Password was stored in {} file", passwordFilePath);
+      return true;
+    } catch (IOException e) {
+      throw logErrorAndCreateRuntimeException(e, "Password generation has failed");
+    }
+  }
+
+  private byte[] generateToken() {
+    SecureRandom random = new SecureRandom();
+    byte[] token = new byte[PASSWORD_LENGTH_DEFAULT];
+    random.nextBytes(token);
+    return token;
+  }
+
+  private IllegalStateException logErrorAndCreateRuntimeException(
+      String msg, Object... parameters) {
+    return logErrorAndCreateRuntimeException(null, msg, parameters);
+  }
+
+  private IllegalStateException logErrorAndCreateRuntimeException(
+      Exception e, String msg, Object... parameters) {
+    String log = String.format(msg, parameters);
+    if (e != null) {
+      logger.error(log, e);
+      return new IllegalStateException(log, e);
+    }
+
+    logger.error(log);
+    return new IllegalStateException(log);
+  }
+}
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..a5bf767
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
@@ -0,0 +1,172 @@
+// 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.KEY_DELIMITER;
+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 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;
+  private static final String testPasswordDevice = "/dev/zero";
+
+  @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 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, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+    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() {
+    String currentKeyConfig = "currentKeyConfig";
+    String someOtherKeyConfig = "someOtherKeyConfig";
+    config.setBoolean(CONF_KEY_SECTION, currentKeyConfig, CURRENT_CONFIG_LABEL, true);
+    config.setString(
+        CONF_KEY_SECTION, currentKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+    config.setBoolean(CONF_KEY_SECTION, someOtherKeyConfig, CURRENT_CONFIG_LABEL, false);
+    config.setString(
+        CONF_KEY_SECTION, someOtherKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+
+    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.setString(
+        CONF_KEY_SECTION, currentKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+    config.setBoolean(CONF_KEY_SECTION, someOtherKeyConfig, CURRENT_CONFIG_LABEL, false);
+    config.setString(
+        CONF_KEY_SECTION, someOtherKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+
+    GitHubOAuthConfig objectUnderTest = objectUnderTest();
+
+    assertEquals(objectUnderTest.getKeyConfig(currentKeyConfig).getKeyId(), currentKeyConfig);
+    assertEquals(objectUnderTest.getKeyConfig(someOtherKeyConfig).getKeyId(), someOtherKeyConfig);
+  }
+
+  @Test
+  public void shouldThrowWhenNoKeyIdIsConfigured() {
+    IllegalStateException illegalStateException =
+        assertThrows(IllegalStateException.class, this::objectUnderTest);
+
+    assertEquals(
+        illegalStateException.getMessage(),
+        String.format(
+            "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
+            CONF_KEY_SECTION, 0));
+  }
+
+  @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);
+  }
+
+  @Test
+  public void shouldThrowWhenKeyIdMissesPasswordDevice() {
+    String someKeyConfig = "someKeyConfig";
+    config.setBoolean(CONF_KEY_SECTION, someKeyConfig, CURRENT_CONFIG_LABEL, true);
+
+    IllegalStateException illegalStateException =
+        assertThrows(IllegalStateException.class, this::objectUnderTest);
+
+    assertEquals(
+        String.format(
+            "Configuration error. Missing %s.%s for key-id '%s'",
+            CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, someKeyConfig),
+        illegalStateException.getMessage());
+  }
+
+  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..3e31d6b
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
@@ -0,0 +1,207 @@
+// 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.KEY_ID_DEFAULT;
+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.nio.file.Path;
+import java.util.Base64;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class OAuthTokenCipherTest {
+
+  CanonicalWebUrl canonicalWebUrl;
+  Config config;
+
+  @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private static final String VERSION1_KEY_ID = "version1";
+  private static final String VERSION2_KEY_ID = "version2";
+
+  @Before
+  public void setUp() {
+    config = createCommonConfig();
+
+    config.setBoolean(CONF_KEY_SECTION, VERSION1_KEY_ID, CURRENT_CONFIG_LABEL, true);
+    config.setBoolean(CONF_KEY_SECTION, VERSION2_KEY_ID, CURRENT_CONFIG_LABEL, false);
+
+    String testPasswordDevice = "/dev/zero";
+    config.setString(
+        CONF_KEY_SECTION, VERSION1_KEY_ID, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+    config.setString(
+        CONF_KEY_SECTION, VERSION2_KEY_ID, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+
+    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 shouldEncryptAndDecryptATokenWithPasswordGeneratedAtInit() throws IOException {
+    // simulate plugin init step by generating a password to a file and configuring it in
+    // gerrit.config
+    Path passwordFilePath =
+        temporaryFolder.newFolder().toPath().resolve(PasswordGenerator.DEFAULT_PASSWORD_FILE);
+    new PasswordGenerator().generate(passwordFilePath);
+
+    config = createCommonConfig();
+    config.setBoolean(CONF_KEY_SECTION, KEY_ID_DEFAULT, CURRENT_CONFIG_LABEL, true);
+    config.setString(
+        CONF_KEY_SECTION,
+        KEY_ID_DEFAULT,
+        PASSWORD_DEVICE_CONFIG_LABEL,
+        passwordFilePath.toString());
+
+    verifyTokenEncryptionAndDecryption(objectUnderTest());
+  }
+
+  @Test
+  public void shouldEncryptAndDecryptAToken() throws IOException {
+    verifyTokenEncryptionAndDecryption(objectUnderTest());
+  }
+
+  private void verifyTokenEncryptionAndDecryption(OAuthTokenCipher objectUnderTest)
+      throws CipherException {
+    String someOauthToken = "someToken";
+    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 objectUnderTest(config);
+  }
+
+  private OAuthTokenCipher objectUnderTest(Config testConfig) throws IOException {
+    return new OAuthTokenCipher(new GitHubOAuthConfig(testConfig, canonicalWebUrl));
+  }
+
+  private static Config createCommonConfig() {
+    Config 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());
+    return config;
+  }
+}
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java
new file mode 100644
index 0000000..0fe37a8
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java
@@ -0,0 +1,92 @@
+// 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.PASSWORD_LENGTH_DEFAULT;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class PasswordGeneratorTest {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private Path passwordPath;
+  private final PasswordGenerator objectUnderTest = new PasswordGenerator();
+
+  @Before
+  public void setup() throws IOException {
+    passwordPath =
+        temporaryFolder.newFolder().toPath().resolve(PasswordGenerator.DEFAULT_PASSWORD_FILE);
+  }
+
+  @Test
+  public void shouldGenerateKeyFileWithPasswordDefaultLength() throws IOException {
+    assertTrue(objectUnderTest.generate(passwordPath));
+    assertTrue(Files.isRegularFile(passwordPath));
+
+    byte[] token = Files.readAllBytes(passwordPath);
+    assertEquals(
+        String.format(
+            "Generated password length doesn't equal to expected %d", PASSWORD_LENGTH_DEFAULT),
+        token.length,
+        PASSWORD_LENGTH_DEFAULT);
+  }
+
+  @Test
+  public void shouldNotGenerateNewDefaultKeyIfOneAlreadyExistAndIsNotEmpty() throws IOException {
+    assertTrue(objectUnderTest.generate(passwordPath));
+    byte[] expected = Files.readAllBytes(passwordPath);
+    assertFalse(objectUnderTest.generate(passwordPath));
+    byte[] token = Files.readAllBytes(passwordPath);
+    assertArrayEquals("Existing password file was overwritten", expected, token);
+  }
+
+  @Test
+  public void shouldGenerateDifferentContentForDifferentSites() throws IOException {
+    assertTrue(objectUnderTest.generate(passwordPath));
+    byte[] siteA = Files.readAllBytes(passwordPath);
+
+    assertTrue(passwordPath.toFile().delete());
+    assertTrue(objectUnderTest.generate(passwordPath));
+    byte[] siteB = Files.readAllBytes(passwordPath);
+    assertFalse(
+        "The same password was generated for two different sites", Arrays.equals(siteA, siteB));
+  }
+
+  @Test
+  public void shouldThrowIllegalStateExceptionWhenDefaultKeyIsDirectory() throws IOException {
+    // create dir from passwordPath
+    assertTrue(passwordPath.toFile().mkdir());
+
+    IllegalStateException illegalStateException =
+        assertThrows(IllegalStateException.class, () -> objectUnderTest.generate(passwordPath));
+
+    assertTrue(
+        illegalStateException
+            .getMessage()
+            .endsWith("is directory whilst a regular file was expected."));
+  }
+}
diff --git a/github-plugin/pom.xml b/github-plugin/pom.xml
index e12fd99..ed54bf3 100644
--- a/github-plugin/pom.xml
+++ b/github-plugin/pom.xml
@@ -101,12 +101,6 @@
       <scope>provided</scope>
     </dependency>
     <dependency>
-      <groupId>junit</groupId>
-      <artifactId>junit</artifactId>
-      <version>4.8.1</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
       <groupId>${project.groupId}</groupId>
       <artifactId>github-oauth</artifactId>
       <version>${project.version}</version>
@@ -160,14 +154,8 @@
     </dependency>
     <dependency>
       <groupId>org.apache.velocity</groupId>
-      <artifactId>velocity</artifactId>
-      <version>1.7</version>
-    </dependency>
-    <dependency>
-      <groupId>org.jukito</groupId>
-      <artifactId>jukito</artifactId>
-      <version>1.2</version>
-      <scope>test</scope>
+      <artifactId>velocity-engine-core</artifactId>
+      <version>2.3</version>
     </dependency>
   </dependencies>
 </project>
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
index fe9cd2f..010660e 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.httpd.CanonicalWebUrl;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -75,10 +74,9 @@
       @GerritServerConfig Config config,
       final SitePaths site,
       AllProjectsNameProvider allProjectsNameProvider,
-      CanonicalWebUrl canonicalWebUrl,
-      AuthConfig authConfig)
+      CanonicalWebUrl canonicalWebUrl)
       throws MalformedURLException {
-    super(config, canonicalWebUrl, authConfig);
+    super(config, canonicalWebUrl);
     String[] wizardFlows = config.getStringList(CONF_SECTION, null, CONF_WIZARD_FLOW);
     for (String fromTo : wizardFlows) {
       boolean redirect = fromTo.indexOf(FROM_TO_REDIRECT_SEPARATOR) > 0;
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
index 828613e..894e1a5 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
@@ -13,15 +13,35 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.github;
 
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_KEY_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.IS_CURRENT_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_ID_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_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 com.googlesource.gerrit.plugins.github.oauth.PasswordGenerator.DEFAULT_PASSWORD_FILE;
+
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.InitUtil;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.github.oauth.PasswordGenerator;
 import java.net.URISyntaxException;
+import java.nio.file.Path;
 import java.util.EnumSet;
+import java.util.Optional;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
 
 public class InitGitHub implements InitStep {
   private static final String GITHUB_URL = "https://github.com";
@@ -29,15 +49,26 @@
   private static final String GITHUB_REGISTER_APPLICATION_PATH = "/settings/applications/new";
   private static final String GERRIT_OAUTH_CALLBACK_PATH = "oauth";
 
+  private final Path pluginData;
   private final ConsoleUI ui;
   private final Section auth;
   private final Section httpd;
   private final Section github;
   private final Section gerrit;
+  private final Section.Factory sections;
+  private final FileBasedConfig cfg;
 
   @Inject
-  InitGitHub(final ConsoleUI ui, final Section.Factory sections) {
+  InitGitHub(
+      @PluginName String pluginName,
+      SitePaths site,
+      final ConsoleUI ui,
+      final Section.Factory sections,
+      InitFlags flags) {
+    this.pluginData = site.data_dir.resolve(pluginName);
     this.ui = ui;
+    this.sections = sections;
+    this.cfg = flags.cfg;
     this.github = sections.get("github", null);
     this.httpd = sections.get("httpd", null);
     this.auth = sections.get("auth", null);
@@ -85,6 +116,87 @@
       httpd.unset("filterClass");
       httpd.unset("httpHeader");
     }
+
+    setupGitHubOAuthTokenCipher();
+  }
+
+  private void setupGitHubOAuthTokenCipher() {
+    ui.header("GitHub OAuth token cipher configuration");
+    Optional<String> maybeCurrentKeyId = getCurrentKeyId();
+
+    boolean configureNewPasswordDevice = false;
+    if (maybeCurrentKeyId.isPresent()) {
+      if (!ui.yesno(
+          false,
+          "Current GitHub OAuth token cipher is configured under the %s key id. Do you want to configure a new one?",
+          maybeCurrentKeyId.get())) {
+        return;
+      }
+
+      String notUniqueKeyIdPrefix = "K";
+      do {
+        String newKeyId = ui.readString("some-key-id", "%sey identifier", notUniqueKeyIdPrefix);
+        if (cfg.getSubsections(CONF_KEY_SECTION).contains(newKeyId)) {
+          notUniqueKeyIdPrefix =
+              String.format(
+                  "Provided key id '%s' already exists. Please provide a different key.", newKeyId);
+          continue;
+        }
+
+        cfg.setBoolean(CONF_KEY_SECTION, maybeCurrentKeyId.get(), CURRENT_CONFIG_LABEL, false);
+        maybeCurrentKeyId = Optional.of(newKeyId);
+        configureNewPasswordDevice = true;
+        break;
+      } while (true);
+    }
+
+    String currentKeyId = maybeCurrentKeyId.orElse(KEY_ID_DEFAULT);
+    ui.message("Configuring GitHub OAuth token cipher under '%s' key id\n", currentKeyId);
+    Section gitHubKey = sections.get(CONF_KEY_SECTION, currentKeyId);
+
+    gitHubKey.set(CURRENT_CONFIG_LABEL, "true");
+
+    Path defaultPasswordPath = pluginData.resolve(DEFAULT_PASSWORD_FILE);
+    String currentPasswordPath =
+        gitHubKey.string(
+            "Password file or device",
+            PASSWORD_DEVICE_CONFIG_LABEL,
+            configureNewPasswordDevice ? null : defaultPasswordPath.toString());
+    if (defaultPasswordPath.toString().equalsIgnoreCase(currentPasswordPath)) {
+      pluginData.toFile().mkdirs();
+      if (new PasswordGenerator().generate(Path.of(currentPasswordPath))) {
+        ui.message(
+            "New password (%d bytes long) was generated under '%s' file.\n",
+            PASSWORD_LENGTH_DEFAULT, currentPasswordPath);
+      } else {
+        ui.message(
+            "The file under '%s' path already exists. Password wasn't regenerated.\n",
+            currentPasswordPath);
+      }
+    } else {
+      // ask for length only if default password is not used
+      gitHubKey.set(
+          PASSWORD_LENGTH_CONFIG_LABEL,
+          String.valueOf(ui.readInt(PASSWORD_LENGTH_DEFAULT, "Password length in bytes")));
+    }
+
+    gitHubKey.string(
+        "The algorithm to be used to encrypt the provided password",
+        SECRET_KEY_CONFIG_LABEL,
+        SECRET_KEY_ALGORITHM_DEFAULT);
+
+    gitHubKey.string(
+        "The algorithm to be used for encryption/decryption",
+        CIPHER_ALGO_CONFIG_LABEL,
+        CIPHER_ALGORITHM_DEFAULT);
+  }
+
+  private Optional<String> getCurrentKeyId() {
+    return cfg.getSubsections(CONF_KEY_SECTION).stream()
+        .filter(
+            keyId ->
+                cfg.getBoolean(CONF_KEY_SECTION, keyId, CURRENT_CONFIG_LABEL, IS_CURRENT_DEFAULT))
+        .findFirst();
   }
 
   private void authSetDefault(String key, String defValue) {
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/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java
index 01fe742..1bdce3c 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java
@@ -53,7 +53,7 @@
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.DecoderException;
 import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityModel.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityModel.java
index 4970e1d..87fe39d 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityModel.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityModel.java
@@ -43,7 +43,7 @@
     return context.put(key, value);
   }
 
-  public Object remove(Object key) {
+  public Object remove(String key) {
     return context.remove(key);
   }
 }
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityRuntimeProvider.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityRuntimeProvider.java
index d0080c5..e1b4d9b 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityRuntimeProvider.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/PluginVelocityRuntimeProvider.java
@@ -24,7 +24,6 @@
 import java.util.Properties;
 import org.apache.velocity.runtime.RuntimeConstants;
 import org.apache.velocity.runtime.RuntimeInstance;
-import org.apache.velocity.runtime.log.Log4JLogChute;
 import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
 import org.apache.velocity.runtime.resource.loader.JarResourceLoader;
 
@@ -51,7 +50,6 @@
 
     Properties p = new Properties();
     p.setProperty(RuntimeConstants.VM_PERM_INLINE_LOCAL, "true");
-    p.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, Log4JLogChute.class.getName());
     p.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
     p.setProperty("runtime.log.logsystem.log4j.category", "velocity");
 
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/VelocityStaticServlet.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/VelocityStaticServlet.java
index 06a5556..7ea0283 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/VelocityStaticServlet.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/velocity/VelocityStaticServlet.java
@@ -15,21 +15,26 @@
 package com.googlesource.gerrit.plugins.github.velocity;
 
 import com.google.common.collect.Maps;
+import com.google.gerrit.httpd.raw.SiteStaticDirectoryServlet;
+import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.PluginEntry;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
 import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPOutputStream;
+import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.io.IOUtils;
 import org.apache.velocity.runtime.RuntimeInstance;
@@ -43,25 +48,17 @@
 public class VelocityStaticServlet extends HttpServlet {
   private static final Logger log = LoggerFactory.getLogger(VelocityStaticServlet.class);
   private static final Map<String, String> MIME_TYPES = Maps.newHashMap();
+  private static final String STATIC_PATH_PREFIX = "static/";
 
   static {
     MIME_TYPES.put("html", "text/html");
     MIME_TYPES.put("htm", "text/html");
     MIME_TYPES.put("js", "application/x-javascript");
     MIME_TYPES.put("css", "text/css");
-    MIME_TYPES.put("rtf", "text/rtf");
-    MIME_TYPES.put("txt", "text/plain");
-    MIME_TYPES.put("text", "text/plain");
-    MIME_TYPES.put("pdf", "application/pdf");
-    MIME_TYPES.put("jpeg", "image/jpeg");
-    MIME_TYPES.put("jpg", "image/jpeg");
-    MIME_TYPES.put("gif", "image/gif");
-    MIME_TYPES.put("png", "image/png");
-    MIME_TYPES.put("tiff", "image/tiff");
-    MIME_TYPES.put("tif", "image/tiff");
-    MIME_TYPES.put("svg", "image/svg+xml");
   }
 
+  private static final Set<String> VELOCITY_STATIC_TYPES = Set.of("html", "htm", "js", "css");
+
   private static String contentType(final String name) {
     final int dot = name.lastIndexOf('.');
     final String ext = 0 < dot ? name.substring(dot + 1) : "";
@@ -69,14 +66,35 @@
     return type != null ? type : "application/octet-stream";
   }
 
+  private byte[] read(PluginEntry pluginEntry) throws IOException {
+    try (InputStream raw = plugin.getContentScanner().getInputStream(pluginEntry)) {
+      final ByteArrayOutputStream out = new ByteArrayOutputStream();
+      IOUtils.copy(raw, out);
+      out.flush();
+      return out.toByteArray();
+    }
+  }
+
   private static byte[] readResource(final Resource p) throws IOException {
-    try (InputStream in = p.getResourceLoader().getResourceStream(p.getName());
+    try (Reader in =
+            p.getResourceLoader().getResourceReader(p.getName(), StandardCharsets.UTF_8.name());
         ByteArrayOutputStream byteOut = new ByteArrayOutputStream()) {
       IOUtils.copy(in, byteOut);
       return byteOut.toByteArray();
     }
   }
 
+  private byte[] compress(PluginEntry pluginEntry) throws IOException {
+    try (InputStream raw = plugin.getContentScanner().getInputStream(pluginEntry)) {
+      final ByteArrayOutputStream out = new ByteArrayOutputStream();
+      final GZIPOutputStream gz = new GZIPOutputStream(out);
+      IOUtils.copy(raw, gz);
+      gz.finish();
+      gz.flush();
+      return out.toByteArray();
+    }
+  }
+
   private static byte[] compress(final byte[] raw) throws IOException {
     final ByteArrayOutputStream out = new ByteArrayOutputStream();
     final GZIPOutputStream gz = new GZIPOutputStream(out);
@@ -87,16 +105,22 @@
   }
 
   private final RuntimeInstance velocity;
+  private final SiteStaticDirectoryServlet siteStaticServlet;
+  private final Plugin plugin;
 
   @Inject
   VelocityStaticServlet(
-      @Named("PluginRuntimeInstance") final Provider<RuntimeInstance> velocityRuntimeProvider) {
+      @Named("PluginRuntimeInstance") final Provider<RuntimeInstance> velocityRuntimeProvider,
+      SiteStaticDirectoryServlet siteStaticServlet,
+      Plugin plugin) {
     this.velocity = velocityRuntimeProvider.get();
+    this.siteStaticServlet = siteStaticServlet;
+    this.plugin = plugin;
   }
 
   private Resource local(final HttpServletRequest req) {
     final String name = req.getPathInfo();
-    if (name.length() < 2 || !name.startsWith("/") || isUnreasonableName(name)) {
+    if (name.length() < 2 || !name.startsWith("/")) {
       // Too short to be a valid file name, or doesn't start with
       // the path info separator like we expected.
       //
@@ -112,6 +136,24 @@
     }
   }
 
+  private boolean isVelocityStaticResource(String resourceName) {
+    final int dot = resourceName.lastIndexOf('.');
+    final String ext = 0 < dot ? resourceName.substring(dot + 1) : "";
+    return VELOCITY_STATIC_TYPES.contains(ext.toLowerCase());
+  }
+
+  private String resourceName(HttpServletRequest req) {
+    final String name = req.getPathInfo();
+    if (name.length() < 2 || !name.startsWith("/") || isUnreasonableName(name)) {
+      // Too short to be a valid file name, or doesn't start with
+      // the path info separator like we expected.
+      //
+      return null;
+    }
+
+    return name.substring(1);
+  }
+
   private static boolean isUnreasonableName(String name) {
     if (name.charAt(name.length() - 1) == '/') return true; // no suffix
     if (name.indexOf('\\') >= 0) return true; // no windows/dos stlye paths
@@ -131,29 +173,68 @@
 
   @Override
   protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
-    final Resource p = local(req);
-    if (p == null) {
+      throws IOException, ServletException {
+    String resourceName = resourceName(req);
+    if (isUnreasonableName(resourceName) || !resourceName.startsWith(STATIC_PATH_PREFIX)) {
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
 
-    final String type = contentType(p.getName());
-    final byte[] tosend;
-    if (!type.equals("application/x-javascript") && RequestUtil.acceptsGzipEncoding(req)) {
-      rsp.setHeader("Content-Encoding", "gzip");
-      tosend = compress(readResource(p));
-    } else {
-      tosend = readResource(p);
+    if (isVelocityStaticResource(resourceName)) {
+      final Resource p = local(req);
+      if (p == null) {
+        CacheHeaders.setNotCacheable(rsp);
+        rsp.setStatus(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      final String type = contentType(p.getName());
+      final byte[] tosend;
+      if (!type.equals("application/x-javascript") && RequestUtil.acceptsGzipEncoding(req)) {
+        rsp.setHeader("Content-Encoding", "gzip");
+        tosend = compress(readResource(p));
+      } else {
+        tosend = readResource(p);
+      }
+
+      CacheHeaders.setCacheable(req, rsp, 12, TimeUnit.HOURS);
+      rsp.setDateHeader("Last-Modified", p.getLastModified());
+      rsp.setContentType(type);
+      rsp.setContentLength(tosend.length);
+      try (OutputStream out = rsp.getOutputStream()) {
+        out.write(tosend);
+      }
     }
 
-    CacheHeaders.setCacheable(req, rsp, 12, TimeUnit.HOURS);
-    rsp.setDateHeader("Last-Modified", p.getLastModified());
-    rsp.setContentType(type);
-    rsp.setContentLength(tosend.length);
-    try (OutputStream out = rsp.getOutputStream()) {
-      out.write(tosend);
+    Optional<PluginEntry> jarResource = plugin.getContentScanner().getEntry(resourceName);
+    if (jarResource.isPresent()) {
+      final String type = contentType(resourceName);
+      final byte[] tosend;
+      if (!type.equals("application/x-javascript") && RequestUtil.acceptsGzipEncoding(req)) {
+        rsp.setHeader("Content-Encoding", "gzip");
+        tosend = compress(jarResource.get());
+      } else {
+        tosend = read(jarResource.get());
+      }
+
+      CacheHeaders.setCacheable(req, rsp, 12, TimeUnit.HOURS);
+      rsp.setContentType(type);
+      rsp.setContentLength(tosend.length);
+      try (OutputStream out = rsp.getOutputStream()) {
+        out.write(tosend);
+      }
+    } else {
+
+      HttpServletRequestWrapper mappedReq =
+          new HttpServletRequestWrapper(req) {
+
+            @Override
+            public String getPathInfo() {
+              return super.getPathInfo().substring(STATIC_PATH_PREFIX.length());
+            }
+          };
+      siteStaticServlet.service(mappedReq, rsp);
     }
   }
 }
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/wizard/AccountController.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/wizard/AccountController.java
index 23edcbc..fc578af 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/wizard/AccountController.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/wizard/AccountController.java
@@ -51,7 +51,7 @@
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.github.GHKey;
 import org.kohsuke.github.GHMyself;
diff --git a/github-plugin/src/main/resources/Documentation/config.md b/github-plugin/src/main/resources/Documentation/config.md
index e8fc5fa..e2ea0a3 100644
--- a/github-plugin/src/main/resources/Documentation/config.md
+++ b/github-plugin/src/main/resources/Documentation/config.md
@@ -7,7 +7,8 @@
 library to work properly.
 
 GitHub OAuth library rely on Gerrit HTTP authentication defined during the standard
-Gerrit init steps.
+Gerrit init steps. It also requires GitHub OAuth token cipher configuration
+(details `Key configuration` section) but provides convenient defaults for it.
 See below a sample session of relevant init steps for a default
 configuration pointing to the Web GitHub instance:
 
@@ -36,6 +37,15 @@
    ClientSecret                   []: f82c3f9b3802666f2adcc4c8cacfb164295b0a99
    confirm password : 
    HTTP Authentication Header     [GITHUB_USER]: 
+
+   *** GitHub OAuth token cipher configuration
+   ***
+
+   Configuring GitHub OAuth token cipher under 'current' key id
+   Password file or device        [gerrit/data/github-plugin/default.key]:
+   New password (16 bytes long) was generated under 'gerrit/data/github-plugin/default.key' file.
+   The algorithm to be used to encrypt the provided password [AES]:
+   The algorithm to be used for encryption/decryption [AES/ECB/PKCS5Padding]:
 ```
 
 Configuration
@@ -83,4 +93,57 @@
     * 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. Encryption configuration is a
+mandatory step performed during the plugin init (that also provides convenient
+defaults). The Gerrit admin can introduce its own cipher configuration
+(already in init step) by setting the following parameters.
+
+github-key.<key-id>.passwordDevice
+: The device or file where to retrieve the encryption passphrase.\
+This is a required parameter for `key-id` configuration.
+
+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>`.
+
+*Notes:*
+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.
+
+Plugin will not start if no `github-key.<key-id>` section, marked as current,
+exists in configuration. One needs to either configure it manually or call init
+for a default configuration to be created.
diff --git a/github-plugin/src/main/resources/static/scope.html b/github-plugin/src/main/resources/static/scope.html
index 5b46f4c..113b5b5 100644
--- a/github-plugin/src/main/resources/static/scope.html
+++ b/github-plugin/src/main/resources/static/scope.html
@@ -55,7 +55,7 @@
                             #set ( $scopeItems = $config.scopes.get($scope) )
                             #foreach ( $scopeItem in $scopeItems )
                                 $scopeItem.description
-                                #if ( $velocityCount < $scopeItems.size())
+                                #if ( $foreach.count < $scopeItems.size())
                                     ,&nbsp;
                                 #end
                             #end
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>